Compare commits

...

191 Commits

Author SHA1 Message Date
f560a62c8f Download fixes (#63)
* refactor: Rename StoredManifest to DropData

Signed-off-by: quexeky <git@quexeky.dev>

* fix: Downloads when resuming would truncate files which had not been finished

Signed-off-by: quexeky <git@quexeky.dev>

* chore: Didn't import debug macro

Signed-off-by: quexeky <git@quexeky.dev>

* fix: Download chunks with wrong indexes

Migrated to using checksums as indexes instead

Signed-off-by: quexeky <git@quexeky.dev>

* feat: Resume download button

Also added DBWrite and DBRead structs to make database management easier

Signed-off-by: quexeky <git@quexeky.dev>

* feat: Download resuming

Signed-off-by: quexeky <git@quexeky.dev>

* feat: Resume button and PartiallyInstalled status

Signed-off-by: quexeky <git@quexeky.dev>

* feat: Download validation

Signed-off-by: quexeky <git@quexeky.dev>

* chore: Ran cargo fix & cargo fmt

Signed-off-by: quexeky <git@quexeky.dev>

* fix: download validation, installs, etc

* chore: version bump

---------

Signed-off-by: quexeky <git@quexeky.dev>
Co-authored-by: quexeky <git@quexeky.dev>
2025-07-14 16:31:06 +10:00
2874b9776b fix: Accidentally moved request when setting the header
Signed-off-by: quexeky <git@quexeky.dev>
2025-06-25 09:17:06 +10:00
afcd4e916f chore: bump version to 0.3.0-rc-4 2025-06-25 09:05:08 +10:00
885fa42ecc fix: Move Authorization header generation to download_game_chunk()
Signed-off-by: quexeky <git@quexeky.dev>
2025-06-25 06:53:42 +10:00
6d295bd47f fix: Broken README path
Signed-off-by: quexeky <git@quexeky.dev>
2025-06-07 06:37:14 +10:00
c3ee09af85 fix: Update broken README link in docs
Signed-off-by: quexeky <git@quexeky.dev>
2025-06-07 06:35:57 +10:00
0ce55e12c5 fix: Re-update the user and app status when recieve_handshake is called (#54)
Also enabled assetProtocol for better caching in general

Signed-off-by: quexeky <git@quexeky.dev>
2025-06-06 12:09:44 +10:00
86bce1c68d Release: v0.3.0-rc-3 (#51) 2025-06-06 09:25:44 +10:00
924e4e334c Database not being properly serialised with rpm_serde (#48)
Signed-off-by: quexeky <git@quexeky.dev>
2025-06-05 17:22:22 +10:00
065eb2356a fix: database corrupted on every startup (#40) 2025-06-01 19:53:24 +10:00
689e9ad890 fix: add new dependencies to linux build 2025-05-28 20:51:32 +10:00
7c35ed73aa fix: Folders can now be copied too
Signed-off-by: quexeky <git@quexeky.dev>
2025-05-28 20:48:34 +10:00
8f261a5dac chore: Add extract() function
Signed-off-by: quexeky <git@quexeky.dev>
2025-05-28 20:48:34 +10:00
67b6f2aa2e chore: Initial path normalisation & parsing with backup generation
Signed-off-by: quexeky <git@quexeky.dev>
2025-05-28 20:47:43 +10:00
11e2b3fe8a fix: regenerate lockfile 2025-05-28 20:37:26 +10:00
eba224f998 fix: remove memd-exec dependency 2025-05-28 20:22:18 +10:00
d045385a5d build: 0.3.0-rc-2 2025-05-28 20:09:58 +10:00
d878806ade Merge branch 'compat' into develop
Signed-off-by: quexeky <git@quexeky.dev>
2025-05-28 11:24:30 +10:00
b71081006e refactor: Reorganise file structure
Signed-off-by: quexeky <git@quexeky.dev>
2025-05-28 11:19:48 +10:00
c9e1ed78eb refactor: Delete downloadable_metadata.rs
Signed-off-by: quexeky <git@quexeky.dev>
2025-05-28 09:18:33 +10:00
446aa70b0b inprogress: compat 2025-05-28 09:07:09 +10:00
1d0b81078a feat: Add "NO_TRAY_ICON" env option
Signed-off-by: quexeky <git@quexeky.dev>
2025-05-27 12:13:49 +10:00
5251a56c3c feat: add arm linux builds 2025-05-25 11:46:24 +10:00
eeca8a7a98 chore(tailscale): Add test
Signed-off-by: quexeky <git@quexeky.dev>
2025-05-16 15:01:50 +10:00
365cdaf311 feat(tailscale): Add TailscaleListener and TailscaleConn
Needs testing on a native windows machine

Signed-off-by: quexeky <git@quexeky.dev>
2025-05-16 12:57:11 +10:00
2957773179 fix: remove reqwest default-features to compile without openssl 2025-05-15 21:03:31 +10:00
15e5fe4dc0 fix: move to reqwest rustls 2025-05-15 20:58:48 +10:00
2dc0a78354 fix: update cargo lock 2025-05-15 20:53:04 +10:00
51c480f245 feat: inline capability registration 2025-05-15 16:05:34 +10:00
95d223e2b2 feat(tailscale): Add wrapper around libtailscale with Tailscale struct
Signed-off-by: quexeky <git@quexeky.dev>
2025-05-15 15:23:20 +10:00
790e8c2afe feat: move to native_model to allow for database upgrades 2025-05-15 10:13:24 +10:00
02edb2cbc1 chore: libtailscale rust build 2025-05-14 10:01:26 +10:00
4b4c0734ec fix: windows builds 2025-05-10 16:41:37 +10:00
e75e0044fb fix: windows launching 2025-05-10 15:38:20 +10:00
65561abdab fix: update object id paths for new server 2025-05-10 15:25:40 +10:00
fed3e08dce fix: re-add minimise button 2025-05-10 14:22:21 +10:00
b0b1e397b1 fix: install dir flow 2025-05-10 09:02:59 +10:00
7b443818d1 fix: github ci and dialogtitle issue 2025-05-08 08:25:10 +10:00
fa4a881cc0 fix: bump tauri.config.json to 0.3.0-rc-1 2025-05-07 14:36:54 +10:00
4f16a6e6b2 fix: remove nightly trigger
it creates releases
2025-05-07 14:29:27 +10:00
47d9e9949b feat: bump app versions to 0.3.0-rc-1 2025-05-07 14:28:11 +10:00
a643d6102b fix: switch to rust nightly 2025-05-07 13:51:27 +10:00
a71ff160c2 feat: add github build 2025-05-07 12:21:03 +10:00
a53a566792 feat: cleanup settings menu and fix styles 2025-05-01 13:36:52 +10:00
ac6b034501 fix: error with game options for remote games 2025-05-01 12:26:41 +10:00
5ef20f7a57 chore(library): Update error type on update_game_configuration 2025-04-28 11:31:54 +10:00
8e5e3b2715 fix: some of GitHub's dependabot alerts 2025-04-27 21:15:32 +10:00
0f717d51d0 feat: launch options 2025-04-27 21:07:39 +10:00
4941f2a6fa feat: better error message if cannot connect to provided url 2025-04-26 01:06:03 +10:00
40eb19cf8b feat: add iframe store page 2025-04-08 16:17:03 +10:00
6b9b9e3606 feat: add backend for template launching 2025-04-07 13:52:52 +10:00
3e074abc0a feat: improve errors and include installed games in library 2025-04-05 15:36:53 +11:00
1fdf569278 fix: offline game status, user widget and use binary-encoding 2025-04-04 11:07:10 +11:00
77251a6524 feat: better client name w/ hostname 2025-04-04 10:14:23 +11:00
137b71b3ba feat: switch to shell-based command launching
note: needs error handling
2025-04-02 20:04:14 +11:00
569ba4243c feat: add offline widget & remove openssl in favour of droplet-rs 2025-04-02 11:00:39 +11:00
834f52d024 fix: macos and ui 2025-03-15 15:05:35 +11:00
1ce6be80db fix(collections): Ensured that all internal collection commands use and send the correct data
Signed-off-by: quexeky <git@quexeky.dev>
2025-03-11 20:35:43 +11:00
19c8fc24aa chore(collections): Slightly fixed return value for collections
Signed-off-by: quexeky <git@quexeky.dev>
2025-03-11 19:26:05 +11:00
4239215451 feat(collections): Added all internal collections commands
Signed-off-by: quexeky <git@quexeky.dev>
2025-03-11 12:34:56 +11:00
9614af7f03 feat(collections): Added fetch_collections function
Signed-off-by: quexeky <git@quexeky.dev>
2025-03-11 10:46:16 +11:00
639d3b4630 fix: refactoring and error handling 2025-02-20 21:19:54 +11:00
cdcd69391d Merge remote-tracking branch 'aden/develop' into develop 2025-02-18 14:45:09 +11:00
8520b255a3 style(library): Re-designed Library UI with new features 2025-02-15 16:41:32 +10:30
d9c4f7aa75 feat(library): Reactive library updating
Signed-off-by: quexeky <git@quexeky.dev>
2025-02-12 10:00:45 +11:00
316a3742eb fix(cache): Added proper error handling to fetch_object(_offline) 2025-02-12 10:00:45 +11:00
b9df197534 feat(cache): Caching objects which use the useObject tauri command 2025-02-12 10:00:45 +11:00
5c479cb283 chore(cache): Added fetch_drop_object command
Signed-off-by: quexeky <git@quexeky.dev>
2025-02-12 10:00:45 +11:00
4c59c5d6c1 feat(cache): Implemented caching for game metadata 2025-02-12 10:00:45 +11:00
9977107374 fix(cache): Bug where games would not remove themselves from the list of installed applications when being uninstalled 2025-02-12 10:00:45 +11:00
2690c3019d chore: Various formatting 2025-02-12 10:00:45 +11:00
2a1a7326d0 feat(cache): Added forceOffline in settings and caching games & library 2025-02-12 10:00:45 +11:00
f33ca95bdf feat(cache): Added offline!() macro to manage online and offline function distinctions
See fetch_library command for example
2025-02-12 10:00:45 +11:00
bb23e88ead chore: Swapped over to using a macro with an offline mode
Signed-off-by: quexeky <git@quexeky.dev>
2025-02-12 10:00:45 +11:00
810fbdfe49 chore: Progress on caching 2025-02-12 10:00:45 +11:00
e204ff30b4 fix: Removed unnecessary nightly feature try_trait_v2 2025-02-12 10:00:45 +11:00
501145c5d9 fix(downloads): Fix rearranging download queue throwing error 2025-02-12 10:00:45 +11:00
dca5f65e89 chore: Version bump 2025-02-12 10:00:45 +11:00
00f55ff3ae Merge branch 'main' into develop 2025-01-25 18:46:33 +11:00
52c70052a4 Update changelog.md 2025-01-25 14:35:17 +11:00
7a0cf4fbb6 fix(logging): Restored RUST_LOG env functionality 2025-01-25 14:34:08 +11:00
76bae3d926 fix(library): Added "LIbrary Failed to Update" content to recover from library load fail 2025-01-24 22:35:09 +11:00
53234d283e feat(settings): Made save button include user feedback & only allow numeric characters 2025-01-24 13:01:59 +11:00
3e10f1749a Update .gitlab-ci.yml with artifacts 2025-01-21 09:09:02 +11:00
6d7630e7c0 Update .gitlab-ci.yml 2025-01-21 08:41:51 +11:00
b6a54c0d09 chore: Update .gitlab-ci.yml 2025-01-21 08:04:29 +11:00
9897698322 build: Version bump & appimage build 2025-01-20 23:13:49 +11:00
4ef49cc832 fix: Games not launching due to string semantics 2025-01-20 23:12:57 +11:00
6ad383799d feat(games): Added multi-argument game launch and setup support 2025-01-20 20:03:44 +11:00
e0ea8c9a57 chore: Apply stashed changes 2025-01-20 18:22:24 +11:00
4fc0855ba1 fix(game downloads): Added error handling for chunk request errors 2025-01-20 18:13:25 +11:00
f50818697f fix: Adding usize to completed_contexts_lock instead of &usize 2025-01-20 17:39:05 +11:00
39f2ebd2d6 fix(download agent): fixed completed indexes 2025-01-20 17:29:33 +11:00
89ea34c94e fix: assorted fixes 2025-01-20 16:46:57 +11:00
92729701c3 chore: Ran cargo clippy & cargo fmt 2025-01-20 08:55:19 +11:00
7d4651db69 feat(database): Ensure that any database issues are resolved by standalone functions
Functions are as follows:
- save_db()
- borrow_db_checked()
- borrow_db_mut_checked()
2025-01-20 08:24:06 +11:00
5db9ae5f98 fix(ui): modal stack doesn't cover whole app 2025-01-19 22:23:40 +11:00
4d8eadc491 fix(logs): add file & line to console logs 2025-01-19 22:23:09 +11:00
3ca87fc45b feat(downloads): re-enable checksums 2025-01-19 22:22:55 +11:00
21204dee69 fix(download manager): don't crash download manager if multiple errors
come in
2025-01-19 22:22:04 +11:00
cfc9d13cad style(logging): Ensured that all logs start with lowercase capital and have no trailing punctuation 2025-01-19 20:36:38 +11:00
5bb04dafdd feat(settings): Allow settings to update UI using fetch_settings command 2025-01-19 19:14:52 +11:00
23077040ce Revert "chore: Update .gitlab-ci.yml"
This reverts commit fc6bab9381.
2025-01-19 18:37:51 +11:00
b99ff67e69 chore(logging): Imported appropriate logging macros 2025-01-19 18:37:21 +11:00
f183a9d1a2 refactor(logging): Using more appropriate logging statements
Still probably needs some work, but that's enough for now
2025-01-19 18:30:16 +11:00
fc6bab9381 chore: Update .gitlab-ci.yml 2025-01-19 17:18:08 +11:00
170fde5e23 feat(errors): Using SerializeDisplay for better error management with Result 2025-01-19 17:17:51 +11:00
c2f54c1dbc fix: fix other metadata endpoints 2025-01-19 16:15:43 +11:00
d83aae6dc4 fix(install ui): stop loading on error 2025-01-19 15:44:00 +11:00
9a184a8f35 refactor(remote): Created separate function to generate requests 2025-01-19 15:09:35 +11:00
fd30b3e402 feat(progress): Added rolling progress window
Still needs tweaks on specific timings, as well as cleanup
2025-01-19 09:48:04 +11:00
cf19477d4d chore: Progress on rolling progress window 2025-01-19 08:41:20 +11:00
5f5cbd07c6 chore(downloads): Progress on terminator 2025-01-16 18:57:40 +11:00
0381b8b8cb chore(exit): Progress on cleanup and exit 2025-01-16 18:57:40 +11:00
9369ff14b8 chore(progress): Added rolling_progress_updates.rs
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-16 17:23:53 +11:00
7c3140e424 feat(logging): Added line numbers to file logging and highlighting to console 2025-01-16 16:48:31 +11:00
9e29aa7a76 fix(settings): Broken command invoke logic in settings/downloads.vue 2025-01-14 07:57:33 +11:00
604d5b5884 Implement better error system and segregate errors and commands (#23)
* chore: Progress on amend_settings command

Signed-off-by: quexeky <git@quexeky.dev>

* chore(errors): Progress on better error handling with segragation of files

* chore: Progress on amend_settings command

Signed-off-by: quexeky <git@quexeky.dev>

* chore(commands): Separated commands under each subdirectory into respective commands.rs files

Signed-off-by: quexeky <git@quexeky.dev>

* chore(errors): Almost all errors and commands have been segregated

* chore(errors): Added drop server error

Signed-off-by: quexeky <git@quexeky.dev>

* feat(core): Update to using nightly compiler

Signed-off-by: quexeky <git@quexeky.dev>

* chore(errors): More progress on error handling

Signed-off-by: quexeky <git@quexeky.dev>

* chore(errors): Implementing Try and FromResidual for UserValue

Signed-off-by: quexeky <git@quexeky.dev>

* refactor(errors): Segregated errors and commands from code, and made commands return UserValue struct

Signed-off-by: quexeky <git@quexeky.dev>

* fix(errors): Added missing files

* chore(errors): Convert match statement to map_err

* feat(settings): Implemented settings editing from UI

* feat(errors): Clarified return values from retry_connect command

* chore(errors): Moved autostart commands to autostart.rs

* chore(process manager): Converted launch_process function for games to use game_id

---------

Signed-off-by: quexeky <git@quexeky.dev>
2025-01-13 21:44:57 +11:00
245a84d20b feat(debug): use shift or DEBUG RUST_LOG to show Debug Info
* Update settings.vue to have a conditional debug page

* Update debug.rs to add RUST_LOG status fetching
2025-01-09 19:44:04 +11:00
f1c8bbf8dd refactor(compat): remove unnecessary compat code (#20)
* Delete pages/settings/compatibility.vue

* Update settings.vue

* Update debug.vue

* Update lib.rs

* Update compat.rs
2025-01-09 13:44:27 +11:00
60d0a48a1a fix(handle invalid database): use set_file_name instead of pushing to
strings
2025-01-09 12:27:32 +11:00
7ab53f3357 fix(metadata): update routes for new server 2025-01-09 12:11:39 +11:00
4e93eb440c feat(download manager): Added UI to change download threads
Co-authored-by: AdenMGB <140392385+AdenMGB@users.noreply.github.com>
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-06 20:56:19 +11:00
94cf6788d8 chore: More refining info!() statements
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-06 20:46:08 +11:00
3eda9799c5 chore: Removed tools/
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-06 20:06:01 +11:00
f29e989aff chore: Removed tests/
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-06 20:05:19 +11:00
182361e598 chore: General cleanup
- Changed some info!() statements to debug!() and warn!()
- Removed most Turbofish syntax cases
- Removed InvalidCodeError and replaced it with InvalidResponse

Signed-off-by: quexeky <git@quexeky.dev>
2025-01-06 20:04:34 +11:00
50f37fd022 chore: Moved generateGameMeta.ts to composables, using PathBuf instead of String for install_dirs
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-06 09:22:18 +11:00
5ea47d733b feat(settings): Added max_download_threads setting and separated settings from db
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-06 07:36:35 +11:00
2822b7a593 chore: More cleanup after cargo clippy
Also added some type efficiency improvements (using references where possible and added SliceDeque crate)

Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 21:08:27 +11:00
82804ebc67 refactor: Ran cargo clippy & fmt
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 20:29:15 +11:00
8aad64ffa7 fix(db): Added Settings component
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:44:18 +11:00
9e82a0b3c3 fix(game downloads): Accidentally was attempting to lock onto something that was already in scope
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:05:00 +11:00
6ea4cf2797 perf(game downloads): Moved some variable declarations outside of the spawned download thread
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:05:00 +11:00
165a9671fd chore(README): Updated README.md
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:04:47 +11:00
25ba200a5e feat(database): Added database corruption dialog
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:04:47 +11:00
32ae7d5385 feat(recovery): Added database recovery
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:04:47 +11:00
005bab2fb8 fix(kill game): Re-enabled killing games
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:04:47 +11:00
06d1e9ed95 refactor(downloads): Moved all files relevant to game downloads to their own directory
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:04:47 +11:00
a56ee25581 fix(uninstalling): Re-enabled uninstalling apps
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:04:21 +11:00
dceaa56ade fix(game launcher): Renamed game_id to id
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:04:07 +11:00
6159319172 feat(download manager): Added generic download manager
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:04:07 +11:00
8be1dd435c refactor(download manager): Removed Arc requirement for DownloadableMetadata
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:02:19 +11:00
cac612b176 refactor(download manager): Fully separate & generic download manager
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:02:19 +11:00
6568faaf4f refactor(download manager): Removed all references to anything outside of the DownloadManager
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:00:47 +11:00
ea70ec9453 chore(download manager): Added manage_go_signal command
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:00:47 +11:00
f64782e5d4 chore(download manager): Added manage_queue_signal
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:00:47 +11:00
a2e63aa2c8 chore(tool manager): Added ToolDownloadAgent
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:00:18 +11:00
78149bbb3c Revert "refactor(download manager): Moved manifest and stored_manifest to download_manager"
This reverts commit 8db2393346.
2025-01-05 19:00:18 +11:00
a846eed306 refactor(download manager): Moved manifest and stored_manifest to download_manager
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:00:18 +11:00
1a89135342 chore(download manager): Ensure that Downloadable is also send and sync
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:00:18 +11:00
0a2ac25b1c chore(download manager): Some easy cleanup of the download manager
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:00:18 +11:00
aed58e49bc refactor(download manager): Renamed GameDonwloadError to ApplicationDownloadError and moved
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 19:00:18 +11:00
881fcc6abe chore(download manager): Renamed most instances of "game" outside of actual game downloads
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 18:59:08 +11:00
b4d70a35b3 refactor(download manager): Added Downloadable trait and replaced references to GameDownloadAgent
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 18:52:00 +11:00
b6c64e56e5 refactor(download manager): Moved download manager to separate directory
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 18:52:00 +11:00
3299c71b3d chore(tool manager): Progress on adding tools
Going to try changing around the download manager to take a generic trait rather than specifically for game downloads

Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 18:49:32 +11:00
2c8164e54f feat(library ui): add installed ui in the library menu 2025-01-05 18:32:22 +11:00
02f8591a60 feat(settings): add debug page
* Create debug.rs

* Update settings.vue to add tab for debug

* Update main.scss to add light theme

* Update interface.vue to add light mode

* Create debug.vue

* Update debug.vue too add open log button

* Update lib.rs

* Update debug.rs

* Update debug.rs

* Update lib.rs

* Update lib.rs

* Update debug.rs

* Update debug.vue

* fix(debug): refactor and cleanup

* revert(theme): revert light theming

---------

Co-authored-by: DecDuck <declanahofmeyr@gmail.com>
2025-01-05 17:56:33 +11:00
0a0d9d6294 feat(account settings): Add signout functionality (#16)
* Create account.vue with logout button

* Update auth.rs to add signout command

* Update lib.rs to pass sign_out command to frontend
2025-01-01 13:23:18 +11:00
a17311a88d fix(download manager): fix incorrect error assumptions & update types 2024-12-31 00:08:05 +11:00
472eb1d435 squash(autostart): added adenmgb's autostart feature
Squashed commit of the following:

commit 085cd9481d
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 16:29:41 2024 +1030

    Update lib.rs for the DB sync of autostart

commit 86f2fb19bd
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 16:29:13 2024 +1030

    Update db.rs to accomidate the settings sync

commit ece11e7581
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 16:27:48 2024 +1030

    Update autostart.rs to include DB

commit 7ea8a24fdc
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:17:38 2024 +1030

    Add files via upload

commit af2f232d94
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:17:09 2024 +1030

    Delete src-tauri/Cargo.toml

commit 5d27b65612
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:15:42 2024 +1030

    Add files via upload

commit 2eea7b97a8
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:15:31 2024 +1030

    Delete src-tauri/src/lib.rs

commit 9a635a10d1
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:14:49 2024 +1030

    Add files via upload

commit 2fb049531a
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:13:37 2024 +1030

    Add files via upload

commit ea1be4d750
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:13:20 2024 +1030

    Delete pages/settings/index.vue
2024-12-30 17:31:03 +11:00
6b96e408b2 feat(process): shared child with stop command 2024-12-30 15:37:29 +11:00
c3f62222fe fix(deep links): Re-enabled deep links
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-30 14:18:42 +11:00
01e6162527 feat(process manager): Game kill tauri command
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-30 13:50:26 +11:00
88b2505e71 feat(Compat): Implemented spawning with umu (using umu-wrapper-lib)
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-30 13:12:27 +11:00
95f2174f8d fix(scrollbars): fix ugly scrollbars on edge webview 2024-12-29 20:45:45 +11:00
7c90d2b8fd fix(process): fix poorly designed parsing for executables with spaces 2024-12-28 10:13:26 +11:00
d7b0302bdd fix(library page): fix install button 2024-12-27 16:33:04 +11:00
3ccd44466f merge(SeeThruHead): add nvm rc
Signed-off-by: DecDuck <declanahofmeyr@gmail.com>
2024-12-27 14:33:23 +11:00
93b8b83c20 Add files via upload 2024-12-27 14:32:56 +11:00
1861659daa Delete pages/library.vue 2024-12-27 14:32:56 +11:00
327628b780 fix(install flow): clear stale data before requesting new 2024-12-27 14:32:56 +11:00
f4ac1c87cd feat(install modal): add note about more install dirs 2024-12-27 14:32:56 +11:00
03fa3646fa merge(adenmgb): library ui
Signed-off-by: DecDuck <declanahofmeyr@gmail.com>
2024-12-27 14:30:38 +11:00
a881d8e248 adds nvm rc! 2024-12-26 22:21:26 -05:00
dcb2c0f004 Add files via upload 2024-12-27 13:40:24 +10:30
c722a54132 Delete pages/library.vue 2024-12-27 13:39:50 +10:30
e72662c4a8 fix(install flow): clear stale data before requesting new 2024-12-27 14:04:55 +11:00
139bc0ca36 feat(install modal): add note about more install dirs 2024-12-27 14:03:06 +11:00
949acfc161 feat(auth): offer manual signin 2024-12-27 13:07:10 +11:00
9af0d08875 fix(auth initiate): add better error message 2024-12-27 12:15:30 +11:00
dcb1564568 chore: Ran cargo clippy
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-27 11:58:37 +11:00
1f899ec349 feat(download ui): add speed and time remaining information
closes #7

Co-authored-by: AdenMGB <140392385+AdenMGB@users.noreply.github.com>
2024-12-27 11:53:17 +11:00
6a8d0af87d fix(sign on): add message about nonce expiration 2024-12-26 21:30:08 +11:00
21835858f1 feat(downloads): lockless tracking of downloaded chunks 2024-12-26 17:41:10 +11:00
a135b1321c feat(process): better process management, including running state 2024-12-26 17:19:19 +11:00
ad92dbec08 feat(errors): better download manager errors + modal 2024-12-26 12:56:54 +11:00
85a08990c3 chore(metadata): update metadata 2024-12-26 12:56:26 +11:00
dd7f5675d8 feat(game): game uninstalling & partial compat 2024-12-26 11:59:26 +11:00
9ea2aa4997 chore(process manager): refactor for generic way to implement cross
platform launchers
2024-12-25 23:05:10 +11:00
141 changed files with 15164 additions and 5795 deletions

70
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,70 @@
name: 'publish'
on:
workflow_dispatch: {}
release:
types: [published]
# This can be used to automatically publish nightlies at UTC nighttime
# schedule:
# - cron: "0 2 * * *" # run at 2 AM UTC
# This workflow will trigger on each push to the `release` branch to create or update a GitHub release, build your app, and upload the artifacts to the release.
jobs:
publish-tauri:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # for Arm based macs (M1 and above).
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest' # for Intel based macs.
args: '--target x86_64-apple-darwin'
- platform: 'ubuntu-24.04' # for Tauri v1 you could replace this with ubuntu-20.04.
args: ''
- platform: 'ubuntu-24.04-arm'
args: '--target aarch64-unknown-linux-gnu'
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
with:
submodules: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libgtk2.0-dev libsoup3.0-dev
# webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
# You can remove the one that doesn't apply to your app to speed up the workflow a bit.
- name: install frontend dependencies
run: yarn install # change this to npm, pnpm or bun depending on which one you use.
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
releaseName: 'Auto-release v__VERSION__'
releaseBody: 'See the assets to download this version and install. This release was created automatically.'
releaseDraft: false
prerelease: true
args: ${{ matrix.args }}

View File

@ -13,9 +13,10 @@ build-linux:
- yarnpkg tauri build
- cp src-tauri/target/release/bundle/deb/*.deb .
- cp src-tauri/target/release/bundle/rpm/*.rpm .
- cp src-tauri/target/release/bundle/appimage/*.AppImage .
artifacts:
paths:
- "*.{deb,rpm}"
- "*.{deb,rpm,AppImage}"
build-windows:
stage: build

9
.gitmodules vendored Normal file
View File

@ -0,0 +1,9 @@
[submodule "drop-base"]
path = drop-base
url = https://github.com/drop-oss/drop-base
[submodule "src-tauri/tailscale/libtailscale"]
path = src-tauri/tailscale/libtailscale
url = https://github.com/tailscale/libtailscale.git
[submodule "src-tauri/umu/umu-launcher"]
path = src-tauri/umu/umu-launcher
url = https://github.com/Open-Wine-Components/umu-launcher.git

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
23

View File

@ -2,6 +2,19 @@
Drop app is the companion app for [Drop](https://github.com/Drop-OSS/drop). It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI.
## Running
Before setting up the drop app, be sure that you have a server set up.
The instructions for this can be found on the [Drop Docs](https://docs.droposs.org/docs/guides/quickstart)
## Current features
Currently supported are the following features:
- Signin (with custom server)
- Database registering & recovery
- Dynamic library fetching from server
- Installing & uninstalling games
- Download progress monitoring
- Launching / playing games
## Development
Install dependencies with `yarn`
@ -10,7 +23,7 @@ Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use she
To manually specify the logging level, add the environment variable `RUST_LOG=[debug, info, warn, error]` to `yarn tauri dev`:
e.g. `RUST_LOG=debug yarn taudi dev`
e.g. `RUST_LOG=debug yarn tauri dev`
## Contributing
Check the original [Drop repo](https://github.com/Drop-OSS/drop/blob/main/CONTRIBUTING.md) for contributing guidelines.

16
app.vue
View File

@ -1,12 +1,12 @@
<template>
<NuxtLayout class="select-none w-screen h-screen">
<NuxtPage />
<ModalStack />
</NuxtLayout>
<ModalStack />
</template>
<script setup lang="ts">
import "~/composables/queue";
import "~/composables/downloads.js";
import { invoke } from "@tauri-apps/api/core";
import { AppStatus } from "~/types";
@ -20,10 +20,18 @@ import {
const router = useRouter();
const state = useAppState();
state.value = await invoke("fetch_state");
try {
state.value = JSON.parse(await invoke("fetch_state"));
} catch (e) {
console.error("failed to parse state", e);
}
router.beforeEach(async () => {
state.value = await invoke("fetch_state");
try {
state.value = JSON.parse(await invoke("fetch_state"));
} catch (e) {
console.error("failed to parse state", e);
}
});
setupHooks();

View File

@ -60,3 +60,25 @@ $helvetica: (
src: url("/fonts/inter/InterVariable-Italic.ttf");
font-style: italic;
}
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {
scrollbar-width: 4px;
scrollbar-color: #52525b #00000000;
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 4px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: #52525b;
border-radius: 10px;
border: 3px solid #52525b;
}

View File

@ -1,5 +1,292 @@
## Release 0.2.0-beta
### Fixes
- Re-enabled killing games #005bab2
- fixed queue manipulation and waiting for downloads #01260f0
- fix logic error in detecting dir #04368ff
- absolute executable invoke #17759c4
- don't crash download manager if multiple errors come in #21204de
- clear stale data before requesting new #327628b
- fixed completed indexes #39f2ebd
- add file & line to console logs #4d8eadc
- Games not launching due to string semantics #4ef49cc
- Added error handling for chunk request errors #4fc0855
- Chunk counting logic error #5ba151f
- modal stack doesn't cover whole app #5db9ae5
- use set_file_name instead of pushing to strings #60d0a48
- use of completed signal, and pause/resuming #64d7f64
- add message about nonce expiration #6a8d0af
- Added "LIbrary Failed to Update" content to recover from library load fail #76bae3d
- Restored RUST_LOG env functionality #7a0cf4f
- initialise doesn't recreate default install dir #7a3841b
- update routes for new server #7ab53f3
- use vendored flag #7c8089e
- fix poorly designed parsing for executables with spaces #7c90d2b
- assorted fixes #89ea34c
- Added Settings component #8aad64f
- windows build #8d9234f
- fix ugly scrollbars on edge webview #95f2174
- windows shadow #9a8cc59
- add better error message #9af0d08
- Broken command invoke logic in settings/downloads.vue #9e29aa7
- Accidentally was attempting to lock onto something that was already in scope #9e82a0b
- fix incorrect error assumptions & update types #a17311a
- Re-enabled uninstalling apps #a56ee25
- types #af056c0
- fix other metadata endpoints #c2f54c1
- Re-enabled deep links #c3f6222
- added console as an appender #d12bf15
- remove unnecessary unstable feature #d5ac1b0
- fix install button #d7b0302
- stop loading on error #d83aae6
- use unix timestamp to avoid invalid characters in filename #dafce24
- Renamed game_id to id #dceaa56
- use chrono library to generate timestamps #e22e6d8
- clear stale data before requesting new #e72662c
- fix scrollbars on edge webview #f09605a
- update readme instructions #f0c47d8
- Adding usize to completed_contexts_lock instead of &usize #f508186
### Features
- Game kill tauri command #01e6162
- add debug page #02f8591
- Add signout functionality (#16) #0a0d9d6
- queue and library UIs #0a20139
- add note about more install dirs #139bc0c
- Using SerializeDisplay for better error management with Result #170fde5
- add pre-launch log to file #17f8d76
- Added option to change root directory #1aa52c0
- add speed and time remaining information #1f899ec
- lockless tracking of downloaded chunks #2183585
- quit button #239b8d5
- use shift or DEBUG RUST_LOG to show Debug Info #245a84d
- Added database corruption dialog #25ba200
- only allow downloads for supported platforms #269dcbb
- add installed ui in the library menu #2c8164e
- added file-based logging #2d4a7e8
- automatically fetch remote data if not available #2dedfbb
- Added database recovery #32ae7d5
- ability to add more download dirs #384f7a5
- re-enable checksums #3ca87fc
- background processes and close/open menu #3d60fd5
- launch games with log files #3f71149
- Download cancelling #450bca9
- refactoring and error message #469a2d6
- Added UI to change download threads #4e93eb4
- Made save button include user feedback & only allow numeric characters #53234d2
- download widget and queue fix #532d13e
- Pausing and resuming game downloads #55b7921
- Allow settings to update UI using fetch_settings command #5bb04da
- temporary queue ui and flamegraph instructions #5cbeb3b
- Added DownloadThreadControl struct #5e05e68
- Added max_download_threads setting and separated settings from db #5ea47d7
- Added generic download manager #6159319
- Added AgentInterfaceData to get information about all downloads in queue #63c3cc1
- debug queue interface #671d45f
- reduce scope of download agent #6a38ea3
- Added multi-argument game launch and setup support #6ad3837
- shared child with stop command #6b96e40
- Added function to take and set any game state #6bc6482
- Added line numbers to file logging and highlighting to console #7c3140e
- Separated chunk updates into individual counters #7d3c601
- Ensure that any database issues are resolved by standalone functions #7d4651d
- ui to install games #8670bca
- Implemented spawning with umu (using umu-wrapper-lib) #88b2505
- offer manual signin #949acfc
- better process management, including running state #a135b13
- Added Download Manager #a1ada07
- retry connnection on server unavailable #a53d838
- finish download dir CRUD interface #a580a46
- better download manager errors + modal #ad92dbe
- syncs state to disk to persist across reboots #b556842
- prevent default context menu and emit event on elements #c560656
- initial creation and logo update #d9a51cf
- Added manifest.json utility for persistent download progress #d9d0122
- game uninstalling & partial compat #dd7f567
- combined db and download interface improvements #de52dac
- update db state with ui and emit events #e4df4eb
- Generic function to set download state #f10d92d
- Convert DownloadThreadControlFlag to AtomicBool #f25bfed
- add note about more install dirs #f4ac1c8
- Added rolling progress window #fd30b3e
### Other Changes
- quexeky <git@quexeky.dev>
- Convert DATA_ROOT_DIR to Mutex #00b7179
- Converting DB access to a trait #01b092c
- Updated changelog #022330b
- Progress on cleanup and exit #0381b8b
- library ui #03fa364
- Scoping changes and removing qualifications #046ba64
- Moved all files relevant to game downloads to their own directory #06d1e9e
- SLowly integrating game_download into the FE. Started with using the manifest minimal example in the server (#1) #07379b2
- Ran cargo clippy & moved DownloadManagerInterface #075d6ec
- Made logging systems match #0a1dddf
- Some easy cleanup of the download manager #0a2ac25
- client now fetches user information from Drop server #0c0cfeb
- Included in AppStatus (Also trying to link to Issue #1)
- Accidentally serialized AppStatus and broke everything :/ #10791ed
- Removed debugging statements #10c8344
- Wrappers are the bane of my existence. Also here's the download cancelling logic. #13df631
- Merge branch 'error-handling' #1520471
- Updated README.md #165a967
- Removed unnecessary dependencies #1724449
- merge(download-manager) -> 'main' #172d6b0
- More refactoring and renaming camelCase struct definitions to snake_case #1742793
- General cleanup #182361e
- Delete pages/library.vue #1861659
- progress on more precise download control #18b9149
- Allowing some dead code features because they are there for future use (potentially) #191e62c
- Ensure that Downloadable is also send and sync #1a89135
- I think that download queuing is working #1ab61c8
- auth initiate, database and more #22b1aee
- Update .gitlab-ci.yml" #2307704
- More fleshing out on how specifically game downloads will work (#1) #23137dd
- Removed utils.rs #270bc8b
- Fixing some references to "id" vs "game_id" #27e5a8e
- More cleanup after cargo clippy #2822b7a
- Updated contributing link #2aa5b9c
- More fleshing out on how specifically game downloads will work #2b90de9
- Cleaning up downloads playing and pausing #2c7b5fb
- fixed multi-chunk downloads #2ec351f
- Clippy refactoring #2efe304
- remove unpacker mod statement #32067c0
- Progress on adding tools #3299c71
- Fixed bug with bad initial loading into store instead of auth #3923acf
- add nvm rc #3ccd444
- partial download manager #3dbf5ab
- Update .gitlab-ci.yml with artifacts #3e10f17
- Removed tools/ #3eda979
- Downloads should be fixed now #403ca65
- transient vs synced state now defined #42c0198
- added adenmgb's autostart feature #472eb1d
- better download defaults #4779383
- Progress on downloads. Currently working on parsing functions to be run asynchronously #496c6a5
- Ran cargo clippy & cargo fmt #4983b25
- handshakes #4bb33c8
- Convert DOWNLOAD_MAX_THREADS to const #4fc13a1
- Merge branch 'downloads' #50ed841
- Moved generateGameMeta.ts to composables, using PathBuf instead of String for install_dirs #50f37fd
- Added time debugging and fixed logging formatting #5243694
- Clippy changes #553bc37
- Queue is running game downloads sequentially now #5564d23
- migrate to new droplet ca system #556898f
- Add LICENSE #57a5737
- ran cargo clippy & cargo fmt #5e3d26b
- my own take on some BASED design decisions #5ed0833
- cleanup and game UI beginnings #5ef6b8e
- Progress on terminator #5f5cbd0
- Implement better error system and segregate errors and commands (#23) #604d5b5
- moved to completed index arr to help serialization #64ebc19
- Ran cargo clippy & cargo fmt #653717e
- Removed all references to anything outside of the DownloadManager #6568faa
- Merge remote-tracking branch 'origin/main' #68ca4a7
- swap file name and to binary encoding #694f2fd
- chore(polish & cleanup) #6cc0c67
- Update .gitlab-ci.yml #6d7630e
- Moved some variable declarations outside of the spawned download thread #6ea4cf2
- Encoding game IDs and versions #6ef444e
- restructing and renaming #7049673
- Converted to md5 #706f525
- Merge branch 'main' into downloads #714b968
- Semantic naming changes #725f16b
- Abstracted queue system #76b0975
- Moved manifest and stored_manifest to download_manager" #78149bb
- README update #78fc668
- Ensured everything is serializing/deserializing to camelCase #7a95b7f
- fixed some of quexeky's BASED design decisions #7e3da04
- Progress checker works #7fec00d
- Progress on refactoring and abiding by cargo clippy #816b427
- Added GAME_PAUSE_CHECK_INTERVAL value #8204795
- Ran cargo clippy & fmt #82804eb
- update metadata #85a0899
- Renamed most instances of "game" outside of actual game downloads #881fcc6
- Debugging & starting work on parsing manifest #89d2814
- slight ui/ux fixes and updates to auth protocol #8a2d23d
- Removed Arc requirement for DownloadableMetadata #8be1dd4
- compliant with new APIs #8f6f184
- Ran cargo clippy & cargo fmt #9272970
- Added rolling_progress_updates.rs #9369ff1
- Add files via upload #93b8b83
- More refining info!() statements #94cf678
- fixed windows issues #959dad3
- Starting p2p progress #97bb1fa
- Game downloads from the client are working (multithreaded) by parsing in gameID, GameVersion, and maxThreads from FE (#1) #984472e
- Version bump & appimage build #9897698
- Some progress on thread terminations #99beca4
- rename files to what they contain #99c8b39
- Created separate function to generate requests #9a184a8
- cleanup of lib and toml #9b1cfa7
- refactor for generic way to implement cross platform launchers #9ea2aa4
- Updated logging format #a213765
- fix(windows build) #a24cc8a
- Added ToolDownloadAgent #a2e63aa
- copy direct to disk #a628fc1
- Moved manifest and stored_manifest to download_manager #a846eed
- adds nvm rc! #a881d8e
- Reordered DownloadThreadControlFlag to agree with From<bool> #ab606e8
- ci/cd and patches for windows builds #ac1c3b6
- patch for not draggable windows during setup #ac66b20
- another stage of client authentication #ae4c65b
- Renamed GameDonwloadError to ApplicationDownloadError and moved #aed58e4
- Progress on write speeds & added debug statements #b065e10
- Updated logging #b3963b6
- Created file settings.rs #b47b7ea
- Added Downloadable trait and replaced references to GameDownloadAgent #b4d70a3
- Update .gitlab-ci.yml #b6a54c0
- Moved download manager to separate directory #b6c64e5
- Ran cargo fmt #b8cf44c
- Imported appropriate logging macros #b99ff67
- Merge branch 'main' into download-manager #bb60942
- Ran cargo clippy & cargo fmt #bd3deac
- beginnings of game state management #bf46dec
- Update Cargo.toml #c1fb39e
- migrated unpacking to rust zstd to conform with droplet #c46c54b
- More progress on checksums #c51e761
- Delete pages/library.vue #c722a54
- Merge branch 'downloads' (again) #c748aec
- migrate to nuxt and groundwork #c957744
- More debugging because apparently checksums are the bane of my existence. But it works and I was just an idiot #c9d9d2e
- Fully separate & generic download manager #cac612b
- Progress on rolling progress window #cf19477
- Ensured that all logs start with lowercase capital and have no trailing punctuation #cfc9d13
- Validated that loading data works #d21b1d2
- Mostly finished with checksums. Just merging main in at the same time #d39e7cb
- Ran cargo clippy #dcb1564
- Add files via upload #dcb2c0f
- Theoretically adding queue support and optimistic manifest downloading (#1). Needs tests when actual functions are implemented #dcd8fa8
- Merge remote-tracking branch 'origin/downloads' into downloads #dd23ca8
- Debugging line #ddc585d
- Re-enabled closing the window and some more renaming #defba51
- drop no longer freaks out if server is unavailable on startup #df88395
- Apply stashed changes #e0ea8c9
- Merge remote-tracking branch 'origin/downloads' into downloads #e4e605b
- convert to more sensible permission schema #e504c00
- Update on GameDownload #e71e4cf
- reorganisation, cleanup and new nonce protocol #e828bca
- rustix fs feature #e9805a8
- Added manage_go_signal command #ea70ec9
- Drop will no longer crash when the server goes down #eb3311a
- Made all errors type-based #ec2f414
- Added description on how the DownloadManager works #f029cbf
- Using more appropriate logging statements #f183a9d
- remove unnecessary compat code (#20) #f1c8bbf
- Manifests are now being parsed successfully #f28c880
- Removed tests/ #f29e989
- I think that downloads are working. Need to test and set decent file locations now #f388237
- Just debugging tauri's damn Sync command features #f60ca2b
- fixes and patches for merged changes #f6476bc
- Added manage_queue_signal #f64782e
- initial commit #f6cd7c3
- Update .gitlab-ci.yml #fc6bab9
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
## Release 0.1.0-beta
### Fixes

View File

@ -0,0 +1,31 @@
<template>
<div>
<label for="launch" class="block text-sm/6 font-medium text-zinc-100"
>Launch string template</label
>
<div class="mt-2">
<input
type="text"
name="launch"
id="launch"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-zinc-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
placeholder="{}"
aria-describedby="launch-description"
v-model="model!!.launchString"
/>
</div>
<p class="mt-2 text-sm text-zinc-400" id="launch-description">
Override the launch string. Passed to system's default shell, and replaces
"{}" with the command to start the game.
<span class="font-semibold text-zinc-200"
>Leaving it blank will cause the game not to start.</span
>
</p>
</div>
</template>
<script setup lang="ts">
import type { FrontendGameConfiguration } from "~/composables/game";
const model = defineModel<FrontendGameConfiguration>();
</script>

View File

@ -0,0 +1,122 @@
<template>
<ModalTemplate size-class="max-w-4xl" v-model="open">
<template #default>
<div class="flex flex-row gap-x-4">
<nav class="flex flex-1 flex-col" aria-label="Sidebar">
<ul role="list" class="-mx-2 space-y-1">
<li v-for="(tab, tabIdx) in tabs" :key="tab.name">
<button
@click="() => (currentTabIndex = tabIdx)"
:class="[
tabIdx == currentTabIndex
? 'bg-zinc-800 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100',
'transition w-full group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
]"
>
<component
:is="tab.icon"
:class="[
tabIdx == currentTabIndex
? 'text-zinc-100'
: 'text-gray-400 group-hover:text-zinc-100',
'size-6 shrink-0',
]"
aria-hidden="true"
/>
{{ tab.name }}
</button>
</li>
</ul>
</nav>
<div class="border-l-2 border-zinc-800 w-full grow pl-4">
<component
v-model="configuration"
:is="tabs[currentTabIndex]?.page"
/>
</div>
</div>
<div v-if="saveError" class="mt-5 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ saveError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton
@click="() => save()"
:loading="saveLoading"
type="submit"
class="ml-2 w-full sm:w-fit"
>
Save
</LoadingButton>
<button
@click="() => (open = false)"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import type { Component } from "vue";
import {
RocketLaunchIcon,
ServerIcon,
TrashIcon,
XCircleIcon,
} from "@heroicons/vue/20/solid";
import Launch from "./GameOptions/Launch.vue";
import type { FrontendGameConfiguration } from "~/composables/game";
import { invoke } from "@tauri-apps/api/core";
const open = defineModel<boolean>();
const props = defineProps<{ gameId: string }>();
const game = await useGame(props.gameId);
const configuration: Ref<FrontendGameConfiguration> = ref({
launchString: game.version!!.launchCommandTemplate,
});
const tabs: Array<{ name: string; icon: Component; page: Component }> = [
{
name: "Launch",
icon: RocketLaunchIcon,
page: Launch,
},
{
name: "Storage",
icon: ServerIcon,
page: h("div"),
},
];
const currentTabIndex = ref(0);
const saveLoading = ref(false);
const saveError = ref<undefined | string>();
async function save() {
saveLoading.value = true;
try {
await invoke("update_game_configuration", {
gameId: game.game.id,
options: configuration.value,
});
open.value = false;
} catch (e) {
saveError.value = (e as unknown as string).toString();
}
saveLoading.value = false;
}
</script>

View File

@ -1,52 +1,111 @@
<template>
<button
type="button"
@click="() => buttonActions[props.status.type]()"
:class="[
<!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 -->
<div class="inline-flex divide-x divide-zinc-900">
<button type="button" @click="() => buttonActions[props.status.type]()" :class="[
styles[props.status.type],
'inline-flex uppercase font-display items-center gap-x-2 rounded-md px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]"
>
<component
:is="buttonIcons[props.status.type]"
class="-mr-0.5 size-5"
aria-hidden="true"
/>
{{ buttonNames[props.status.type] }}
</button>
showDropdown ? 'rounded-l-md' : 'rounded-md',
'inline-flex uppercase font-display items-center gap-x-2 px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]">
<component :is="buttonIcons[props.status.type]" class="-mr-0.5 size-5" aria-hidden="true" />
{{ buttonNames[props.status.type] }}
</button>
<Menu v-if="showDropdown" as="div" class="relative inline-block text-left grow">
<div class="h-full">
<MenuButton :class="[
styles[props.status.type],
'inline-flex w-full h-full justify-center items-center rounded-r-md px-1 py-2 text-sm font-semibold shadow-sm group',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]">
<ChevronDownIcon class="size-5" aria-hidden="true" />
</MenuButton>
</div>
<transition enter-active-class="transition ease-out duration-100" enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100" leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100" leave-to-class="transform opacity-0 scale-95">
<MenuItems
class="absolute right-0 z-[500] mt-2 w-32 origin-top-right rounded-md bg-zinc-900 shadow-lg ring-1 ring-zinc-100/5 focus:outline-none">
<div class="py-1">
<MenuItem v-slot="{ active }">
<button @click="() => emit('options')" :class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full block px-4 py-2 text-sm inline-flex justify-between',
]">
Options
<Cog6ToothIcon class="size-5" />
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<button @click="() => emit('uninstall')" :class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full block px-4 py-2 text-sm inline-flex justify-between',
]">
Uninstall
<TrashIcon class="size-5" />
</button>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
</div>
</template>
<script setup lang="ts">
import {
ArrowDownTrayIcon,
ChevronDownIcon,
PlayIcon,
QueueListIcon,
TrashIcon,
WrenchIcon,
} from "@heroicons/vue/20/solid";
import type { Component } from "vue";
import { GameStatusEnum, type GameStatus } from "~/types.js";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { Cog6ToothIcon, TrashIcon } from "@heroicons/vue/24/outline";
const props = defineProps<{ status: GameStatus }>();
const emit = defineEmits<{
(e: "install"): void;
(e: "play"): void;
(e: "launch"): void;
(e: "queue"): void;
(e: "uninstall"): void;
(e: "kill"): void;
(e: "options"): void;
(e: "resume"): void
}>();
const showDropdown = computed(
() =>
props.status.type === GameStatusEnum.Installed ||
props.status.type === GameStatusEnum.SetupRequired ||
props.status.type === GameStatusEnum.PartiallyInstalled
);
const styles: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]:
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600",
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500",
[GameStatusEnum.Queued]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Downloading]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.SetupRequired]:
"bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600",
"bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600 hover:bg-yellow-500",
[GameStatusEnum.Installed]:
"bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600",
[GameStatusEnum.Updating]: "",
[GameStatusEnum.Uninstalling]: "",
"bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600 hover:bg-green-500",
[GameStatusEnum.Updating]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Uninstalling]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Running]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.PartiallyInstalled]:
"bg-gray-600 text-white hover:bg-gray-500 focus-visible:outline-gray-600 hover:bg-gray-500"
};
const buttonNames: { [key in GameStatusEnum]: string } = {
@ -57,6 +116,8 @@ const buttonNames: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "Play",
[GameStatusEnum.Updating]: "Updating",
[GameStatusEnum.Uninstalling]: "Uninstalling",
[GameStatusEnum.Running]: "Stop",
[GameStatusEnum.PartiallyInstalled]: "Resume"
};
const buttonIcons: { [key in GameStatusEnum]: Component } = {
@ -67,15 +128,19 @@ const buttonIcons: { [key in GameStatusEnum]: Component } = {
[GameStatusEnum.Installed]: PlayIcon,
[GameStatusEnum.Updating]: ArrowDownTrayIcon,
[GameStatusEnum.Uninstalling]: TrashIcon,
[GameStatusEnum.Running]: PlayIcon,
[GameStatusEnum.PartiallyInstalled]: ArrowDownTrayIcon
};
const buttonActions: { [key in GameStatusEnum]: () => void } = {
[GameStatusEnum.Remote]: () => emit("install"),
[GameStatusEnum.Queued]: () => emit("queue"),
[GameStatusEnum.Downloading]: () => emit("queue"),
[GameStatusEnum.SetupRequired]: () => {},
[GameStatusEnum.Installed]: () => emit("play"),
[GameStatusEnum.SetupRequired]: () => emit("launch"),
[GameStatusEnum.Installed]: () => emit("launch"),
[GameStatusEnum.Updating]: () => emit("queue"),
[GameStatusEnum.Uninstalling]: () => {},
[GameStatusEnum.Uninstalling]: () => { },
[GameStatusEnum.Running]: () => emit("kill"),
[GameStatusEnum.PartiallyInstalled]: () => emit("resume")
};
</script>

View File

@ -11,7 +11,7 @@
v-for="(nav, navIdx) in navigation"
:class="[
'transition uppercase font-display font-semibold text-md',
navIdx === currentPageIndex
navIdx === currentNavigation
? 'text-zinc-100'
: 'text-zinc-400 hover:text-zinc-200',
]"
@ -28,9 +28,7 @@
/>
<div class="inline-flex items-center">
<ol class="inline-flex gap-3">
<HeaderQueueWidget
:object="currentQueueObject"
/>
<HeaderQueueWidget :object="currentQueueObject" />
<li v-for="(item, itemIdx) in quickActions">
<HeaderWidget
@click="item.action"
@ -39,21 +37,23 @@
<component class="h-5" :is="item.icon" />
</HeaderWidget>
</li>
<OfflineHeaderWidget v-if="state.status === AppStatus.Offline" />
<HeaderUserWidget />
</ol>
</div>
</div>
<WindowControl class="h-16 w-16 p-4" />
<WindowControl />
</div>
</template>
<script setup lang="ts">
import { BellIcon, UserGroupIcon } from "@heroicons/vue/16/solid";
import type { NavigationItem, QuickActionNav } from "../types";
import { AppStatus, type NavigationItem, type QuickActionNav } from "../types";
import HeaderWidget from "./HeaderWidget.vue";
import { getCurrentWindow } from "@tauri-apps/api/window";
const window = getCurrentWindow();
const state = useAppState();
const navigation: Array<NavigationItem> = [
{
@ -78,7 +78,7 @@ const navigation: Array<NavigationItem> = [
},
];
const currentPageIndex = useCurrentNavigationIndex(navigation);
const { currentNavigation } = useCurrentNavigationIndex(navigation);
const quickActions: Array<QuickActionNav> = [
{

View File

@ -1,5 +1,5 @@
<template>
<button class="transition h-10 w-10 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 p-2">
<button class="transition h-full aspect-square text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 p-[1.1rem]">
<slot />
</button>
</template>

View File

@ -49,17 +49,20 @@
Admin Dashboard
</a>
</MenuItem>
<MenuItem v-for="(nav, navIdx) in navigation" v-slot="{ active, close }">
<MenuItem
v-for="(nav, navIdx) in navigation"
v-slot="{ active, close }"
>
<button
@click="() => navigate(close, nav)"
@click="() => navigate(close, nav)"
:href="nav.route"
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'transition text-left block px-4 py-2 text-sm',
]"
>
{{ nav.label }}</button
>
{{ nav.label }}
</button>
</MenuItem>
</div>
</PanelWidget>
@ -80,27 +83,22 @@ const open = ref(false);
const router = useRouter();
router.afterEach(() => {
open.value = false;
})
});
const state = useAppState();
const profilePictureUrl: string = await invoke("gen_drop_url", {
path: `/api/v1/object/${state.value.user?.profilePicture}`,
});
const profilePictureUrl: string = await useObject(
state.value.user?.profilePictureObjectId ?? ""
);
const adminUrl: string = await invoke("gen_drop_url", {
path: "/admin",
});
function navigate(close: () => any, to: NavigationItem){
function navigate(close: () => any, to: NavigationItem) {
close();
router.push(to.route);
}
const navigation: NavigationItem[] = [
{
label: "Account settings",
route: "/account",
prefix: "",
},
{
label: "App settings",
route: "/settings",
@ -110,6 +108,6 @@ const navigation: NavigationItem[] = [
label: "Quit Drop",
route: "/quit",
prefix: "",
}
]
},
];
</script>

View File

@ -42,6 +42,31 @@
</span>
</button>
<div class="mt-5" v-if="offerManual">
<h1 class="text-zinc-100 font-semibold">Having trouble?</h1>
<p class="mt-1 text-zinc-400 text-sm">
You can manually enter the token from your web browser.
</p>
<div class="inline-flex gap-x-1 mt-2 w-full">
<input
id="token"
name="token"
type="text"
autocomplete="token"
required
class="grow block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
v-model="manualToken"
/>
<LoadingButton
:loading="manualLoading"
@click="() => continueManual_wrapper()"
class="w-fit"
>
Submit
</LoadingButton>
</div>
</div>
<div v-if="error" class="mt-5 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
@ -101,15 +126,40 @@ import { invoke } from "@tauri-apps/api/core";
const loading = ref(false);
const error = ref<string | undefined>();
let offerManualTimeout: NodeJS.Timeout | undefined;
const offerManual = ref(false);
const manualToken = ref("");
const manualLoading = ref(false);
async function auth() {
await invoke("auth_initiate");
}
function authWrapper_wrapper() {
error.value = undefined;
loading.value = true;
auth().catch((e) => {
loading.value = false;
error.value = e;
if (offerManualTimeout) clearTimeout(offerManualTimeout);
});
offerManualTimeout = setTimeout(() => {
offerManual.value = true;
}, 2000);
}
async function continueManual() {
await invoke("manual_recieve_handshake", { token: manualToken.value });
}
function continueManual_wrapper() {
loading.value = true;
continueManual()
.catch((e) => {
error.value = e;
})
.finally(() => {
loading.value = false;
});
}
</script>

View File

@ -0,0 +1,179 @@
<template>
<div>
<div
class="relative mb-3 transition-transform duration-300 hover:scale-105 active:scale-95"
>
<div
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
>
<MagnifyingGlassIcon class="h-5 w-5 text-zinc-400" aria-hidden="true" />
</div>
<input
type="text"
v-model="searchQuery"
class="block w-full rounded-lg border-0 bg-zinc-800/50 py-2 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-500 focus:bg-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
placeholder="Search library..."
/>
</div>
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5">
<NuxtLink
v-for="nav in filteredNavigation"
:key="nav.id"
:class="[
'transition-all duration-300 rounded-lg flex items-center py-2 px-3 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
nav.index === currentNavigation
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
: nav.isInstalled.value
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
]"
:href="nav.route"
>
<div class="flex items-center w-full gap-x-3">
<div
class="flex-none transition-transform duration-300 hover:-rotate-2"
>
<img
class="size-8 object-cover bg-zinc-900 rounded-lg transition-all duration-300 shadow-sm"
:src="icons[nav.id]"
alt=""
/>
</div>
<div class="flex flex-col flex-1">
<p
class="truncate text-xs font-display leading-5 flex-1 font-semibold"
>
{{ nav.label }}
</p>
<p
class="text-xs font-medium"
:class="[gameStatusTextStyle[games[nav.id].status.value.type]]"
>
{{ gameStatusText[games[nav.id].status.value.type] }}
</p>
</div>
</div>
</NuxtLink>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import { GameStatusEnum, type Game, type GameStatus } from "~/types";
import { TransitionGroup } from "vue";
import { listen } from "@tauri-apps/api/event";
// Style information
const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "text-green-500",
[GameStatusEnum.Downloading]: "text-blue-500",
[GameStatusEnum.Running]: "text-green-500",
[GameStatusEnum.Remote]: "text-zinc-500",
[GameStatusEnum.Queued]: "text-blue-500",
[GameStatusEnum.Updating]: "text-blue-500",
[GameStatusEnum.Uninstalling]: "text-zinc-100",
[GameStatusEnum.SetupRequired]: "text-yellow-500",
[GameStatusEnum.PartiallyInstalled]: "text-gray-600"
};
const gameStatusText: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]: "Not installed",
[GameStatusEnum.Queued]: "Queued",
[GameStatusEnum.Downloading]: "Downloading...",
[GameStatusEnum.Installed]: "Installed",
[GameStatusEnum.Updating]: "Updating...",
[GameStatusEnum.Uninstalling]: "Uninstalling...",
[GameStatusEnum.SetupRequired]: "Setup required",
[GameStatusEnum.Running]: "Running",
[GameStatusEnum.PartiallyInstalled]: "Partially installed"
};
const router = useRouter();
const searchQuery = ref("");
const games: {
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
} = {};
const icons: { [key: string]: string } = {};
const rawGames: Ref<Game[], Game[]> = ref([]);
async function calculateGames() {
rawGames.value = await invoke("fetch_library");
for (const game of rawGames.value) {
if (games[game.id]) continue;
games[game.id] = await useGame(game.id);
}
for (const game of rawGames.value) {
if (icons[game.id]) continue;
icons[game.id] = await useObject(game.mIconObjectId);
}
}
await calculateGames();
const navigation = computed(() =>
rawGames.value.map((game) => {
const status = games[game.id].status;
const isInstalled = computed(
() =>
status.value.type == GameStatusEnum.Installed ||
status.value.type == GameStatusEnum.SetupRequired
);
const item = {
label: game.mName,
route: `/library/${game.id}`,
prefix: `/library/${game.id}`,
isInstalled,
id: game.id,
};
return item;
})
);
const { currentNavigation, recalculateNavigation } = useCurrentNavigationIndex(
navigation.value
);
const filteredNavigation = computed(() => {
if (!searchQuery.value)
return navigation.value.map((e, i) => ({ ...e, index: i }));
const query = searchQuery.value.toLowerCase();
return navigation.value
.filter((nav) => nav.label.toLowerCase().includes(query))
.map((e, i) => ({ ...e, index: i }));
});
listen("update_library", async (event) => {
console.log("Updating library");
let oldNavigation = navigation.value[currentNavigation.value];
await calculateGames();
recalculateNavigation();
if (oldNavigation !== navigation.value[currentNavigation.value]) {
console.log("Triggered");
router.push("/library");
}
});
</script>
<style scoped>
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.list-leave-active {
position: absolute;
}
</style>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import { ArrowDownTrayIcon, CloudIcon } from "@heroicons/vue/20/solid";
</script>
<template>
<div
class="transition inline-flex items-center rounded-sm px-4 py-1.5 bg-zinc-900 text-sm text-zinc-400 gap-x-2"
>
<div class="relative">
<CloudIcon class="h-5 z-50 text-zinc-500" />
<div
class="absolute rounded-full left-1/2 top-1/2 -translate-y-[45%] -translate-x-1/2 w-[2px] h-6 rotate-[45deg] bg-zinc-400 z-50"
/>
</div>
Offline
</div>
</template>

View File

@ -0,0 +1,7 @@
<template>
<NuxtLink
class="inline-flex items-center gap-x-2 px-1 py-0.5 rounded bg-blue-900 text-zinc-100 hover:bg-blue-800"
>
<slot />
</NuxtLink>
</template>

View File

@ -1,4 +1,7 @@
<template>
<HeaderButton v-if="showMinimise" @click="() => minimise()">
<MinusIcon />
</HeaderButton>
<HeaderButton @click="() => close()">
<XMarkIcon />
</HeaderButton>
@ -8,11 +11,14 @@
import { MinusIcon, XMarkIcon } from "@heroicons/vue/16/solid";
import { getCurrentWindow } from "@tauri-apps/api/window";
async function close(){
console.log(window);
const result = await window.close();
console.log(`closed window: ${result}`);
const window = getCurrentWindow();
const showMinimise = await window.isMinimizable();
async function close() {
await window.close();
}
const window = getCurrentWindow();
async function minimise() {
await window.minimize();
}
</script>

View File

@ -26,5 +26,7 @@ export const useCurrentNavigationIndex = (
currentNavigation.value = calculateCurrentNavIndex(to);
});
return currentNavigation;
return {currentNavigation, recalculateNavigation: () => {
currentNavigation.value = calculateCurrentNavIndex(route);
}};
};

34
composables/downloads.ts Normal file
View File

@ -0,0 +1,34 @@
import { listen } from "@tauri-apps/api/event";
import type { DownloadableMetadata } from "~/types";
export type QueueState = {
queue: Array<{
meta: DownloadableMetadata;
status: string;
progress: number | null;
current: number;
max: number;
}>;
status: string;
};
export type StatsState = {
speed: number; // Bytes per second
time: number; // Seconds,
};
export const useQueueState = () =>
useState<QueueState>("queue", () => ({ queue: [], status: "Unknown" }));
export const useStatsState = () =>
useState<StatsState>("stats", () => ({ speed: 0, time: 0 }));
listen("update_queue", (event) => {
const queue = useQueueState();
queue.value = event.payload as QueueState;
});
listen("update_stats", (event) => {
const stats = useStatsState();
stats.value = event.payload as StatsState;
});

View File

@ -1,8 +1,9 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Game, GameStatus, GameStatusEnum } from "~/types";
import type { Game, GameStatus, GameStatusEnum, GameVersion } from "~/types";
const gameRegistry: { [key: string]: Game } = {};
const gameRegistry: { [key: string]: { game: Game; version?: GameVersion } } =
{};
const gameStatusRegistry: { [key: string]: Ref<GameStatus> } = {};
@ -12,7 +13,8 @@ export type SerializedGameStatus = [
OptionGameStatus | null
];
const parseStatus = (status: SerializedGameStatus): GameStatus => {
export const parseStatus = (status: SerializedGameStatus): GameStatus => {
console.log(status);
if (status[0]) {
return {
type: status[0].type,
@ -28,28 +30,46 @@ const parseStatus = (status: SerializedGameStatus): GameStatus => {
}
};
export const useGame = async (id: string) => {
if (!gameRegistry[id]) {
const data: { game: Game; status: SerializedGameStatus } = await invoke(
"fetch_game",
{
id,
}
);
gameRegistry[id] = data.game;
if (!gameStatusRegistry[id]) {
gameStatusRegistry[id] = ref(parseStatus(data.status));
export const useGame = async (gameId: string) => {
if (!gameRegistry[gameId]) {
const data: {
game: Game;
status: SerializedGameStatus;
version?: GameVersion;
} = await invoke("fetch_game", {
gameId,
});
gameRegistry[gameId] = { game: data.game, version: data.version };
if (!gameStatusRegistry[gameId]) {
gameStatusRegistry[gameId] = ref(parseStatus(data.status));
listen(`update_game/${id}`, (event) => {
listen(`update_game/${gameId}`, (event) => {
const payload: {
status: SerializedGameStatus;
version?: GameVersion;
} = event.payload as any;
gameStatusRegistry[id].value = parseStatus(payload.status);
console.log(payload.status);
gameStatusRegistry[gameId].value = parseStatus(payload.status);
/**
* I am not super happy about this.
*
* This will mean that we will still have a version assigned if we have a game installed then uninstall it.
* It is necessary because a flag to check if we should overwrite seems excessive, and this function gets called
* on transient state updates.
*/
if (payload.version) {
gameRegistry[gameId].version = payload.version;
}
});
}
}
const game = gameRegistry[id];
const status = gameStatusRegistry[id];
return { game, status };
const game = gameRegistry[gameId];
const status = gameStatusRegistry[gameId];
return { ...game, status };
};
export type FrontendGameConfiguration = {
launchString: string;
};

View File

@ -0,0 +1,9 @@
import { type DownloadableMetadata, DownloadableType } from '~/types'
export default function generateGameMeta(gameId: string, version: string): DownloadableMetadata {
return {
id: gameId,
version,
downloadType: DownloadableType.Game
}
}

View File

@ -1,13 +0,0 @@
import { listen } from "@tauri-apps/api/event";
export type QueueState = {
queue: Array<{ id: string; status: string, progress: number | null }>;
};
export const useQueueState = () =>
useState<QueueState>("queue", () => ({ queue: [] }));
listen("update_queue", (event) => {
const queue = useQueueState();
queue.value = event.payload as QueueState;
});

View File

@ -1,8 +1,11 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { data } from "autoprefixer";
import { AppStatus, type AppState } from "~/types";
export function setupHooks() {
const router = useRouter();
const state = useAppState();
listen("auth/processing", (event) => {
router.push("/auth/processing");
@ -14,8 +17,23 @@ export function setupHooks() {
);
});
listen("auth/finished", (event) => {
listen("auth/finished", async (event) => {
router.push("/store");
state.value = JSON.parse(await invoke("fetch_state"));
});
listen("download_error", (event) => {
createModal(
ModalType.Notification,
{
title: "Drop encountered an error while downloading",
description: `Drop encountered an error while downloading your game: "${(
event.payload as unknown as string
).toString()}"`,
buttonText: "Close"
},
(e, c) => c()
);
});
/*
@ -33,9 +51,7 @@ export function initialNavigation(state: Ref<AppState>) {
switch (state.value.status) {
case AppStatus.NotConfigured:
router.push({ path: "/setup" }).then(() => {
console.log("Pushed Setup");
});
router.push({ path: "/setup" });
break;
case AppStatus.SignedOut:
router.push("/auth");

1
drop-base Submodule

Submodule drop-base added at 26698e5b06

90
error.vue Normal file
View File

@ -0,0 +1,90 @@
<template>
<NuxtLayout name="default">
<div
class="grid min-h-full grid-cols-1 grid-rows-[1fr,auto,1fr] lg:grid-cols-[max(50%,36rem),1fr]"
>
<header
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
>
<Logo class="h-10 w-auto sm:h-12" />
</header>
<main
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
>
<div class="max-w-lg">
<p class="text-base font-semibold leading-8 text-blue-600">
{{ error?.statusCode }}
</p>
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
Oh no!
</h1>
<p
v-if="message"
class="mt-3 font-bold text-base leading-7 text-red-500"
>
{{ message }}
</p>
<p class="mt-6 text-base leading-7 text-zinc-400">
An error occurred while responding to your request. If you believe
this to be a bug, please report it. Try signing in and see if it
resolves the issue.
</p>
<div class="mt-10">
<!-- full app reload to fix errors -->
<a
href="/store"
class="text-sm font-semibold leading-7 text-blue-600"
><span aria-hidden="true">&larr;</span> Back to store</a
>
</div>
</div>
</main>
<footer class="self-end lg:col-span-2 lg:col-start-1 lg:row-start-3">
<div class="border-t border-zinc-700 bg-zinc-900 py-10">
<nav
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
>
<NuxtLink href="/docs">Documentation</NuxtLink>
<svg
viewBox="0 0 2 2"
aria-hidden="true"
class="h-0.5 w-0.5 fill-zinc-600"
>
<circle cx="1" cy="1" r="1" />
</svg>
<a href="https://discord.gg/NHx46XKJWA" target="_blank"
>Support Discord</a
>
</nav>
</div>
</footer>
<div
class="hidden lg:relative lg:col-start-2 lg:row-start-1 lg:row-end-4 lg:block"
>
<img
src="@/assets/wallpaper.jpg"
alt=""
class="absolute inset-0 h-full w-full object-cover"
/>
</div>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import type { NuxtError } from "#app";
const props = defineProps({
error: Object as () => NuxtError,
});
const statusCode = props.error?.statusCode;
const message =
props.error?.statusMessage ||
props.error?.message ||
"An unknown error occurred.";
console.error(props.error);
</script>

View File

@ -1,9 +1,79 @@
<template>
<div class="flex flex-col bg-zinc-900 overflow-hidden">
<Header class="select-none" />
<div class="grow overflow-y-auto">
<slot />
</div>
<div class="flex flex-col bg-zinc-900 overflow-hidden h-screen">
<NuxtErrorBoundary>
<Header class="select-none" />
<div class="relative grow overflow-y-auto">
<slot />
</div>
<template #error="{ error }">
<MiniHeader />
<div class="relative grow overflow-y-auto bg-zinc-950">
<div
class="grid min-h-full grid-cols-1 grid-rows-[1fr,auto,1fr] lg:grid-cols-[max(50%,36rem),1fr]"
>
<header
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
>
<Logo class="h-10 w-auto sm:h-12" />
</header>
<main
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
>
<div class="max-w-lg">
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
Unrecoverable error
</h1>
<p class="mt-6 text-base leading-7 text-zinc-400">
Drop encountered an error that it couldn't handle. Please
restart the application and file a bug report.
</p>
<p class="mt-3 text-sm font-monospace text-zinc-500">
Error: {{ error }}
</p>
</div>
</main>
<footer
class="self-end lg:col-span-2 lg:col-start-1 lg:row-start-3"
>
<div class="border-t border-blue-600 bg-zinc-900 py-10">
<nav
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
>
<a href="#">Documentation</a>
<svg
viewBox="0 0 2 2"
aria-hidden="true"
class="h-0.5 w-0.5 fill-zinc-700"
>
<circle cx="1" cy="1" r="1" />
</svg>
<a href="#">Troubleshooting</a>
<svg
viewBox="0 0 2 2"
aria-hidden="true"
class="h-0.5 w-0.5 fill-zinc-700"
>
<circle cx="1" cy="1" r="1" />
</svg>
<NuxtLink to="/setup/server">Switch instance</NuxtLink>
</nav>
</div>
</footer>
<div
class="hidden lg:relative lg:col-start-2 lg:row-start-1 lg:row-end-4 lg:block"
>
<img
src="@/assets/wallpaper.jpg"
alt=""
class="absolute inset-0 h-full w-full object-cover"
/>
</div>
</div>
</div>
</template>
</NuxtErrorBoundary>
</div>
</template>

View File

@ -1,7 +1,7 @@
<template>
<div class="flex flex-col bg-zinc-950 overflow-hidden">
<div class="flex flex-col bg-zinc-950 overflow-hidden h-screen">
<MiniHeader />
<div class="grow overflow-y-auto">
<div class="relative grow overflow-y-auto">
<slot />
</div>
</div>

View File

@ -13,5 +13,5 @@ export default defineNuxtConfig({
ssr: false,
extends: [["github:drop-oss/drop-base"]],
extends: [["./drop-base"]],
});

View File

@ -1,7 +1,7 @@
{
"name": "drop-app",
"private": true,
"version": "0.1.1",
"version": "0.3.0-rc-5",
"type": "module",
"scripts": {
"build": "nuxt build",
@ -18,10 +18,12 @@
"@tauri-apps/api": ">=2.0.0",
"@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/plugin-shell": ">=2.0.0",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-shell": "^2.2.1",
"koa": "^2.16.1",
"markdown-it": "^14.1.0",
"moment": "^2.30.1",
"nuxt": "^3.13.0",
"micromark": "^4.0.1",
"nuxt": "^3.16.0",
"scss": "^0.2.4",
"vue": "latest",
"vue-router": "latest",
@ -35,7 +37,9 @@
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"sass-embedded": "^1.79.4",
"tailwindcss": "^3.4.13"
"tailwindcss": "^3.4.13",
"typescript": "^5.8.3",
"vue-tsc": "^2.2.10"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@ -26,7 +26,7 @@
import { XCircleIcon } from "@heroicons/vue/16/solid";
const route = useRoute();
const message = route.query.error ?? "An unknown error occurred.";
const message = route.query.error ?? "An unknown error occurred";
definePageMeta({
layout: "mini",

View File

@ -1,66 +0,0 @@
<template>
<div
class="grid min-h-full grid-cols-1 grid-rows-[1fr,auto,1fr] lg:grid-cols-[max(50%,36rem),1fr]"
>
<header
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
>
<Logo class="h-10 w-auto sm:h-12" />
</header>
<main
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
>
<div class="max-w-lg">
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
Unrecoverable error
</h1>
<p class="mt-6 text-base leading-7 text-zinc-400">
Drop encountered an error that it couldn't handle. Please restart the
application and file a bug report.
</p>
</div>
</main>
<footer class="self-end lg:col-span-2 lg:col-start-1 lg:row-start-3">
<div class="border-t border-blue-600 bg-zinc-900 py-10">
<nav
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
>
<a href="#">Documentation</a>
<svg
viewBox="0 0 2 2"
aria-hidden="true"
class="h-0.5 w-0.5 fill-zinc-700"
>
<circle cx="1" cy="1" r="1" />
</svg>
<a href="#">Troubleshooting</a>
<svg
viewBox="0 0 2 2"
aria-hidden="true"
class="h-0.5 w-0.5 fill-zinc-700"
>
<circle cx="1" cy="1" r="1" />
</svg>
<NuxtLink to="/setup/server">Switch instance</NuxtLink>
</nav>
</div>
</footer>
<div
class="hidden lg:relative lg:col-start-2 lg:row-start-1 lg:row-end-4 lg:block"
>
<img
src="@/assets/wallpaper.jpg"
alt=""
class="absolute inset-0 h-full w-full object-cover"
/>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: "mini",
});
</script>

View File

@ -1,4 +1,7 @@
<template />
<script setup lang="ts">
definePageMeta({
layout: false
})
</script>

View File

@ -1,59 +1,52 @@
<template>
<div class="flex flex-row h-full">
<div class="flex-none max-h-full overflow-y-auto w-64 bg-zinc-950 px-2 py-1">
<ul class="flex flex-col gap-y-1">
<NuxtLink
v-for="(nav, navIdx) in navigation"
:key="nav.route"
:class="[
'transition group rounded flex justify-between gap-x-6 py-2 px-3',
navIdx === currentNavigationIndex ? 'bg-zinc-900' : '',
]"
:href="nav.route"
>
<div class="flex items-center min-w-0 gap-x-2">
<img
class="h-5 w-auto flex-none object-cover rounded-sm bg-zinc-900"
:src="icons[navIdx]"
alt=""
/>
<div class="min-w-0 flex-auto">
<p
:class="[
navIdx === currentNavigationIndex
? 'text-zinc-100'
: 'text-zinc-400 group-hover:text-zinc-300',
'truncate transition text-sm font-display leading-6',
]"
>
{{ nav.label }}
</p>
</div>
</div>
</NuxtLink>
</ul>
<!-- Sidebar -->
<div
class="flex-none max-h-full overflow-y-auto w-72 bg-zinc-950/50 backdrop-blur-xl px-4 py-3 border-r border-zinc-800/50"
>
<LibrarySearch />
</div>
<div class="grow overflow-y-auto">
<NuxtPage />
<NuxtErrorBoundary>
<NuxtPage />
<template #error="{ error }">
<main
class="grid min-h-full w-full place-items-center px-6 py-24 sm:py-32 lg:px-8"
>
<div class="text-center">
<p class="text-base font-semibold text-blue-600">Error</p>
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
Failed to load library
</h1>
<p class="mt-6 text-base leading-7 text-zinc-400">
Drop couldn't load your library: "{{ error }}".
</p>
</div>
</main>
</template>
</NuxtErrorBoundary>
</div>
</div>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import type { Game, NavigationItem } from "~/types";
<script setup lang="ts"></script>
const games: Array<Game> = await invoke("fetch_library");
const icons = await Promise.all(games.map((e) => useObject(e.mIconId)));
<style scoped>
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
const navigation = games.map((e) => {
const item: NavigationItem = {
label: e.mName,
route: `/library/${e.id}`,
prefix: `/library/${e.id}`,
};
return item;
});
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
const currentNavigationIndex = useCurrentNavigationIndex(navigation);
</script>
.list-leave-active {
position: absolute;
}
</style>

View File

@ -1,322 +1,442 @@
<template>
<div
class="mx-auto w-full relative flex flex-col justify-center pt-64 z-10 overflow-hidden"
class="mx-auto w-full relative flex flex-col justify-center pt-72 overflow-hidden"
>
<!-- banner image -->
<div class="absolute flex top-0 h-fit inset-x-0 z-[-20]">
<img :src="bannerUrl" class="w-full h-auto object-cover" />
<h1
class="absolute inset-x-0 w-fit mx-auto text-center top-32 -translate-y-[50%] text-4xl text-zinc-100 font-bold font-display z-50 p-4 shadow-xl bg-zinc-900/80 rounded-xl"
>
{{ game.mName }}
</h1>
<div class="absolute inset-0 z-0">
<img
:src="bannerUrl"
class="w-full h-[24rem] object-cover blur-sm scale-105"
/>
<div
class="absolute inset-0 bg-gradient-to-b from-transparent to-50% to-zinc-900"
class="absolute inset-0 bg-gradient-to-t from-zinc-900 via-zinc-900/80 to-transparent opacity-90"
/>
<div
class="absolute inset-0 bg-gradient-to-r from-zinc-900/95 via-zinc-900/80 to-transparent opacity-90"
/>
</div>
<!-- main page -->
<div class="w-full min-h-screen mx-auto bg-zinc-900 px-5 py-6">
<!-- game toolbar -->
<div class="h-full flex flex-row gap-x-4 items-stretch">
<GameStatusButton
@install="() => installFlow()"
@play="() => play()"
@queue="() => queue()"
:status="status"
/>
<a
:href="remoteUrl"
target="_blank"
type="button"
class="inline-flex items-center rounded-md bg-zinc-800/50 px-4 font-semibold text-white shadow-sm hover:bg-zinc-800/80 uppercase font-display"
<div class="relative z-10">
<div class="px-8 pb-4">
<h1
class="text-5xl text-zinc-100 font-bold font-display drop-shadow-lg mb-8"
>
<BuildingStorefrontIcon class="mr-2 size-5" aria-hidden="true" />
{{ game.mName }}
</h1>
Store
</a>
</div>
</div>
</div>
<TransitionRoot as="template" :show="installFlowOpen">
<Dialog class="relative z-50" @close="installFlowOpen = false">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="fixed inset-0 bg-zinc-950 bg-opacity-75 transition-opacity"
/>
</TransitionChild>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div
class="flex min-h-full items-start justify-center p-4 text-center sm:items-center sm:p-0"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
<div class="flex flex-row gap-x-4 items-stretch mb-8">
<!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 -->
<GameStatusButton
@install="() => installFlow()"
@launch="() => launch()"
@queue="() => queue()"
@uninstall="() => uninstall()"
@kill="() => kill()"
@options="() => (configureModalOpen = true)"
@resume="() => resumeDownload()"
:status="status"
/>
<a
:href="remoteUrl"
target="_blank"
type="button"
class="transition-transform duration-300 hover:scale-105 active:scale-95 inline-flex items-center rounded-md bg-zinc-800/50 px-6 font-semibold text-white shadow-xl backdrop-blur-sm hover:bg-zinc-800/80 uppercase font-display"
>
<form
@submit.prevent="() => install()"
class="relative transform rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
>
<div class="px-4 pb-4 pt-5 space-y-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left">
<DialogTitle
as="h3"
class="text-base font-semibold text-zinc-100"
>Install {{ game.mName }}?
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-zinc-400">
Drop will add {{ game.mName }} to the queue to be
downloaded. While downloading, Drop may use up a large
amount of resources, particularly network bandwidth and
CPU utilisation.
</p>
</div>
</div>
</div>
<BuildingStorefrontIcon class="mr-2 size-5" aria-hidden="true" />
Store
</a>
</div>
</div>
<div class="space-y-6">
<div v-if="versionOptions && versionOptions.length > 0">
<Listbox as="div" v-model="installVersionIndex">
<ListboxLabel
class="block text-sm/6 font-medium text-zinc-100"
>Version</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate"
>{{
versionOptions[installVersionIndex].versionName
}}
on
{{
versionOptions[installVersionIndex].platform
}}</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<!-- Main content -->
<div class="w-full bg-zinc-900 px-8 py-6">
<div class="grid grid-cols-[2fr,1fr] gap-8">
<div class="space-y-6">
<div class="bg-zinc-800/50 rounded-xl p-6 backdrop-blur-sm">
<div
v-html="htmlDescription"
class="prose prose-invert prose-blue overflow-y-auto custom-scrollbar max-w-none"
></div>
</div>
</div>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(version, versionIdx) in versionOptions"
:key="version.versionName"
:value="versionIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active
? 'bg-blue-600 text-white'
: 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ version.versionName }} on
{{ version.platform }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon
class="h-5 w-5"
aria-hidden="true"
/>
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div v-else class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
<div class="space-y-6">
<div class="bg-zinc-800/50 rounded-xl p-6 backdrop-blur-sm">
<h2 class="text-xl font-display font-semibold text-zinc-100 mb-4">
Game Images
</h2>
<div class="relative">
<div v-if="mediaUrls.length > 0">
<div
class="relative aspect-video rounded-lg overflow-hidden cursor-pointer group"
>
<div
class="absolute inset-0"
@click="fullscreenImage = mediaUrls[currentImageIndex]"
>
<TransitionGroup name="slide" tag="div" class="h-full">
<img
v-for="(url, index) in mediaUrls"
:key="url"
:src="url"
class="absolute inset-0 w-full h-full object-cover"
v-show="index === currentImageIndex"
/>
</TransitionGroup>
</div>
<div
class="absolute inset-0 flex items-center justify-between px-4 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
>
<div class="pointer-events-auto">
<button
v-if="mediaUrls.length > 1"
@click.stop="previousImage()"
class="p-2 rounded-full bg-zinc-900/50 text-zinc-100 hover:bg-zinc-900/80 transition-all duration-300 hover:scale-110"
>
<ChevronLeftIcon class="size-5" />
</button>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
There are no supported versions to install. Please
contact your server admin or try again later.
</h3>
<div class="pointer-events-auto">
<button
v-if="mediaUrls.length > 1"
@click.stop="nextImage()"
class="p-2 rounded-full bg-zinc-900/50 text-zinc-100 hover:bg-zinc-900/80 transition-all duration-300 hover:scale-110"
>
<ChevronRightIcon class="size-5" />
</button>
</div>
</div>
<div
class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
/>
<div
class="absolute bottom-4 right-4 flex items-center gap-x-2 text-white opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
>
<ArrowsPointingOutIcon class="size-5" />
<span class="text-sm font-medium">View Fullscreen</span>
</div>
</div>
<div v-if="installDirs">
<Listbox as="div" v-model="installDir">
<ListboxLabel
class="block text-sm/6 font-medium text-zinc-100"
>Install to</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate">{{
installDirs[installDir]
}}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(dir, dirIdx) in installDirs"
:key="dir"
:value="dirIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active
? 'bg-blue-600 text-white'
: 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ dir }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon
class="h-5 w-5"
aria-hidden="true"
/>
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div
class="absolute -bottom-2 left-1/2 -translate-x-1/2 flex gap-x-2"
>
<button
v-for="(_, index) in mediaUrls"
:key="index"
@click.stop="currentImageIndex = index"
class="w-1.5 h-1.5 rounded-full transition-all"
:class="[
currentImageIndex === index
? 'bg-zinc-100 scale-125'
: 'bg-zinc-600 hover:bg-zinc-500',
]"
/>
</div>
</div>
<div
v-if="installError"
class="mt-1 rounded-md bg-red-600/10 p-4"
v-else
class="aspect-video rounded-lg overflow-hidden bg-zinc-900/50 flex flex-col items-center justify-center text-center px-4"
>
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ installError }}
</h3>
</div>
</div>
<PhotoIcon class="size-12 text-zinc-500 mb-2" />
<p class="text-zinc-400 font-medium">No images available</p>
<p class="text-zinc-500 text-sm">
Game screenshots will appear here when available
</p>
</div>
</div>
<div
class="rounded-b-lg bg-zinc-800 px-4 py-3 sm:flex sm:gap-x-2 sm:flex-row-reverse sm:px-6"
>
<LoadingButton
:disabled="
!(
versionOptions &&
versionOptions.length > 0 &&
!installDir
)
"
:loading="installLoading"
type="submit"
class="w-full sm:w-fit"
>
Install
</LoadingButton>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="installFlowOpen = false"
ref="cancelButtonRef"
>
Cancel
</button>
</div>
</form>
</TransitionChild>
</div>
</div>
</div>
</div>
</Dialog>
</TransitionRoot>
</div>
</div>
<ModalTemplate v-model="installFlowOpen">
<template #default>
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left">
<h3 class="text-base font-semibold text-zinc-100"
>Install {{ game.mName }}?
</h3>
<div class="mt-2">
<p class="text-sm text-zinc-400">
Drop will add {{ game.mName }} to the queue to be downloaded.
While downloading, Drop may use up a large amount of resources,
particularly network bandwidth and CPU utilisation.
</p>
</div>
</div>
</div>
<form class="space-y-6">
<div v-if="versionOptions && versionOptions.length > 0">
<Listbox as="div" v-model="installVersionIndex">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
>Version</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate"
>{{ versionOptions[installVersionIndex].versionName }}
on
{{ versionOptions[installVersionIndex].platform }}</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(version, versionIdx) in versionOptions"
:key="version.versionName"
:value="versionIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ version.versionName }} on
{{ version.platform }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div v-else class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
There are no supported versions to install. Please contact your
server admin or try again later.
</h3>
</div>
</div>
</div>
<div v-if="installDirs">
<Listbox as="div" v-model="installDir">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
>Install to</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate">{{
installDirs[installDir]
}}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(dir, dirIdx) in installDirs"
:key="dir"
:value="dirIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ dir }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
<div class="text-zinc-400 text-sm mt-2">
Add more install directories in
<PageWidget to="/settings/downloads">
<WrenchIcon class="size-3" />
Settings
</PageWidget>
</div>
</Listbox>
</div>
</form>
<div v-if="installError" class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ installError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton
@click="() => install()"
:disabled="
!(versionOptions && versionOptions.length > 0)
"
:loading="installLoading"
type="submit"
class="ml-2 w-full sm:w-fit"
>
Install
</LoadingButton>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="installFlowOpen = false"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
<GameOptionsModal v-if="status.type === GameStatusEnum.Installed" v-model="configureModalOpen" :game-id="game.id" />
<Transition
enter="transition ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
v-if="fullscreenImage"
class="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
@click="fullscreenImage = null"
>
<div
class="relative w-full h-full flex items-center justify-center"
@click.stop
>
<button
class="absolute top-4 right-4 p-2 rounded-full bg-zinc-900/50 text-zinc-100 hover:bg-zinc-900 transition-colors"
@click.stop="fullscreenImage = null"
>
<XMarkIcon class="size-6" />
</button>
<button
v-if="mediaUrls.length > 1"
@click.stop="previousImage()"
class="absolute left-4 p-3 rounded-full bg-zinc-900/50 text-zinc-100 hover:bg-zinc-900 transition-colors"
>
<ChevronLeftIcon class="size-6" />
</button>
<button
v-if="mediaUrls.length > 1"
@click.stop="nextImage()"
class="absolute right-4 p-3 rounded-full bg-zinc-900/50 text-zinc-100 hover:bg-zinc-900 transition-colors"
>
<ChevronRightIcon class="size-6" />
</button>
<TransitionGroup
name="slide"
tag="div"
class="w-full h-full flex items-center justify-center"
@click.stop
>
<img
v-for="(url, index) in mediaUrls"
v-show="currentImageIndex === index"
:key="url"
:src="url"
class="max-h-[90vh] max-w-[90vw] object-contain"
:alt="`${game.mName} screenshot ${index + 1}`"
/>
</TransitionGroup>
<div
class="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-full bg-zinc-900/50 backdrop-blur-sm"
>
<p class="text-zinc-100 text-sm font-medium">
{{ currentImageIndex + 1 }} / {{ mediaUrls.length }}
</p>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
@ -331,10 +451,21 @@ import {
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import {
CheckIcon,
ChevronUpDownIcon,
WrenchIcon,
ChevronLeftIcon,
ChevronRightIcon,
XMarkIcon,
ArrowsPointingOutIcon,
PhotoIcon,
} from "@heroicons/vue/20/solid";
import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline";
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { invoke } from "@tauri-apps/api/core";
import { micromark } from "micromark";
import { GameStatusEnum } from "~/types";
const route = useRoute();
const router = useRouter();
@ -347,20 +478,35 @@ const remoteUrl: string = await invoke("gen_drop_url", {
path: `/store/${game.value.id}`,
});
const bannerUrl = await useObject(game.value.mBannerId);
const bannerUrl = await useObject(game.value.mBannerObjectId);
// Get all available images
const mediaUrls = await Promise.all(
game.value.mImageCarouselObjectIds.map((id) => useObject(id))
);
const htmlDescription = micromark(game.value.mDescription);
const installFlowOpen = ref(false);
const versionOptions = ref<
undefined | Array<{ versionName: string; platform: string }>
>();
const installDirs = ref<undefined | Array<string>>();
const currentImageIndex = ref(0);
const configureModalOpen = ref(false);
async function installFlow() {
installFlowOpen.value = true;
versionOptions.value = undefined;
installDirs.value = undefined;
try {
versionOptions.value = await invoke("fetch_game_verion_options", {
gameId: game.value.id,
});
console.log(versionOptions.value);
installDirs.value = await invoke("fetch_download_dir_stats");
} catch (error) {
installError.value = (error as string).toString();
@ -373,25 +519,33 @@ const installVersionIndex = ref(0);
const installDir = ref(0);
async function install() {
try {
if (!versionOptions.value)
throw new Error("Versions have not been loaded.");
if (!versionOptions.value) throw new Error("Versions have not been loaded");
installLoading.value = true;
await invoke("download_game", {
gameId: game.value.id,
gameVersion: versionOptions.value[installVersionIndex.value].versionName,
installDir: installDir.value,
});
installLoading.value = false;
installFlowOpen.value = false;
} catch (error) {
installError.value = (error as string).toString();
}
installLoading.value = false;
}
async function play() {
async function resumeDownload() {
try {
await invoke("resume_download", { gameId: game.value.id })
}
catch(e) {
console.error(e)
}
}
async function launch() {
try {
await invoke("launch_game", { gameId: game.value.id });
await invoke("launch_game", { id: game.value.id });
} catch (e) {
createModal(
ModalType.Notification,
@ -409,4 +563,82 @@ async function play() {
async function queue() {
router.push("/queue");
}
async function uninstall() {
await invoke("uninstall_game", { gameId: game.value.id });
}
async function kill() {
try {
await invoke("kill_game", { gameId: game.value.id });
} catch (e) {
createModal(
ModalType.Notification,
{
title: `Couldn't stop "${game.value.mName}"`,
description: `Drop failed to stop "${game.value.mName}": ${e}`,
buttonText: "Close",
},
(e, c) => c()
);
console.error(e);
}
}
function nextImage() {
currentImageIndex.value = (currentImageIndex.value + 1) % mediaUrls.length;
}
function previousImage() {
currentImageIndex.value =
(currentImageIndex.value - 1 + mediaUrls.length) % mediaUrls.length;
}
const fullscreenImage = ref<string | null>(null);
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
position: absolute;
}
.slide-enter-from {
opacity: 0;
transform: translateX(100%);
}
.slide-leave-to {
opacity: 0;
transform: translateX(-100%);
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgb(82 82 91) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgb(82 82 91);
border-radius: 3px;
}
</style>

View File

@ -1,3 +1,19 @@
<template>
</template>
<div class="h-full flex flex-col items-center justify-center">
<div class="text-center">
<div class="flex flex-col items-center gap-y-4">
<div class="p-4 rounded-xl bg-zinc-700/50 backdrop-blur-sm">
<RocketLaunchIcon class="size-12 text-zinc-400" />
</div>
<div>
<h3 class="text-xl font-display font-semibold text-zinc-100">Select a game</h3>
<p class="mt-1 text-sm text-zinc-400">Choose a game from your library to view details</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { RocketLaunchIcon } from '@heroicons/vue/24/outline';
</script>

View File

@ -1,27 +1,46 @@
<template>
<div class="bg-zinc-950 p-4 min-h-full">
<div class="bg-zinc-950 p-4 min-h-full space-y-4">
<div
class="h-16 overflow-hidden relative rounded-xl flex flex-row border border-zinc-900"
>
<div
class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 text-blue-400 font-display items-left justify-center pl-2"
>
<span class="font-semibold">{{ formatKilobytes(stats.speed) }}/s</span>
<span v-if="stats.time > 0" class="text-sm"
>{{ formatTime(stats.time) }} left</span
>
</div>
<div class="absolute inset-0 h-full flex flex-row items-end justify-end">
<div
v-for="bar in speedHistory"
:style="{ height: `${(bar / speedMax) * 100}%` }"
class="w-[8px] bg-blue-600/40"
/>
</div>
</div>
<draggable v-model="queue.queue" @end="onEnd">
<template #item="{ element }: { element: (typeof queue.value.queue)[0] }">
<li
v-if="games[element.id]"
:key="element.id"
v-if="games[element.meta.id]"
:key="element.meta.id"
class="mb-4 bg-zinc-900 rounded-lg flex flex-row justify-between gap-x-6 py-5 px-4"
>
<div class="w-full flex items-center max-w-md gap-x-4 relative">
<img
class="size-24 flex-none bg-zinc-800 object-cover rounded"
:src="games[element.id].cover"
:src="games[element.meta.id].cover"
alt=""
/>
<div class="min-w-0 flex-auto">
<p class="text-xl font-semibold text-zinc-100">
<NuxtLink :href="`/library/${element.id}`" class="">
<NuxtLink :href="`/library/${element.meta.id}`" class="">
<span class="absolute inset-x-0 -top-px bottom-0" />
{{ games[element.id].game.mName }}
{{ games[element.meta.id].game.mName }}
</NuxtLink>
</p>
<p class="mt-1 flex text-xs/5 text-gray-500">
{{ games[element.id].game.mShortDescription }}
{{ games[element.meta.id].game.mShortDescription }}
</p>
</div>
</div>
@ -39,8 +58,17 @@
:style="{ width: `${element.progress * 100}%` }"
/>
</div>
<span
class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display"
><span class="text-zinc-300">{{
formatKilobytes(element.current / 1000)
}}</span>
/
<span class="">{{ formatKilobytes(element.max / 1000) }}</span
><ServerIcon class="size-5"
/></span>
</div>
<button @click="() => cancelGame(element.id)" class="group">
<button @click="() => cancelGame(element.meta)" class="group">
<XMarkIcon
class="transition size-8 flex-none text-zinc-600 group-hover:text-zinc-300"
aria-hidden="true"
@ -61,29 +89,81 @@
</template>
<script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/20/solid";
import { ServerIcon, XMarkIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import type { Game, GameStatus } from "~/types";
import { GameStatusEnum, type DownloadableMetadata, type Game, type GameStatus } from "~/types";
// const actionNames = {
// [GameStatusEnum.Downloading]: "downloading",
// [GameStatusEnum.Verifying]: "verifying",
// }
const windowWidth = ref(window.innerWidth);
window.addEventListener("resize", (event) => {
windowWidth.value = window.innerWidth;
});
const queue = useQueueState();
const current = computed(() => queue.value.queue.at(0));
const rest = computed(() => queue.value.queue.slice(1));
const stats = useStatsState();
const speedHistory = useState<Array<number>>(() => []);
const speedHistoryMax = computed(() => windowWidth.value / 8);
const speedMax = computed(
() => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.3
);
const previousGameId = ref<string | undefined>();
const games: Ref<{
[key: string]: { game: Game; status: Ref<GameStatus>; cover: string };
}> = ref({});
function resetHistoryGraph() {
speedHistory.value = [];
stats.value = { time: 0, speed: 0 };
}
function checkReset(v: QueueState) {
const currentGame = v.queue.at(0)?.meta.id;
// If we're finished
if (!currentGame && previousGameId.value) {
previousGameId.value = undefined;
resetHistoryGraph();
return;
}
// If we don't have a game
if (!currentGame) return;
// If we started a new download
if (currentGame && !previousGameId.value) {
previousGameId.value = currentGame;
resetHistoryGraph();
return;
}
// If it's a different game now
if (currentGame != previousGameId.value) {
previousGameId.value = currentGame;
resetHistoryGraph();
return;
}
}
watch(queue, (v) => {
loadGamesForQueue(v);
checkReset(v);
});
watch(stats, (v) => {
const newLength = speedHistory.value.push(v.speed);
if (newLength > speedHistoryMax.value) {
speedHistory.value.splice(0, 1);
}
checkReset(queue.value);
});
function loadGamesForQueue(v: typeof queue.value) {
for (const { id } of v.queue) {
for (const {
meta: { id },
} of v.queue) {
if (games.value[id]) return;
(async () => {
const gameData = await useGame(id);
const cover = await useObject(gameData.game.mCoverId);
const cover = await useObject(gameData.game.mCoverObjectId);
games.value[id] = { ...gameData, cover };
})();
}
@ -92,13 +172,41 @@ function loadGamesForQueue(v: typeof queue.value) {
loadGamesForQueue(queue.value);
async function onEnd(event: { oldIndex: number; newIndex: number }) {
await invoke("move_game_in_queue", {
await invoke("move_download_in_queue", {
oldIndex: event.oldIndex,
newIndex: event.newIndex,
});
}
async function cancelGame(id: string) {
await invoke("cancel_game", { gameId: id });
async function cancelGame(meta: DownloadableMetadata) {
await invoke("cancel_game", { meta });
}
function formatKilobytes(bytes: number): string {
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = bytes;
let unitIndex = 0;
const scalar = 1000;
while (value >= scalar && unitIndex < units.length - 1) {
value /= scalar;
unitIndex++;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
function formatTime(seconds: number): string {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m ${Math.round(seconds % 60)}s`;
}
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
</script>

View File

@ -9,25 +9,18 @@
<nav class="flex flex-col" aria-label="Sidebar">
<ul role="list" class="-mx-2 space-y-1">
<li v-for="(item, itemIdx) in navigation" :key="item.prefix">
<NuxtLink
:href="item.route"
:class="[
itemIdx === currentPageIndex
? 'bg-zinc-800/50 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800/30 hover:text-zinc-200',
'transition group flex gap-x-3 rounded-md p-2 pr-12 text-sm font-semibold leading-6',
]"
>
<component
:is="item.icon"
:class="[
itemIdx === currentPageIndex
? 'text-zinc-100'
: 'text-zinc-400 group-hover:text-zinc-200',
'transition h-6 w-6 shrink-0',
]"
aria-hidden="true"
/>
<NuxtLink :href="item.route" :class="[
itemIdx === currentNavigation
? 'bg-zinc-800/50 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800/30 hover:text-zinc-200',
'transition group flex gap-x-3 rounded-md p-2 pr-12 text-sm font-semibold leading-6',
]">
<component :is="item.icon" :class="[
itemIdx === currentNavigation
? 'text-zinc-100'
: 'text-zinc-400 group-hover:text-zinc-200',
'transition h-6 w-6 shrink-0',
]" aria-hidden="true" />
{{ item.label }}
</NuxtLink>
</li>
@ -43,13 +36,54 @@
<script setup lang="ts">
import {
ArrowDownTrayIcon,
CubeIcon,
HomeIcon,
RectangleGroupIcon,
BugAntIcon,
} from "@heroicons/vue/16/solid";
import type { Component } from "vue";
import type { NavigationItem } from "~/types";
import { platform } from '@tauri-apps/plugin-os';
import { invoke } from "@tauri-apps/api/core";
import { UserIcon } from "@heroicons/vue/20/solid";
const navigation: Array<NavigationItem & { icon: Component }> = [
const systemData = await invoke<{
clientId: string;
baseUrl: string;
dataDir: string;
logLevel: string;
}>("fetch_system_data");
const isDebugMode = ref(systemData.logLevel.toLowerCase() === "debug");
const debugRevealed = ref(false);
// Track shift key state and debug reveal
onMounted(() => {
window.addEventListener('keydown', (e) => {
if (e.key === 'Shift') {
isDebugMode.value = true;
debugRevealed.value = true;
}
});
window.addEventListener('keyup', (e) => {
if (e.key === 'Shift') {
isDebugMode.value = debugRevealed.value || systemData.logLevel.toLowerCase() === "debug";
}
});
// Reset debug reveal when leaving the settings page
const router = useRouter();
router.beforeEach((to) => {
if (!to.path.startsWith('/settings')) {
debugRevealed.value = false;
isDebugMode.value = systemData.logLevel.toLowerCase() === "debug";
}
});
});
// Make navigation reactive by wrapping in computed
const navigation = computed(() => [
{
label: "Home",
route: "/settings",
@ -57,7 +91,7 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
icon: HomeIcon,
},
{
label: "Interface",
label: "Interface",
route: "/settings/interface",
prefix: "/settings/interface",
icon: RectangleGroupIcon,
@ -68,7 +102,27 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
prefix: "/settings/downloads",
icon: ArrowDownTrayIcon,
},
];
{
label: "Account",
route: "/settings/account",
prefix: "/settings/account",
icon: UserIcon
},
...(isDebugMode.value ? [{
label: "Debug Info",
route: "/settings/debug",
prefix: "/settings/debug",
icon: BugAntIcon,
}] : []),
]);
const currentPageIndex = useCurrentNavigationIndex(navigation);
const currentPlatform = platform();
// Use .value to unwrap the computed ref
const {currentNavigation} = useCurrentNavigationIndex(navigation.value);
// Watch for navigation changes and update currentPageIndex
watch(navigation, (newNav) => {
currentNavigation.value = useCurrentNavigationIndex(newNav).currentNavigation.value;
});
</script>

View File

@ -0,0 +1,64 @@
<template>
<div class="border-b border-zinc-700 py-5">
<h3 class="text-base font-semibold font-display leading-6 text-zinc-100">
General
</h3>
</div>
<div class="mt-5 flex flex-col gap-4">
<div class="flex flex-row items-center justify-between">
<div>
<h3 class="text-sm font-medium leading-6 text-zinc-100">Sign out</h3>
<p class="mt-1 text-sm leading-6 text-zinc-400">
Sign out of your Drop account on this device
</p>
</div>
<button
@click="signOut"
type="button"
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
>
Sign out
</button>
</div>
<div v-if="error" class="rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useRouter } from "#imports";
import { XCircleIcon } from "@heroicons/vue/16/solid";
const router = useRouter();
const error = ref<string | null>(null);
// Listen for auth events
onMounted(async () => {
await listen("auth/signedout", () => {
router.push("/auth/signedout");
});
});
async function signOut() {
try {
error.value = null;
await invoke("sign_out");
} catch (e) {
error.value = `Failed to sign out: ${e}`;
}
}
</script>

136
pages/settings/debug.vue Normal file
View File

@ -0,0 +1,136 @@
<template>
<div class="divide-y divide-zinc-700">
<div class="py-6">
<h2 class="text-base font-semibold font-display leading-7 text-zinc-100">
Debug Information
</h2>
<p class="mt-1 text-sm leading-6 text-zinc-400">
Technical information about your Drop client installation, helpful for
debugging.
</p>
<div class="mt-10 space-y-8">
<div>
<div class="flex items-center gap-x-3">
<FingerPrintIcon class="h-5 w-5 text-zinc-400" />
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Client ID
</h3>
</div>
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
{{ clientId || "Not signed in" }}
</p>
</div>
<div>
<div class="flex items-center gap-x-3">
<ComputerDesktopIcon class="h-5 w-5 text-zinc-400" />
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Platform
</h3>
</div>
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
{{ platformInfo }}
</p>
</div>
<div>
<div class="flex items-center gap-x-3">
<ServerIcon class="h-5 w-5 text-zinc-400" />
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Server URL
</h3>
</div>
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
{{ baseUrl || "Not connected" }}
</p>
</div>
<div>
<div class="flex items-center gap-x-3">
<FolderIcon class="h-5 w-5 text-zinc-400" />
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Data Directory
</h3>
</div>
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
{{ dataDir || "Unknown" }}
</p>
</div>
<div class="pt-6 flex gap-x-4">
<button
@click="() => openDataDir()"
type="button"
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<FolderIcon class="h-5 w-5" aria-hidden="true" />
Open Data Directory
</button>
<button
@click="() => openLogFile()"
type="button"
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<DocumentTextIcon class="h-5 w-5" aria-hidden="true" />
Open Log File
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { platform, type } from "@tauri-apps/plugin-os";
import {
FingerPrintIcon,
TagIcon,
ComputerDesktopIcon,
ServerIcon,
FolderIcon,
CubeIcon,
DocumentTextIcon,
} from "@heroicons/vue/24/outline";
import { open } from "@tauri-apps/plugin-shell";
const clientId = ref<string | null>(null);
const platformInfo = ref("Loading...");
const baseUrl = ref<string | null>(null);
const dataDir = ref<string | null>(null);
const systemData = await invoke<{
clientId: string;
baseUrl: string;
dataDir: string;
}>("fetch_system_data");
console.log(systemData);
clientId.value = systemData.clientId;
baseUrl.value = systemData.baseUrl;
dataDir.value = systemData.dataDir;
const currentPlatform = await platform();
platformInfo.value = currentPlatform;
async function openDataDir() {
if (!dataDir.value) return;
try {
await open(dataDir.value);
} catch (error) {
console.error("Failed to open data dir:", error);
}
}
async function openLogFile() {
if (!dataDir.value) return;
try {
const logPath = `${dataDir.value}/drop.log`;
await open(logPath);
} catch (error) {
console.error("Failed to open log file:", error);
}
}
</script>

View File

@ -1,10 +1,15 @@
<template>
<div>
<div class="border-b border-zinc-600 py-2 px-1">
<div
class="-ml-4 -mt-2 flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<div class="ml-4 mt-2">
<div class="border-b border-zinc-700 py-5">
<h3 class="text-base font-semibold font-display leading-6 text-zinc-100">
Downloads
</h3>
</div>
<div class="mt-5">
<div class="border-b border-zinc-600">
<div class="-ml-4 -mt-2 flex flex-wrap items-center justify-between sm:flex-nowrap">
<div class="ml-4 mt-2 pb-4">
<h3 class="text-base font-display font-semibold text-zinc-100">
Install directories
</h3>
@ -15,27 +20,17 @@
</p>
</div>
<div class="ml-4 mt-2 shrink-0">
<button
@click="() => (open = true)"
type="button"
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<button @click="() => (open = true)" type="button"
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
Add new directory
</button>
</div>
</div>
</div>
<ul role="list" class="divide-y divide-gray-800">
<li
v-for="(dir, dirIdx) in dirs"
:key="dir"
class="flex justify-between gap-x-6 py-5"
>
<li v-for="(dir, dirIdx) in dirs" :key="dir" class="flex justify-between gap-x-6 py-5">
<div class="flex min-w-0 gap-x-4">
<FolderIcon
class="h-6 w-6 text-blue-600 flex-none rounded-full"
alt=""
/>
<FolderIcon class="h-6 w-6 text-blue-600 flex-none rounded-full" alt="" />
<div class="min-w-0 flex-auto">
<p class="text-sm/6 text-zinc-100">
{{ dir }}
@ -43,68 +38,97 @@
</div>
</div>
<div class="flex shrink-0 items-center gap-x-6">
<button
@click="() => deleteDirectory(dirIdx)"
:disabled="dirs.length <= 1"
:class="[
dirs.length <= 1
? 'text-zinc-700'
: 'text-zinc-400 hover:text-zinc-100',
'-m-2.5 block p-2.5',
]"
>
<button @click="() => deleteDirectory(dirIdx)" :disabled="dirs.length <= 1" :class="[
dirs.length <= 1
? 'text-zinc-700'
: 'text-zinc-400 hover:text-zinc-100',
'-m-2.5 block p-2.5',
]">
<span class="sr-only">Open options</span>
<TrashIcon class="size-5" aria-hidden="true" />
</button>
</div>
</li>
</ul>
<div class="border-t border-zinc-600 py-6">
<h3 class="text-base font-display font-semibold text-zinc-100">
Download Settings
</h3>
<p class="mt-1 text-sm text-zinc-400 max-w-xl">
Configure how Drop downloads games and other content.
</p>
<div class="mt-6 max-w-xl">
<label for="threads" class="block text-sm font-medium text-zinc-100">
Maximum Download Threads
</label>
<div class="mt-2">
<input type="number" name="threads" id="threads" min="1" max="32" v-model="downloadThreads"
@keypress="validateNumberInput" @paste="validatePaste"
class="block w-full rounded-md border-0 py-1.5 text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 bg-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" />
</div>
<p class="mt-2 text-sm text-zinc-400">
The maximum number of concurrent download threads. Higher values may
download faster but use more system resources. Default is 4.
</p>
</div>
<div class="mt-10 space-y-8">
<div class="flex flex-row items-center justify-between">
<div>
<h3 class="text-sm font-medium leading-6 text-zinc-100">Force Offline</h3>
<p class="mt-1 text-sm leading-6 text-zinc-400">
Drop will not make any external connections
</p>
</div>
<Switch v-model="forceOffline" :class="[
forceOffline ? 'bg-blue-600' : 'bg-zinc-700',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out'
]">
<span :class="[
forceOffline ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
]" />
</Switch>
</div>
</div>
<div class="mt-6">
<button type="button" @click="saveSettings" :disabled="saveState.loading" :class="[
'inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 transition-colors duration-300',
saveState.success
? 'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600'
: 'bg-blue-600 hover:bg-blue-500 focus-visible:outline-blue-600',
'disabled:bg-blue-600/50 disabled:cursor-not-allowed'
]">
{{ saveState.success ? 'Saved' : 'Save Changes' }}
</button>
</div>
</div>
</div>
<TransitionRoot as="template" :show="open">
<Dialog class="relative z-50" @close="open = false">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="fixed inset-0 bg-zinc-950 bg-opacity-75 transition-opacity"
/>
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100"
leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
<div class="fixed inset-0 bg-zinc-950 bg-opacity-75 transition-opacity" />
</TransitionChild>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div
class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<TransitionChild as="template" enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<DialogPanel
class="relative transform overflow-hidden rounded-lg bg-zinc-900 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6"
>
class="relative transform overflow-hidden rounded-lg bg-zinc-900 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mt-3 w-full sm:ml-4 sm:mt-0">
<div>
<label
for="dir"
class="block text-sm/6 font-medium text-zinc-100"
>Select game directory</label
>
<label for="dir" class="block text-sm/6 font-medium text-zinc-100">Select game directory</label>
<div class="mt-2">
<button
@click="() => selectDirectory()"
class="block text-left w-full rounded-md border-0 px-3 py-1.5 text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 bg-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm/6"
>
<button @click="() => selectDirectory()"
class="block text-left w-full rounded-md border-0 px-3 py-1.5 text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 bg-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm/6">
{{
currentDirectory ?? "Click to select a directory..."
}}
@ -117,36 +141,25 @@
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<LoadingButton
:disabled="currentDirectory == undefined"
type="button"
:loading="createDirectoryLoading"
@click="() => submitDirectory()"
:class="[
<LoadingButton :disabled="currentDirectory == undefined" type="button" :loading="createDirectoryLoading"
@click="() => submitDirectory()" :class="[
'inline-flex w-full shadow-sm sm:ml-3 sm:w-auto',
currentDirectory === undefined
? 'text-zinc-400 bg-blue-600/10 hover:bg-blue-600/10'
: 'text-white bg-blue-600 hover:bg-blue-500',
]"
>
]">
Add
</LoadingButton>
<button
type="button"
<button type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="() => cancelDirectory()"
ref="cancelButtonRef"
>
@click="() => cancelDirectory()" ref="cancelButtonRef">
Cancel
</button>
</div>
<div v-if="error" class="mt-3 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
@ -172,6 +185,8 @@ import {
} from "@headlessui/vue";
import { FolderIcon, TrashIcon, XCircleIcon } from "@heroicons/vue/16/solid";
import { invoke } from "@tauri-apps/api/core";
import { Switch } from '@headlessui/vue'
import { type Settings } from "~/types";
const open = ref(false);
const currentDirectory = ref<string | undefined>(undefined);
@ -180,6 +195,15 @@ const createDirectoryLoading = ref(false);
const dirs = ref<Array<string>>([]);
const settings = await invoke<Settings>("fetch_settings");
const downloadThreads = ref(settings?.maxDownloadThreads ?? 4);
const forceOffline = ref(settings?.forceOffline ?? false);
const saveState = reactive({
loading: false,
success: false
});
async function updateDirs() {
const newDirs = await invoke<Array<string>>("fetch_download_dir_stats");
dirs.value = newDirs;
@ -213,7 +237,7 @@ async function submitDirectory() {
try {
error.value = undefined;
if (!currentDirectory.value)
throw new Error("Please select a directory first.");
throw new Error("Please select a directory first");
createDirectoryLoading.value = true;
// Add directory
@ -235,4 +259,42 @@ async function deleteDirectory(index: number) {
await invoke("delete_download_dir", { index });
await updateDirs();
}
async function saveSettings() {
try {
saveState.loading = true;
await invoke("update_settings", {
newSettings: { maxDownloadThreads: downloadThreads.value, forceOffline: forceOffline.value },
});
// Show success state
saveState.success = true;
// Reset back to normal state after 2 seconds
setTimeout(() => {
saveState.success = false;
}, 2000);
} catch (error) {
console.error('Failed to save settings:', error);
} finally {
saveState.loading = false;
}
}
function validateNumberInput(event: KeyboardEvent) {
// Allow only numbers and basic control keys
if (!/^\d$/.test(event.key) &&
!['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
event.preventDefault();
}
}
function validatePaste(event: ClipboardEvent) {
// Prevent paste if content contains non-numeric characters
const pastedData = event.clipboardData?.getData('text');
if (pastedData && !/^\d+$/.test(pastedData)) {
event.preventDefault();
}
}
</script>

View File

@ -1,3 +1,59 @@
<template>
</template>
<div class="border-b border-zinc-700 py-5">
<h3 class="text-base font-semibold font-display leading-6 text-zinc-100">
General
</h3>
</div>
<div class="mt-5 space-y-8">
<div class="flex flex-row items-center justify-between">
<div>
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Start with system
</h3>
<p class="mt-1 text-sm leading-6 text-zinc-400">
Drop will automatically start when you log into your computer
</p>
</div>
<Switch
v-model="autostartEnabled"
:class="[
autostartEnabled ? 'bg-blue-600' : 'bg-zinc-700',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out',
]"
>
<span
:class="[
autostartEnabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
/>
</Switch>
</div>
</div>
</template>
<script setup lang="ts">
import { Switch } from "@headlessui/vue";
import { invoke } from "@tauri-apps/api/core";
defineProps<{}>();
const autostartEnabled = ref<boolean>(false);
// Load initial state
invoke("get_autostart_enabled").then((enabled) => {
autostartEnabled.value = enabled as boolean;
});
// Watch for changes and update autostart
watch(autostartEnabled, async (newValue: boolean) => {
try {
await invoke("toggle_autostart", { enabled: newValue });
} catch (error) {
console.error("Failed to toggle autostart:", error);
// Revert the toggle if it failed
autostartEnabled.value = !newValue;
}
});
</script>

View File

@ -1,3 +1,7 @@
<template>
</template>
</template>
<script setup lang="ts">
</script>

View File

@ -1,2 +1,4 @@
<template></template>
<template>
<iframe src="server://drop.local/store" class="w-full h-full" />
</template>
<script setup lang="ts"></script>

View File

@ -1,8 +1,11 @@
export default defineNuxtPlugin((nuxtApp) => {
// Also possible
/*
nuxtApp.hook("vue:error", (error, instance, info) => {
console.log(error);
console.error(error, info);
const router = useRouter();
router.replace(`/error`);
});
*/
});

3661
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
[package]
name = "drop-app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
version = "0.3.0-rc-5"
description = "The client application for the open-source, self-hosted game distribution platform Drop"
authors = ["Drop OSS"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -16,8 +16,6 @@ tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "drop_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build]
rustflags = ["-C", "target-feature=+aes,+sse2"]
@ -25,12 +23,10 @@ rustflags = ["-C", "target-feature=+aes,+sse2"]
tauri-build = { version = "2.0.0", features = [] }
[dependencies]
tauri-plugin-shell = "2.0.0"
serde = { version = "1", features = ["derive", "rc"] }
tauri-plugin-shell = "2.2.1"
serde_json = "1"
serde-binary = "0.5.0"
rayon = "1.10.0"
directories = "5.0.1"
webbrowser = "1.0.2"
url = "2.5.2"
tauri-plugin-deep-link = "2"
@ -41,13 +37,45 @@ http = "1.1.0"
urlencoding = "2.1.3"
md5 = "0.7.0"
chrono = "0.4.38"
tauri-plugin-os = "2"
boxcar = "0.2.7"
umu-wrapper-lib = "0.1.0"
tauri-plugin-autostart = "2.0.0"
shared_child = "1.0.1"
serde_with = "3.12.0"
slice-deque = "0.3.0"
throttle_my_fn = "0.2.6"
parking_lot = "0.12.3"
atomic-instant-full = "0.1.0"
cacache = "13.1.0"
http-serde = "2.1.1"
reqwest-middleware = "0.4.0"
reqwest-middleware-cache = "0.1.1"
deranged = "=0.4.0"
droplet-rs = "0.7.3"
gethostname = "1.0.1"
zstd = "0.13.3"
tar = "0.4.44"
rand = "0.9.1"
regex = "1.11.1"
tempfile = "3.19.1"
schemars = "0.8.22"
sha1 = "0.10.6"
dirs = "6.0.0"
whoami = "1.6.0"
filetime = "0.2.25"
walkdir = "2.5.0"
known-folders = "1.2.0"
native_model = { version = "0.6.1", features = ["rmp_serde_1_3"] }
# tailscale = { path = "./tailscale" }
[dependencies.dynfmt]
version = "0.1.5"
features = ["curly"]
[dependencies.tauri]
version = "2.1.1"
features = [
"tray-icon"
]
features = ["protocol-asset", "tray-icon"]
[dependencies.tokio]
version = "1.40.0"
@ -63,23 +91,20 @@ features = ["fs"]
[dependencies.uuid]
version = "1.10.0"
features = [
"v4", # Lets you generate random UUIDs
"fast-rng", # Use a faster (but still sufficiently random) RNG
"macro-diagnostics", # Enable better diagnostics for compile-time UUIDs
]
[dependencies.openssl]
version = "0.10.66"
features = ["vendored"]
features = ["v4", "fast-rng", "macro-diagnostics"]
[dependencies.rustbreak]
version = "2"
features = [] # You can also use "yaml_enc" or "bin_enc"
features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
[dependencies.reqwest]
version = "0.12"
features = ["json", "blocking"]
default-features = false
features = ["json", "http2", "blocking", "rustls-tls-webpki-roots"]
[dependencies.serde]
version = "1"
features = ["derive", "rc"]
[profile.release]
lto = true

View File

@ -13,6 +13,7 @@
"core:window:allow-maximize",
"core:window:allow-close",
"deep-link:default",
"dialog:default"
"dialog:default",
"os:default"
]
}

View File

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

View File

@ -1,221 +0,0 @@
use std::{
env,
sync::Mutex,
time::{SystemTime, UNIX_EPOCH},
};
use chrono::Utc;
use log::{info, warn};
use openssl::{ec::EcKey, hash::MessageDigest, pkey::PKey, sign::Signer};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager};
use url::Url;
use crate::{
db::{DatabaseAuth, DatabaseImpls},
remote::RemoteAccessError,
AppState, AppStatus, User, DB,
};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct InitiateRequestBody {
name: String,
platform: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct HandshakeRequestBody {
client_id: String,
token: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct HandshakeResponse {
private: String,
certificate: String,
id: String,
}
pub fn sign_nonce(private_key: String, nonce: String) -> Result<String, ()> {
let client_private_key = EcKey::private_key_from_pem(private_key.as_bytes()).unwrap();
let pkey_private_key = PKey::from_ec_key(client_private_key).unwrap();
let mut signer = Signer::new(MessageDigest::sha256(), &pkey_private_key).unwrap();
signer.update(nonce.as_bytes()).unwrap();
let signature = signer.sign_to_vec().unwrap();
let hex_signature = hex::encode(signature);
Ok(hex_signature)
}
pub fn generate_authorization_header() -> String {
let certs = {
let db = DB.borrow_data().unwrap();
db.auth.clone().unwrap()
};
let nonce = Utc::now().timestamp_millis().to_string();
let signature = sign_nonce(certs.private, nonce.clone()).unwrap();
format!("Nonce {} {} {}", certs.client_id, nonce, signature)
}
pub fn fetch_user() -> Result<User, RemoteAccessError> {
let base_url = DB.fetch_base_url();
let endpoint = base_url.join("/api/v1/client/user")?;
let header = generate_authorization_header();
let client = reqwest::blocking::Client::new();
let response = client
.get(endpoint.to_string())
.header("Authorization", header)
.send()?;
if response.status() != 200 {
info!("Could not fetch user: {}", response.text().unwrap());
return Err(RemoteAccessError::InvalidCodeError(0));
}
let user = response.json::<User>()?;
Ok(user)
}
fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAccessError> {
let path_chunks: Vec<&str> = path.split("/").collect();
if path_chunks.len() != 3 {
app.emit("auth/failed", ()).unwrap();
return Err(RemoteAccessError::InvalidResponse);
}
let base_url = {
let handle = DB.borrow_data().unwrap();
Url::parse(handle.base_url.as_str())?
};
let client_id = path_chunks.get(1).unwrap();
let token = path_chunks.get(2).unwrap();
let body = HandshakeRequestBody {
client_id: client_id.to_string(),
token: token.to_string(),
};
let endpoint = base_url.join("/api/v1/client/auth/handshake")?;
let client = reqwest::blocking::Client::new();
let response = client.post(endpoint).json(&body).send()?;
info!("{}", response.status().as_u16());
let response_struct = response.json::<HandshakeResponse>()?;
{
let mut handle = DB.borrow_data_mut().unwrap();
handle.auth = Some(DatabaseAuth {
private: response_struct.private,
cert: response_struct.certificate,
client_id: response_struct.id,
});
drop(handle);
DB.save().unwrap();
}
{
let app_state = app.state::<Mutex<AppState>>();
let mut app_state_handle = app_state.lock().unwrap();
app_state_handle.status = AppStatus::SignedIn;
app_state_handle.user = Some(fetch_user()?);
}
Ok(())
}
pub fn recieve_handshake(app: AppHandle, path: String) {
// Tell the app we're processing
app.emit("auth/processing", ()).unwrap();
let handshake_result = recieve_handshake_logic(&app, path);
if let Err(e) = handshake_result {
warn!("error with authentication: {}", e);
app.emit("auth/failed", e.to_string()).unwrap();
return;
}
app.emit("auth/finished", ()).unwrap();
}
async fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> {
let base_url = {
let db_lock = DB.borrow_data().unwrap();
Url::parse(&db_lock.base_url.clone())?
};
let endpoint = base_url.join("/api/v1/client/auth/initiate")?;
let body = InitiateRequestBody {
name: "Drop Desktop Client".to_string(),
platform: env::consts::OS.to_string(),
};
let client = reqwest::Client::new();
let response = client.post(endpoint.to_string()).json(&body).send().await?;
if response.status() != 200 {
return Err(RemoteAccessError::InvalidRedirect);
}
let redir_url = response.text().await?;
let complete_redir_url = base_url.join(&redir_url)?;
info!("opening web browser to continue authentication");
webbrowser::open(complete_redir_url.as_ref()).unwrap();
Ok(())
}
#[tauri::command]
pub async fn auth_initiate<'a>() -> Result<(), String> {
let result = auth_initiate_wrapper().await;
if result.is_err() {
return Err(result.err().unwrap().to_string());
}
Ok(())
}
#[tauri::command]
pub fn retry_connect(state: tauri::State<'_, Mutex<AppState>>) -> Result<(), ()> {
let (app_status, user) = setup()?;
let mut guard = state.lock().unwrap();
guard.status = app_status;
guard.user = user;
drop(guard);
Ok(())
}
pub fn setup() -> Result<(AppStatus, Option<User>), ()> {
let data = DB.borrow_data().unwrap();
if data.auth.is_some() {
let user_result = fetch_user();
if user_result.is_err() {
let error = user_result.err().unwrap();
warn!("auth setup failed with: {}", error);
match error {
RemoteAccessError::FetchError(_) => {
return Ok((AppStatus::ServerUnavailable, None))
}
_ => return Ok((AppStatus::SignedInNeedsReauth, None)),
}
}
return Ok((AppStatus::SignedIn, Some(user_result.unwrap())));
}
drop(data);
Ok((AppStatus::SignedOut, None))
}

View File

@ -1,18 +0,0 @@
use std::sync::Mutex;
use log::info;
use tauri::AppHandle;
use crate::AppState;
#[tauri::command]
pub fn quit(app: tauri::AppHandle) {
cleanup_and_exit(&app);
}
pub fn cleanup_and_exit(app: &AppHandle, ) {
info!("exiting drop application...");
app.exit(0);
}

View File

@ -0,0 +1,75 @@
use crate::database::db::{borrow_db_checked, borrow_db_mut_checked};
use log::debug;
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;
pub fn toggle_autostart_logic(app: AppHandle, enabled: bool) -> Result<(), String> {
let manager = app.autolaunch();
if enabled {
manager.enable().map_err(|e| e.to_string())?;
debug!("enabled autostart");
} else {
manager.disable().map_err(|e| e.to_string())?;
debug!("eisabled autostart");
}
// Store the state in DB
let mut db_handle = borrow_db_mut_checked();
db_handle.settings.autostart = enabled;
drop(db_handle);
Ok(())
}
pub fn get_autostart_enabled_logic(app: AppHandle) -> Result<bool, tauri_plugin_autostart::Error> {
// First check DB state
let db_handle = borrow_db_checked();
let db_state = db_handle.settings.autostart;
drop(db_handle);
// Get actual system state
let manager = app.autolaunch();
let system_state = manager.is_enabled()?;
// If they don't match, sync to DB state
if db_state != system_state {
if db_state {
manager.enable()?;
} else {
manager.disable()?;
}
}
Ok(db_state)
}
// New function to sync state on startup
pub fn sync_autostart_on_startup(app: &AppHandle) -> Result<(), String> {
let db_handle = borrow_db_checked();
let should_be_enabled = db_handle.settings.autostart;
drop(db_handle);
let manager = app.autolaunch();
let current_state = manager.is_enabled().map_err(|e| e.to_string())?;
if current_state != should_be_enabled {
if should_be_enabled {
manager.enable().map_err(|e| e.to_string())?;
debug!("synced autostart: enabled");
} else {
manager.disable().map_err(|e| e.to_string())?;
debug!("synced autostart: disabled");
}
}
Ok(())
}
#[tauri::command]
pub fn toggle_autostart(app: AppHandle, enabled: bool) -> Result<(), String> {
toggle_autostart_logic(app, enabled)
}
#[tauri::command]
pub fn get_autostart_enabled(app: AppHandle) -> Result<bool, tauri_plugin_autostart::Error> {
get_autostart_enabled_logic(app)
}

View File

@ -0,0 +1,23 @@
use log::{debug, error};
use tauri::AppHandle;
use crate::AppState;
#[tauri::command]
pub fn quit(app: tauri::AppHandle, state: tauri::State<'_, std::sync::Mutex<AppState<'_>>>) {
cleanup_and_exit(&app, &state);
}
pub fn cleanup_and_exit(app: &AppHandle, state: &tauri::State<'_, std::sync::Mutex<AppState<'_>>>) {
debug!("cleaning up and exiting application");
let download_manager = state.lock().unwrap().download_manager.clone();
match download_manager.ensure_terminated() {
Ok(res) => match res {
Ok(_) => debug!("download manager terminated correctly"),
Err(_) => error!("download manager failed to terminate correctly"),
},
Err(e) => panic!("{:?}", e),
}
app.exit(0);
}

View File

@ -0,0 +1,11 @@
use crate::AppState;
#[tauri::command]
pub fn fetch_state(
state: tauri::State<'_, std::sync::Mutex<AppState<'_>>>,
) -> Result<String, String> {
let guard = state.lock().unwrap();
let cloned_state = serde_json::to_string(&guard.clone()).map_err(|e| e.to_string())?;
drop(guard);
Ok(cloned_state)
}

View File

@ -0,0 +1,3 @@
pub mod autostart;
pub mod cleanup;
pub mod commands;

View File

@ -0,0 +1,102 @@
use std::{collections::HashMap, path::PathBuf, str::FromStr};
use log::warn;
use crate::{database::db::{GameVersion, DATA_ROOT_DIR}, error::backup_error::BackupError, process::process_manager::Platform};
use super::path::CommonPath;
pub struct BackupManager<'a> {
pub current_platform: Platform,
pub sources: HashMap<(Platform, Platform), &'a (dyn BackupHandler + Sync + Send)>,
}
impl BackupManager<'_> {
pub fn new() -> Self {
BackupManager {
#[cfg(target_os = "windows")]
current_platform: Platform::Windows,
#[cfg(target_os = "macos")]
current_platform: Platform::MacOs,
#[cfg(target_os = "linux")]
current_platform: Platform::Linux,
sources: HashMap::from([
// Current platform to target platform
(
(Platform::Windows, Platform::Windows),
&WindowsBackupManager {} as &(dyn BackupHandler + Sync + Send),
),
(
(Platform::Linux, Platform::Linux),
&LinuxBackupManager {} as &(dyn BackupHandler + Sync + Send),
),
(
(Platform::MacOs, Platform::MacOs),
&MacBackupManager {} as &(dyn BackupHandler + Sync + Send),
),
]),
}
}
}
pub trait BackupHandler: Send + Sync {
fn root_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(DATA_ROOT_DIR.lock().unwrap().join("games")) }
fn game_translate(&self, _path: &PathBuf, game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(PathBuf::from_str(&game.game_id).unwrap()) }
fn base_translate(&self, path: &PathBuf, game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(self.root_translate(path, game)?.join(self.game_translate(path, game)?)) }
fn home_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { let c = CommonPath::Home.get().ok_or(BackupError::NotFound); println!("{:?}", c); c }
fn store_user_id_translate(&self, _path: &PathBuf, game: &GameVersion) -> Result<PathBuf, BackupError> { PathBuf::from_str(&game.game_id).map_err(|_| BackupError::ParseError) }
fn os_user_name_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(PathBuf::from_str(&whoami::username()).unwrap()) }
fn win_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winAppData>"); Err(BackupError::InvalidSystem) }
fn win_local_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winLocalAppData>"); Err(BackupError::InvalidSystem) }
fn win_local_app_data_low_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winLocalAppDataLow>"); Err(BackupError::InvalidSystem) }
fn win_documents_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winDocuments>"); Err(BackupError::InvalidSystem) }
fn win_public_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winPublic>"); Err(BackupError::InvalidSystem) }
fn win_program_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winProgramData>"); Err(BackupError::InvalidSystem) }
fn win_dir_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winDir>"); Err(BackupError::InvalidSystem) }
fn xdg_data_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected XDG Reference in Backup <xdgData>"); Err(BackupError::InvalidSystem) }
fn xdg_config_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected XDG Reference in Backup <xdgConfig>"); Err(BackupError::InvalidSystem) }
fn skip_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(PathBuf::new()) }
}
pub struct LinuxBackupManager {}
impl BackupHandler for LinuxBackupManager {
fn xdg_config_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::Data.get().ok_or(BackupError::NotFound)?)
}
fn xdg_data_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::Config.get().ok_or(BackupError::NotFound)?)
}
}
pub struct WindowsBackupManager {}
impl BackupHandler for WindowsBackupManager {
fn win_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::Config.get().ok_or(BackupError::NotFound)?)
}
fn win_local_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::DataLocal.get().ok_or(BackupError::NotFound)?)
}
fn win_local_app_data_low_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::DataLocalLow.get().ok_or(BackupError::NotFound)?)
}
fn win_dir_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(PathBuf::from_str("C:/Windows").unwrap())
}
fn win_documents_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::Document.get().ok_or(BackupError::NotFound)?)
}
fn win_program_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(PathBuf::from_str("C:/ProgramData").unwrap())
}
fn win_public_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::Public.get().ok_or(BackupError::NotFound)?)
}
}
pub struct MacBackupManager {}
impl BackupHandler for MacBackupManager {}

View File

@ -0,0 +1,6 @@
use crate::process::process_manager::Platform;
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Condition {
Os(Platform)
}

View File

@ -0,0 +1,35 @@
use crate::database::db::GameVersion;
use super::conditions::{Condition};
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CloudSaveMetadata {
pub files: Vec<GameFile>,
pub game_version: GameVersion,
pub save_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameFile {
pub path: String,
pub id: Option<String>,
pub data_type: DataType,
pub tags: Vec<Tag>,
pub conditions: Vec<Condition>
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub enum DataType {
Registry,
File,
Other
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Tag {
Config,
Save,
#[default]
#[serde(other)]
Other,
}

View File

@ -0,0 +1,7 @@
pub mod conditions;
pub mod metadata;
pub mod resolver;
pub mod placeholder;
pub mod normalise;
pub mod path;
pub mod backup_manager;

View File

@ -0,0 +1,162 @@
use std::sync::LazyLock;
use regex::Regex;
use crate::process::process_manager::Platform;
use super::placeholder::*;
pub fn normalize(path: &str, os: Platform) -> String {
let mut path = path.trim().trim_end_matches(['/', '\\']).replace('\\', "/");
if path == "~" || path.starts_with("~/") {
path = path.replacen('~', HOME, 1);
}
static CONSECUTIVE_SLASHES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"/{2,}").unwrap());
static UNNECESSARY_DOUBLE_STAR_1: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"([^/*])\*{2,}").unwrap());
static UNNECESSARY_DOUBLE_STAR_2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*{2,}([^/*])").unwrap());
static ENDING_WILDCARD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\*)+$").unwrap());
static ENDING_DOT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\.)$").unwrap());
static INTERMEDIATE_DOT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\./)").unwrap());
static BLANK_SEGMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\s+/)").unwrap());
static APP_DATA: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%appdata%").unwrap());
static APP_DATA_ROAMING: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Roaming").unwrap());
static APP_DATA_LOCAL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%localappdata%").unwrap());
static APP_DATA_LOCAL_2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Local/").unwrap());
static USER_PROFILE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%").unwrap());
static DOCUMENTS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/Documents").unwrap());
for (pattern, replacement) in [
(&CONSECUTIVE_SLASHES, "/"),
(&UNNECESSARY_DOUBLE_STAR_1, "${1}*"),
(&UNNECESSARY_DOUBLE_STAR_2, "*${1}"),
(&ENDING_WILDCARD, ""),
(&ENDING_DOT, ""),
(&INTERMEDIATE_DOT, "/"),
(&BLANK_SEGMENT, "/"),
(&APP_DATA, WIN_APP_DATA),
(&APP_DATA_ROAMING, WIN_APP_DATA),
(&APP_DATA_LOCAL, WIN_LOCAL_APP_DATA),
(&APP_DATA_LOCAL_2, &format!("{}/", WIN_LOCAL_APP_DATA)),
(&USER_PROFILE, HOME),
(&DOCUMENTS, WIN_DOCUMENTS),
] {
path = pattern.replace_all(&path, replacement).to_string();
}
if os == Platform::Windows {
let documents_2: Regex = Regex::new(r"(?i)<home>/Documents").unwrap();
#[allow(clippy::single_element_loop)]
for (pattern, replacement) in [(&documents_2, WIN_DOCUMENTS)] {
path = pattern.replace_all(&path, replacement).to_string();
}
}
for (pattern, replacement) in [
("{64BitSteamID}", STORE_USER_ID),
("{Steam3AccountID}", STORE_USER_ID),
] {
path = path.replace(pattern, replacement);
}
path
}
fn too_broad(path: &str) -> bool {
println!("Path: {}", path);
use {BASE, HOME, ROOT, STORE_USER_ID, WIN_APP_DATA, WIN_DIR, WIN_DOCUMENTS, XDG_CONFIG, XDG_DATA};
let path_lower = path.to_lowercase();
for item in ALL {
if path == *item {
return true;
}
}
for item in AVOID_WILDCARDS {
if path.starts_with(&format!("{}/*", item)) || path.starts_with(&format!("{}/{}", item, STORE_USER_ID)) {
return true;
}
}
// These paths are present whether or not the game is installed.
// If possible, they should be narrowed down on the wiki.
for item in [
format!("{}/{}", BASE, STORE_USER_ID), // because `<storeUserId>` is handled as `*`
format!("{}/Documents", HOME),
format!("{}/Saved Games", HOME),
format!("{}/AppData", HOME),
format!("{}/AppData/Local", HOME),
format!("{}/AppData/Local/Packages", HOME),
format!("{}/AppData/LocalLow", HOME),
format!("{}/AppData/Roaming", HOME),
format!("{}/Documents/My Games", HOME),
format!("{}/Library/Application Support", HOME),
format!("{}/Library/Application Support/UserData", HOME),
format!("{}/Library/Preferences", HOME),
format!("{}/.renpy", HOME),
format!("{}/.renpy/persistent", HOME),
format!("{}/Library", HOME),
format!("{}/Library/RenPy", HOME),
format!("{}/Telltale Games", HOME),
format!("{}/config", ROOT),
format!("{}/MMFApplications", WIN_APP_DATA),
format!("{}/RenPy", WIN_APP_DATA),
format!("{}/RenPy/persistent", WIN_APP_DATA),
format!("{}/win.ini", WIN_DIR),
format!("{}/SysWOW64", WIN_DIR),
format!("{}/My Games", WIN_DOCUMENTS),
format!("{}/Telltale Games", WIN_DOCUMENTS),
format!("{}/unity3d", XDG_CONFIG),
format!("{}/unity3d", XDG_DATA),
"C:/Program Files".to_string(),
"C:/Program Files (x86)".to_string(),
] {
let item = item.to_lowercase();
if path_lower == item
|| path_lower.starts_with(&format!("{}/*", item))
|| path_lower.starts_with(&format!("{}/{}", item, STORE_USER_ID.to_lowercase()))
|| path_lower.starts_with(&format!("{}/savesdir", item))
{
return true;
}
}
// Drive letters:
let drives: Regex = Regex::new(r"^[a-zA-Z]:$").unwrap();
if drives.is_match(path) {
return true;
}
// Colon not for a drive letter
if path.get(2..).is_some_and(|path| path.contains(':')) {
return true;
}
// Root:
if path == "/" {
return true;
}
// Relative path wildcard:
if path.starts_with('*') {
return true;
}
false
}
pub fn usable(path: &str) -> bool {
let unprintable: Regex = Regex::new(r"(\p{Cc}|\p{Cf})").unwrap();
!path.is_empty()
&& !path.contains("{{")
&& !path.starts_with("./")
&& !path.starts_with("../")
&& !too_broad(path)
&& !unprintable.is_match(path)
}

View File

@ -0,0 +1,48 @@
use std::{path::PathBuf, sync::LazyLock};
pub enum CommonPath {
Config,
Data,
DataLocal,
DataLocalLow,
Document,
Home,
Public,
SavedGames,
}
impl CommonPath {
pub fn get(&self) -> Option<PathBuf> {
static CONFIG: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::config_dir());
static DATA: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::data_dir());
static DATA_LOCAL: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::data_local_dir());
static DOCUMENT: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::document_dir());
static HOME: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::home_dir());
static PUBLIC: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::public_dir());
#[cfg(windows)]
static DATA_LOCAL_LOW: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
known_folders::get_known_folder_path(known_folders::KnownFolder::LocalAppDataLow)
});
#[cfg(not(windows))]
static DATA_LOCAL_LOW: Option<PathBuf> = None;
#[cfg(windows)]
static SAVED_GAMES: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
known_folders::get_known_folder_path(known_folders::KnownFolder::SavedGames)
});
#[cfg(not(windows))]
static SAVED_GAMES: Option<PathBuf> = None;
match self {
Self::Config => CONFIG.clone(),
Self::Data => DATA.clone(),
Self::DataLocal => DATA_LOCAL.clone(),
Self::DataLocalLow => DATA_LOCAL_LOW.clone(),
Self::Document => DOCUMENT.clone(),
Self::Home => HOME.clone(),
Self::Public => PUBLIC.clone(),
Self::SavedGames => SAVED_GAMES.clone(),
}
}
}

View File

@ -0,0 +1,51 @@
use std::sync::LazyLock;
pub const ALL: &[&str] = &[
ROOT,
GAME,
BASE,
HOME,
STORE_USER_ID,
OS_USER_NAME,
WIN_APP_DATA,
WIN_LOCAL_APP_DATA,
WIN_DOCUMENTS,
WIN_PUBLIC,
WIN_PROGRAM_DATA,
WIN_DIR,
XDG_DATA,
XDG_CONFIG,
];
/// These are paths where `<placeholder>/*/` is suspicious.
pub const AVOID_WILDCARDS: &[&str] = &[
ROOT,
HOME,
WIN_APP_DATA,
WIN_LOCAL_APP_DATA,
WIN_DOCUMENTS,
WIN_PUBLIC,
WIN_PROGRAM_DATA,
WIN_DIR,
XDG_DATA,
XDG_CONFIG,
];
pub const ROOT: &str = "<root>"; // a directory where games are installed (configured in backup tool)
pub const GAME: &str = "<game>"; // an installDir (if defined) or the game's canonical name in the manifest
pub const BASE: &str = "<base>"; // shorthand for <root>/<game> (unless overridden by store-specific rules)
pub const HOME: &str = "<home>"; // current user's home directory in the OS (~)
pub const STORE_USER_ID: &str = "<storeUserId>"; // a store-specific id from the manifest, corresponding to the root's store type
pub const OS_USER_NAME: &str = "<osUserName>"; // current user's ID in the game store
pub const WIN_APP_DATA: &str = "<winAppData>"; // current user's name in the OS
pub const WIN_LOCAL_APP_DATA: &str = "<winLocalAppData>"; // %APPDATA% on Windows
pub const WIN_LOCAL_APP_DATA_LOW: &str = "<winLocalAppDataLow>"; // %LOCALAPPDATA% on Windows
pub const WIN_DOCUMENTS: &str = "<winDocuments>"; // <home>/AppData/LocalLow on Windows
pub const WIN_PUBLIC: &str = "<winPublic>"; // <home>/Documents (f.k.a. <home>/My Documents) or a localized equivalent on Windows
pub const WIN_PROGRAM_DATA: &str = "<winProgramData>"; // %PUBLIC% on Windows
pub const WIN_DIR: &str = "<winDir>"; // %PROGRAMDATA% on Windows
pub const XDG_DATA: &str = "<xdgData>"; // %WINDIR% on Windows
pub const XDG_CONFIG: &str = "<xdgConfig>"; // $XDG_DATA_HOME on Linux
pub const SKIP: &str = "<skip>"; // $XDG_CONFIG_HOME on Linux
pub static OS_USERNAME: LazyLock<String> = LazyLock::new(|| whoami::username());

View File

@ -0,0 +1,262 @@
use std::{
fs::{self, create_dir_all, File},
io::{self, ErrorKind, Read, Write},
path::{Path, PathBuf},
thread::sleep,
time::Duration,
};
use super::{
backup_manager::BackupHandler, conditions::Condition, metadata::GameFile, placeholder::*,
};
use log::{debug, warn};
use rustix::path::Arg;
use tempfile::tempfile;
use crate::{
database::db::GameVersion, error::backup_error::BackupError, process::process_manager::Platform,
};
use super::{backup_manager::BackupManager, metadata::CloudSaveMetadata, normalise::normalize};
pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
let f = File::create_new("save").unwrap();
let compressor = zstd::Encoder::new(f, 22).unwrap();
let mut tarball = tar::Builder::new(compressor);
let manager = BackupManager::new();
for file in meta.files.iter_mut() {
let id = uuid::Uuid::new_v4().to_string();
let os = match file
.conditions
.iter()
.find_map(|p| match p {
super::conditions::Condition::Os(os) => Some(os),
_ => None,
})
.cloned()
{
Some(os) => os,
None => {
warn!(
"File {:?} could not be backed up because it did not provide an OS",
&file
);
continue;
}
};
let handler = match manager.sources.get(&(manager.current_platform, os)) {
Some(h) => *h,
None => continue,
};
let t_path = PathBuf::from(normalize(&file.path, os));
println!("{:?}", &t_path);
let path = parse_path(t_path, handler, &meta.game_version).unwrap();
let f = std::fs::metadata(&path).unwrap(); // TODO: Fix unwrap here
if f.is_dir() {
tarball.append_dir_all(&id, path).unwrap();
} else if f.is_file() {
tarball
.append_file(&id, &mut File::open(path).unwrap())
.unwrap();
}
file.id = Some(id);
}
let binding = serde_json::to_string(meta).unwrap();
let serialized = binding.as_bytes();
let mut file = tempfile().unwrap();
file.write(serialized).unwrap();
tarball.append_file("metadata", &mut file).unwrap();
tarball.into_inner().unwrap().finish().unwrap()
}
pub fn extract(file: PathBuf) -> Result<(), BackupError> {
let tmpdir = tempfile::tempdir().unwrap();
// Reopen the file for reading
let file = File::open(file).unwrap();
let decompressor = zstd::Decoder::new(file).unwrap();
let mut f = tar::Archive::new(decompressor);
f.unpack(tmpdir.path()).unwrap();
let path = tmpdir.path();
let mut manifest = File::open(path.join("metadata")).unwrap();
let mut manifest_slice = Vec::new();
manifest.read_to_end(&mut manifest_slice).unwrap();
let manifest: CloudSaveMetadata = serde_json::from_slice(&manifest_slice).unwrap();
for file in manifest.files {
let current_path = path.join(file.id.as_ref().unwrap());
let manager = BackupManager::new();
let os = match file
.conditions
.iter()
.find_map(|p| match p {
super::conditions::Condition::Os(os) => Some(os),
_ => None,
})
.cloned()
{
Some(os) => os,
None => {
warn!(
"File {:?} could not be replaced up because it did not provide an OS",
&file
);
continue;
}
};
let handler = match manager.sources.get(&(manager.current_platform, os)) {
Some(h) => *h,
None => continue,
};
let new_path = parse_path(file.path.into(), handler, &manifest.game_version)?;
create_dir_all(&new_path.parent().unwrap()).unwrap();
println!(
"Current path {:?} copying to {:?}",
&current_path, &new_path
);
copy_item(current_path, new_path).unwrap();
}
Ok(())
}
pub fn copy_item<P: AsRef<Path>>(src: P, dest: P) -> io::Result<()> {
let src_path = src.as_ref();
let dest_path = dest.as_ref();
let metadata = fs::metadata(&src_path)?;
if metadata.is_file() {
// Ensure the parent directory of the destination exists for a file copy
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&src_path, &dest_path)?;
} else if metadata.is_dir() {
// For directories, we call the recursive helper function.
// The destination for the recursive copy is the `dest_path` itself.
copy_dir_recursive(&src_path, &dest_path)?;
} else {
// Handle other file types like symlinks if necessary,
// for now, return an error or skip.
return Err(io::Error::new(
io::ErrorKind::Other,
format!("Source {:?} is neither a file nor a directory", src_path),
));
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
fs::create_dir_all(&dest)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let entry_path = entry.path();
let entry_file_name = match entry_path.file_name() {
Some(name) => name,
None => continue, // Skip if somehow there's no file name
};
let dest_entry_path = dest.join(entry_file_name);
let metadata = entry.metadata()?;
if metadata.is_file() {
debug!(
"Writing file {} to {}",
entry_path.display(),
dest_entry_path.display()
);
fs::copy(&entry_path, &dest_entry_path)?;
} else if metadata.is_dir() {
copy_dir_recursive(&entry_path, &dest_entry_path)?;
}
// Ignore other types like symlinks for this basic implementation
}
Ok(())
}
pub fn parse_path(
path: PathBuf,
backup_handler: &dyn BackupHandler,
game: &GameVersion,
) -> Result<PathBuf, BackupError> {
println!("Parsing: {:?}", &path);
let mut s = PathBuf::new();
for component in path.components() {
match component.as_str().unwrap() {
ROOT => s.push(backup_handler.root_translate(&path, game)?),
GAME => s.push(backup_handler.game_translate(&path, game)?),
BASE => s.push(backup_handler.base_translate(&path, game)?),
HOME => s.push(backup_handler.home_translate(&path, game)?),
STORE_USER_ID => s.push(backup_handler.store_user_id_translate(&path, game)?),
OS_USER_NAME => s.push(backup_handler.os_user_name_translate(&path, game)?),
WIN_APP_DATA => s.push(backup_handler.win_app_data_translate(&path, game)?),
WIN_LOCAL_APP_DATA => s.push(backup_handler.win_local_app_data_translate(&path, game)?),
WIN_LOCAL_APP_DATA_LOW => {
s.push(backup_handler.win_local_app_data_low_translate(&path, game)?)
}
WIN_DOCUMENTS => s.push(backup_handler.win_documents_translate(&path, game)?),
WIN_PUBLIC => s.push(backup_handler.win_public_translate(&path, game)?),
WIN_PROGRAM_DATA => s.push(backup_handler.win_program_data_translate(&path, game)?),
WIN_DIR => s.push(backup_handler.win_dir_translate(&path, game)?),
XDG_DATA => s.push(backup_handler.xdg_data_translate(&path, game)?),
XDG_CONFIG => s.push(backup_handler.xdg_config_translate(&path, game)?),
SKIP => s.push(backup_handler.skip_translate(&path, game)?),
_ => s.push(PathBuf::from(component.as_os_str())),
}
}
println!("Final line: {:?}", &s);
Ok(s)
}
pub fn test() {
let mut meta = CloudSaveMetadata {
files: vec![
GameFile {
path: String::from("<home>/favicon.png"),
id: None,
data_type: super::metadata::DataType::File,
tags: Vec::new(),
conditions: vec![Condition::Os(Platform::Linux)],
},
GameFile {
path: String::from("<home>/Documents/Pixel Art"),
id: None,
data_type: super::metadata::DataType::File,
tags: Vec::new(),
conditions: vec![Condition::Os(Platform::Linux)],
},
],
game_version: GameVersion {
game_id: String::new(),
version_name: String::new(),
platform: Platform::Linux,
launch_command: String::new(),
launch_args: Vec::new(),
launch_command_template: String::new(),
setup_command: String::new(),
setup_args: Vec::new(),
setup_command_template: String::new(),
only_setup: true,
version_index: 0,
delta: false,
umu_id_override: None,
},
save_id: String::from("aaaaaaa"),
};
//resolve(&mut meta);
extract("save".into()).unwrap();
}

View File

View File

@ -0,0 +1,88 @@
use std::{
fs::create_dir_all,
io::{Error, ErrorKind},
path::{Path, PathBuf},
};
use serde_json::Value;
use crate::{
database::db::borrow_db_mut_checked, error::download_manager_error::DownloadManagerError,
};
use super::{
db::{borrow_db_checked, DATA_ROOT_DIR},
debug::SystemData,
models::data::Settings,
};
// Will, in future, return disk/remaining size
// Just returns the directories that have been set up
#[tauri::command]
pub fn fetch_download_dir_stats() -> Vec<PathBuf> {
let lock = borrow_db_checked();
lock.applications.install_dirs.clone()
}
#[tauri::command]
pub fn delete_download_dir(index: usize) {
let mut lock = borrow_db_mut_checked();
lock.applications.install_dirs.remove(index);
}
#[tauri::command]
pub fn add_download_dir(new_dir: PathBuf) -> Result<(), DownloadManagerError<()>> {
// Check the new directory is all good
let new_dir_path = Path::new(&new_dir);
if new_dir_path.exists() {
let dir_contents = new_dir_path.read_dir()?;
if dir_contents.count() != 0 {
return Err(Error::new(
ErrorKind::DirectoryNotEmpty,
"Selected directory cannot contain any existing files",
)
.into());
}
} else {
create_dir_all(new_dir_path)?;
}
// Add it to the dictionary
let mut lock = borrow_db_mut_checked();
if lock.applications.install_dirs.contains(&new_dir) {
return Err(Error::new(
ErrorKind::AlreadyExists,
"Selected directory already exists in database",
)
.into());
}
lock.applications.install_dirs.push(new_dir);
drop(lock);
Ok(())
}
#[tauri::command]
pub fn update_settings(new_settings: Value) {
let mut db_lock = borrow_db_mut_checked();
let mut current_settings = serde_json::to_value(db_lock.settings.clone()).unwrap();
for (key, value) in new_settings.as_object().unwrap() {
current_settings[key] = value.clone();
}
let new_settings: Settings = serde_json::from_value(current_settings).unwrap();
db_lock.settings = new_settings;
}
#[tauri::command]
pub fn fetch_settings() -> Settings {
borrow_db_checked().settings.clone()
}
#[tauri::command]
pub fn fetch_system_data() -> SystemData {
let db_handle = borrow_db_checked();
SystemData::new(
db_handle.auth.as_ref().unwrap().client_id.clone(),
db_handle.base_url.clone(),
DATA_ROOT_DIR.lock().unwrap().to_string_lossy().to_string(),
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
)
}

View File

@ -0,0 +1,182 @@
use std::{
fs::{self, create_dir_all},
mem::ManuallyDrop,
ops::{Deref, DerefMut},
path::PathBuf,
sync::{LazyLock, Mutex, RwLockReadGuard, RwLockWriteGuard},
};
use chrono::Utc;
use log::{debug, error, info, warn};
use native_model::{Decode, Encode};
use rustbreak::{DeSerError, DeSerializer, PathDatabase, RustbreakError};
use serde::{de::DeserializeOwned, Serialize};
use url::Url;
use crate::DB;
use super::models::data::Database;
pub static DATA_ROOT_DIR: LazyLock<Mutex<PathBuf>> =
LazyLock::new(|| Mutex::new(dirs::data_dir().unwrap().join("drop")));
// Custom JSON serializer to support everything we need
#[derive(Debug, Default, Clone)]
pub struct DropDatabaseSerializer;
impl<T: native_model::Model + Serialize + DeserializeOwned> DeSerializer<T>
for DropDatabaseSerializer
{
fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult<Vec<u8>> {
native_model::rmp_serde_1_3::RmpSerde::encode(val)
.map_err(|e| DeSerError::Internal(e.to_string()))
}
fn deserialize<R: std::io::Read>(&self, mut s: R) -> rustbreak::error::DeSerResult<T> {
let mut buf = Vec::new();
s.read_to_end(&mut buf)
.map_err(|e| rustbreak::error::DeSerError::Other(e.into()))?;
let val = native_model::rmp_serde_1_3::RmpSerde::decode(buf)
.map_err(|e| DeSerError::Internal(e.to_string()))?;
Ok(val)
}
}
pub type DatabaseInterface =
rustbreak::Database<Database, rustbreak::backend::PathBackend, DropDatabaseSerializer>;
pub trait DatabaseImpls {
fn set_up_database() -> DatabaseInterface;
fn database_is_set_up(&self) -> bool;
fn fetch_base_url(&self) -> Url;
}
impl DatabaseImpls for DatabaseInterface {
fn set_up_database() -> DatabaseInterface {
let data_root_dir = DATA_ROOT_DIR.lock().unwrap();
let db_path = data_root_dir.join("drop.db");
let games_base_dir = data_root_dir.join("games");
let logs_root_dir = data_root_dir.join("logs");
let cache_dir = data_root_dir.join("cache");
let pfx_dir = data_root_dir.join("pfx");
debug!("creating data directory at {:?}", data_root_dir);
create_dir_all(data_root_dir.clone()).unwrap();
create_dir_all(&games_base_dir).unwrap();
create_dir_all(&logs_root_dir).unwrap();
create_dir_all(&cache_dir).unwrap();
create_dir_all(&pfx_dir).unwrap();
let exists = fs::exists(db_path.clone()).unwrap();
match exists {
true => match PathDatabase::load_from_path(db_path.clone()) {
Ok(db) => db,
Err(e) => handle_invalid_database(e, db_path, games_base_dir, cache_dir),
},
false => {
let default = Database::new(games_base_dir, None, cache_dir);
debug!(
"Creating database at path {}",
db_path.as_os_str().to_str().unwrap()
);
PathDatabase::create_at_path(db_path, default)
.expect("Database could not be created")
}
}
}
fn database_is_set_up(&self) -> bool {
!self.borrow_data().unwrap().base_url.is_empty()
}
fn fetch_base_url(&self) -> Url {
let handle = self.borrow_data().unwrap();
Url::parse(&handle.base_url).unwrap()
}
}
// TODO: Make the error relelvant rather than just assume that it's a Deserialize error
fn handle_invalid_database(
_e: RustbreakError,
db_path: PathBuf,
games_base_dir: PathBuf,
cache_dir: PathBuf,
) -> rustbreak::Database<Database, rustbreak::backend::PathBackend, DropDatabaseSerializer> {
warn!("{}", _e);
let new_path = {
let time = Utc::now().timestamp();
let mut base = db_path.clone();
base.set_file_name(format!("drop.db.backup-{}", time));
base
};
info!(
"old database stored at: {}",
new_path.to_string_lossy().to_string()
);
fs::rename(&db_path, &new_path).unwrap();
let db = Database::new(
games_base_dir.into_os_string().into_string().unwrap(),
Some(new_path),
cache_dir,
);
PathDatabase::create_at_path(db_path, db).expect("Database could not be created")
}
// To automatically save the database upon drop
pub struct DBRead<'a>(RwLockReadGuard<'a, Database>);
pub struct DBWrite<'a>(ManuallyDrop<RwLockWriteGuard<'a, Database>>);
impl<'a> Deref for DBWrite<'a> {
type Target = RwLockWriteGuard<'a, Database>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'a> Deref for DBRead<'a> {
type Target = RwLockReadGuard<'a, Database>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'a> DerefMut for DBWrite<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<'a> Drop for DBWrite<'a> {
fn drop(&mut self) {
unsafe {
ManuallyDrop::drop(&mut self.0);
}
match DB.save() {
Ok(_) => {}
Err(e) => {
error!("database failed to save with error {}", e);
panic!("database failed to save with error {}", e)
}
}
}
}
pub fn borrow_db_checked<'a>() -> DBRead<'a> {
match DB.borrow_data() {
Ok(data) => DBRead(data),
Err(e) => {
error!("database borrow failed with error {}", e);
panic!("database borrow failed with error {}", e);
}
}
}
pub fn borrow_db_mut_checked<'a>() -> DBWrite<'a> {
match DB.borrow_data_mut() {
Ok(data) => DBWrite(ManuallyDrop::new(data)),
Err(e) => {
error!("database borrow mut failed with error {}", e);
panic!("database borrow mut failed with error {}", e);
}
}
}

View File

@ -0,0 +1,21 @@
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemData {
client_id: String,
base_url: String,
data_dir: String,
log_level: String,
}
impl SystemData {
pub fn new(client_id: String, base_url: String, data_dir: String, log_level: String) -> Self {
Self {
client_id,
base_url,
data_dir,
log_level,
}
}
}

View File

@ -0,0 +1,4 @@
pub mod commands;
pub mod db;
pub mod debug;
pub mod models;

View File

@ -0,0 +1,347 @@
use crate::database::models::data::Database;
pub mod data {
use std::path::PathBuf;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
pub type GameVersion = v1::GameVersion;
pub type Database = v3::Database;
pub type Settings = v1::Settings;
pub type DatabaseAuth = v1::DatabaseAuth;
pub type GameDownloadStatus = v2::GameDownloadStatus;
pub type ApplicationTransientStatus = v1::ApplicationTransientStatus;
pub type DownloadableMetadata = v1::DownloadableMetadata;
pub type DownloadType = v1::DownloadType;
pub type DatabaseApplications = v2::DatabaseApplications;
pub type DatabaseCompatInfo = v2::DatabaseCompatInfo;
use std::{collections::HashMap, process::Command};
use crate::process::process_manager::UMU_LAUNCHER_EXECUTABLE;
pub mod v1 {
use crate::process::process_manager::Platform;
use serde_with::serde_as;
use std::{collections::HashMap, path::PathBuf};
use super::*;
fn default_template() -> String {
"{}".to_owned()
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
#[native_model(id = 2, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct GameVersion {
pub game_id: String,
pub version_name: String,
pub platform: Platform,
pub launch_command: String,
pub launch_args: Vec<String>,
#[serde(default = "default_template")]
pub launch_command_template: String,
pub setup_command: String,
pub setup_args: Vec<String>,
#[serde(default = "default_template")]
pub setup_command_template: String,
pub only_setup: bool,
pub version_index: usize,
pub delta: bool,
pub umu_id_override: Option<String>,
}
#[serde_as]
#[derive(Serialize, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[native_model(id = 3, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct DatabaseApplications {
pub install_dirs: Vec<PathBuf>,
// Guaranteed to exist if the game also exists in the app state map
pub game_statuses: HashMap<String, GameDownloadStatus>,
pub game_versions: HashMap<String, HashMap<String, GameVersion>>,
pub installed_game_version: HashMap<String, DownloadableMetadata>,
#[serde(skip)]
pub transient_statuses: HashMap<DownloadableMetadata, ApplicationTransientStatus>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
#[native_model(id = 4, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct Settings {
pub autostart: bool,
pub max_download_threads: usize,
pub force_offline: bool, // ... other settings ...
}
impl Default for Settings {
fn default() -> Self {
Self {
autostart: false,
max_download_threads: 4,
force_offline: false,
}
}
}
// Strings are version names for a particular game
#[derive(Serialize, Clone, Deserialize)]
#[serde(tag = "type")]
#[native_model(id = 5, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub enum GameDownloadStatus {
Remote {},
SetupRequired {
version_name: String,
install_dir: String,
},
Installed {
version_name: String,
install_dir: String,
},
}
// Stuff that shouldn't be synced to disk
#[derive(Clone, Serialize, Deserialize, Debug)]
pub enum ApplicationTransientStatus {
Downloading { version_name: String },
Uninstalling {},
Updating { version_name: String },
Running {},
}
#[derive(serde::Serialize, Clone, Deserialize)]
#[native_model(id = 6, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct DatabaseAuth {
pub private: String,
pub cert: String,
pub client_id: String,
pub web_token: Option<String>,
}
#[native_model(id = 8, version = 1)]
#[derive(
Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone, Copy,
)]
pub enum DownloadType {
Game,
Tool,
DLC,
Mod,
}
#[native_model(id = 7, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DownloadableMetadata {
pub id: String,
pub version: Option<String>,
pub download_type: DownloadType,
}
impl DownloadableMetadata {
pub fn new(id: String, version: Option<String>, download_type: DownloadType) -> Self {
Self {
id,
version,
download_type,
}
}
}
#[native_model(id = 1, version = 1)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database {
#[serde(default)]
pub settings: Settings,
pub auth: Option<DatabaseAuth>,
pub base_url: String,
pub applications: DatabaseApplications,
pub prev_database: Option<PathBuf>,
pub cache_dir: PathBuf,
}
}
pub mod v2 {
use std::{collections::HashMap, path::PathBuf};
use serde_with::serde_as;
use super::*;
#[native_model(id = 1, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database {
#[serde(default)]
pub settings: Settings,
pub auth: Option<DatabaseAuth>,
pub base_url: String,
pub applications: v1::DatabaseApplications,
#[serde(skip)]
pub prev_database: Option<PathBuf>,
pub cache_dir: PathBuf,
pub compat_info: Option<DatabaseCompatInfo>,
}
#[native_model(id = 8, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct DatabaseCompatInfo {
pub umu_installed: bool,
}
impl From<v1::Database> for Database {
fn from(value: v1::Database) -> Self {
Self {
settings: value.settings,
auth: value.auth,
base_url: value.base_url,
applications: value.applications,
prev_database: value.prev_database,
cache_dir: value.cache_dir,
compat_info: crate::database::models::Database::create_new_compat_info(),
}
}
}
// Strings are version names for a particular game
#[derive(Serialize, Clone, Deserialize, Debug)]
#[serde(tag = "type")]
#[native_model(id = 5, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
pub enum GameDownloadStatus {
Remote {},
SetupRequired {
version_name: String,
install_dir: String,
},
Installed {
version_name: String,
install_dir: String,
},
PartiallyInstalled {
version_name: String,
install_dir: String,
},
}
impl From<v1::GameDownloadStatus> for GameDownloadStatus {
fn from(value: v1::GameDownloadStatus) -> Self {
match value {
v1::GameDownloadStatus::Remote {} => Self::Remote {},
v1::GameDownloadStatus::SetupRequired {
version_name,
install_dir,
} => Self::SetupRequired {
version_name,
install_dir,
},
v1::GameDownloadStatus::Installed {
version_name,
install_dir,
} => Self::Installed {
version_name,
install_dir,
},
}
}
}
#[serde_as]
#[derive(Serialize, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[native_model(id = 3, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct DatabaseApplications {
pub install_dirs: Vec<PathBuf>,
// Guaranteed to exist if the game also exists in the app state map
pub game_statuses: HashMap<String, GameDownloadStatus>,
pub game_versions: HashMap<String, HashMap<String, GameVersion>>,
pub installed_game_version: HashMap<String, DownloadableMetadata>,
#[serde(skip)]
pub transient_statuses: HashMap<DownloadableMetadata, ApplicationTransientStatus>,
}
impl From<v1::DatabaseApplications> for DatabaseApplications {
fn from(value: v1::DatabaseApplications) -> Self {
Self {
game_statuses: value
.game_statuses
.into_iter()
.map(|x| (x.0, x.1.into()))
.collect::<HashMap<String, GameDownloadStatus>>(),
install_dirs: value.install_dirs,
game_versions: value.game_versions,
installed_game_version: value.installed_game_version,
transient_statuses: value.transient_statuses,
}
}
}
}
mod v3 {
use std::path::PathBuf;
use super::*;
#[native_model(id = 1, version = 3, with = native_model::rmp_serde_1_3::RmpSerde)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database {
#[serde(default)]
pub settings: Settings,
pub auth: Option<DatabaseAuth>,
pub base_url: String,
pub applications: DatabaseApplications,
#[serde(skip)]
pub prev_database: Option<PathBuf>,
pub cache_dir: PathBuf,
pub compat_info: Option<DatabaseCompatInfo>,
}
impl From<v2::Database> for Database {
fn from(value: v2::Database) -> Self {
Self {
settings: value.settings,
auth: value.auth,
base_url: value.base_url,
applications: value.applications.into(),
prev_database: value.prev_database,
cache_dir: value.cache_dir,
compat_info: Database::create_new_compat_info(),
}
}
}
}
impl Database {
fn create_new_compat_info() -> Option<DatabaseCompatInfo> {
#[cfg(target_os = "windows")]
return None;
let has_umu_installed = Command::new(UMU_LAUNCHER_EXECUTABLE).spawn().is_ok();
Some(DatabaseCompatInfo {
umu_installed: has_umu_installed,
})
}
pub fn new<T: Into<PathBuf>>(
games_base_dir: T,
prev_database: Option<PathBuf>,
cache_dir: PathBuf,
) -> Self {
Self {
applications: DatabaseApplications {
install_dirs: vec![games_base_dir.into()],
game_statuses: HashMap::new(),
game_versions: HashMap::new(),
installed_game_version: HashMap::new(),
transient_statuses: HashMap::new(),
},
prev_database,
base_url: "".to_owned(),
auth: None,
settings: Settings::default(),
cache_dir,
compat_info: Database::create_new_compat_info(),
}
}
}
}

View File

@ -1,204 +0,0 @@
use std::{
collections::HashMap,
fs::{self, create_dir_all},
path::{Path, PathBuf},
sync::{LazyLock, Mutex},
};
use directories::BaseDirs;
use log::debug;
use rustbreak::{DeSerError, DeSerializer, PathDatabase};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use url::Url;
use crate::{process::process_manager::Platform, DB};
#[derive(serde::Serialize, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseAuth {
pub private: String,
pub cert: String,
pub client_id: String,
}
// Strings are version names for a particular game
#[derive(Serialize, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum GameStatus {
Remote {},
SetupRequired {
version_name: String,
install_dir: String,
},
Installed {
version_name: String,
install_dir: String,
},
}
// Stuff that shouldn't be synced to disk
#[derive(Clone, Serialize)]
pub enum GameTransientStatus {
Downloading { version_name: String },
Uninstalling {},
Updating { version_name: String },
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GameVersion {
pub version_index: usize,
pub version_name: String,
pub launch_command: String,
pub setup_command: String,
pub platform: Platform,
}
#[derive(Serialize, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseGames {
pub install_dirs: Vec<String>,
// Guaranteed to exist if the game also exists in the app state map
pub statuses: HashMap<String, GameStatus>,
pub versions: HashMap<String, HashMap<String, GameVersion>>,
#[serde(skip)]
pub transient_statuses: HashMap<String, GameTransientStatus>,
}
#[derive(Serialize, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Database {
pub auth: Option<DatabaseAuth>,
pub base_url: String,
pub games: DatabaseGames,
}
pub static DATA_ROOT_DIR: LazyLock<Mutex<PathBuf>> =
LazyLock::new(|| Mutex::new(BaseDirs::new().unwrap().data_dir().join("drop")));
// Custom JSON serializer to support everything we need
#[derive(Debug, Default, Clone)]
pub struct DropDatabaseSerializer;
impl<T: Serialize + DeserializeOwned> DeSerializer<T> for DropDatabaseSerializer {
fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult<Vec<u8>> {
serde_json::to_vec(val).map_err(|e| DeSerError::Internal(e.to_string()))
}
fn deserialize<R: std::io::Read>(&self, s: R) -> rustbreak::error::DeSerResult<T> {
serde_json::from_reader(s).map_err(|e| DeSerError::Internal(e.to_string()))
}
}
pub type DatabaseInterface =
rustbreak::Database<Database, rustbreak::backend::PathBackend, DropDatabaseSerializer>;
pub trait DatabaseImpls {
fn set_up_database() -> DatabaseInterface;
fn database_is_set_up(&self) -> bool;
fn fetch_base_url(&self) -> Url;
}
impl DatabaseImpls for DatabaseInterface {
fn set_up_database() -> DatabaseInterface {
let data_root_dir = DATA_ROOT_DIR.lock().unwrap();
let db_path = data_root_dir.join("drop.db");
let games_base_dir = data_root_dir.join("games");
let logs_root_dir = data_root_dir.join("logs");
debug!("Creating data directory at {:?}", data_root_dir);
create_dir_all(data_root_dir.clone()).unwrap();
debug!("Creating games directory");
create_dir_all(games_base_dir.clone()).unwrap();
debug!("Creating logs directory");
create_dir_all(logs_root_dir.clone()).unwrap();
#[allow(clippy::let_and_return)]
let exists = fs::exists(db_path.clone()).unwrap();
match exists {
true => PathDatabase::load_from_path(db_path).expect("Database loading failed"),
false => {
let default = Database {
auth: None,
base_url: "".to_string(),
games: DatabaseGames {
install_dirs: vec![games_base_dir.to_str().unwrap().to_string()],
statuses: HashMap::new(),
transient_statuses: HashMap::new(),
versions: HashMap::new(),
},
};
debug!(
"Creating database at path {}",
db_path.as_os_str().to_str().unwrap()
);
PathDatabase::create_at_path(db_path, default)
.expect("Database could not be created")
}
}
}
fn database_is_set_up(&self) -> bool {
!self.borrow_data().unwrap().base_url.is_empty()
}
fn fetch_base_url(&self) -> Url {
let handle = self.borrow_data().unwrap();
Url::parse(&handle.base_url).unwrap()
}
}
#[tauri::command]
pub fn add_download_dir(new_dir: String) -> Result<(), String> {
// Check the new directory is all good
let new_dir_path = Path::new(&new_dir);
if new_dir_path.exists() {
let metadata = new_dir_path
.metadata()
.map_err(|e| format!("Unable to access file or directory: {}", e))?;
if !metadata.is_dir() {
return Err("Invalid path: not a directory".to_string());
}
let dir_contents = new_dir_path
.read_dir()
.map_err(|e| format!("Unable to check directory contents: {}", e))?;
if dir_contents.count() != 0 {
return Err("Directory is not empty".to_string());
}
} else {
create_dir_all(new_dir_path)
.map_err(|e| format!("Unable to create directories to path: {}", e))?;
}
// Add it to the dictionary
let mut lock = DB.borrow_data_mut().unwrap();
if lock.games.install_dirs.contains(&new_dir) {
return Err("Download directory already used".to_string());
}
lock.games.install_dirs.push(new_dir);
drop(lock);
DB.save().unwrap();
Ok(())
}
#[tauri::command]
pub fn delete_download_dir(index: usize) -> Result<(), String> {
let mut lock = DB.borrow_data_mut().unwrap();
lock.games.install_dirs.remove(index);
drop(lock);
DB.save().unwrap();
Ok(())
}
// Will, in future, return disk/remaining size
// Just returns the directories that have been set up
#[tauri::command]
pub fn fetch_download_dir_stats() -> Result<Vec<String>, String> {
let lock = DB.borrow_data().unwrap();
let directories = lock.games.install_dirs.clone();
drop(lock);
Ok(directories)
}

View File

@ -0,0 +1,31 @@
use std::sync::Mutex;
use crate::{database::models::data::DownloadableMetadata, AppState};
#[tauri::command]
pub fn pause_downloads(state: tauri::State<'_, Mutex<AppState>>) {
state.lock().unwrap().download_manager.pause_downloads()
}
#[tauri::command]
pub fn resume_downloads(state: tauri::State<'_, Mutex<AppState>>) {
state.lock().unwrap().download_manager.resume_downloads()
}
#[tauri::command]
pub fn move_download_in_queue(
state: tauri::State<'_, Mutex<AppState>>,
old_index: usize,
new_index: usize,
) {
state
.lock()
.unwrap()
.download_manager
.rearrange(old_index, new_index)
}
#[tauri::command]
pub fn cancel_game(state: tauri::State<'_, Mutex<AppState>>, meta: DownloadableMetadata) {
state.lock().unwrap().download_manager.cancel(meta)
}

View File

@ -4,19 +4,22 @@ use std::{
fmt::Debug,
sync::{
mpsc::{SendError, Sender},
Arc, Mutex, MutexGuard,
Mutex, MutexGuard,
},
thread::JoinHandle,
};
use log::info;
use log::{debug, info};
use serde::Serialize;
use crate::{
database::models::data::DownloadableMetadata,
error::application_download_error::ApplicationDownloadError,
};
use super::{
download_agent::{GameDownloadAgent, GameDownloadError},
download_manager_builder::CurrentProgressObject,
progress_object::ProgressObject,
queue::Queue,
download_manager_builder::{CurrentProgressObject, DownloadAgent},
util::queue::Queue,
};
pub enum DownloadManagerSignal {
@ -24,37 +27,52 @@ pub enum DownloadManagerSignal {
Go,
/// Pauses the DownloadManager
Stop,
/// Called when a GameDownloadAgent has fully completed a download.
Completed(String),
/// Generates and appends a GameDownloadAgent
/// Called when a DownloadAgent has fully completed a download.
Completed(DownloadableMetadata),
/// Generates and appends a DownloadAgent
/// to the registry and queue
Queue(String, String, usize),
Queue(DownloadAgent),
/// Tells the Manager to stop the current
/// download, sync everything to disk, and
/// then exit
Finish,
/// Stops (but doesn't remove) current download
Cancel,
/// Removes a given game
Remove(String),
/// Stops, removes, and tells a download to cleanup
Cancel(DownloadableMetadata),
/// Removes a given application
Remove(DownloadableMetadata),
/// Any error which occurs in the agent
Error(GameDownloadError),
Error(ApplicationDownloadError),
/// Pushes UI update
Update,
UpdateUIQueue,
UpdateUIStats(usize, usize), //kb/s and seconds
/// Uninstall download
/// Takes download ID
Uninstall(DownloadableMetadata),
}
#[derive(Debug)]
pub enum DownloadManagerStatus {
Downloading,
Paused,
Empty,
Error(GameDownloadError),
Error(ApplicationDownloadError),
Finished,
}
#[derive(Serialize, Clone)]
pub enum GameDownloadStatus {
impl Serialize for DownloadManagerStatus {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&format!["{:?}", self])
}
}
#[derive(Serialize, Clone, Debug)]
pub enum DownloadStatus {
Queued,
Downloading,
Validating,
Error,
}
@ -69,32 +87,11 @@ pub enum GameDownloadStatus {
/// which provides raw access to the underlying queue.
/// THIS EDITING IS BLOCKING!!!
pub struct DownloadManager {
terminator: JoinHandle<Result<(), ()>>,
terminator: Mutex<Option<JoinHandle<Result<(), ()>>>>,
download_queue: Queue,
progress: CurrentProgressObject,
command_sender: Sender<DownloadManagerSignal>,
}
pub struct GameDownloadAgentQueueStandin {
pub id: String,
pub status: Mutex<GameDownloadStatus>,
pub progress: Arc<ProgressObject>,
}
impl From<Arc<GameDownloadAgent>> for GameDownloadAgentQueueStandin {
fn from(value: Arc<GameDownloadAgent>) -> Self {
Self {
id: value.id.clone(),
status: Mutex::from(GameDownloadStatus::Queued),
progress: value.progress.clone(),
}
}
}
impl Debug for GameDownloadAgentQueueStandin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GameDownloadAgentQueueStandin")
.field("id", &self.id)
.finish()
}
}
#[allow(dead_code)]
impl DownloadManager {
@ -105,49 +102,44 @@ impl DownloadManager {
command_sender: Sender<DownloadManagerSignal>,
) -> Self {
Self {
terminator,
terminator: Mutex::new(Some(terminator)),
download_queue,
progress,
command_sender,
}
}
pub fn queue_game(
pub fn queue_download(
&self,
id: String,
version: String,
target_download_dir: usize,
download: DownloadAgent,
) -> Result<(), SendError<DownloadManagerSignal>> {
info!("Adding game id {}", id);
self.command_sender.send(DownloadManagerSignal::Queue(
id,
version,
target_download_dir,
))?;
info!("creating download with meta {:?}", download.metadata());
self.command_sender
.send(DownloadManagerSignal::Queue(download))?;
self.command_sender.send(DownloadManagerSignal::Go)
}
pub fn edit(&self) -> MutexGuard<'_, VecDeque<Arc<GameDownloadAgentQueueStandin>>> {
pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> {
self.download_queue.edit()
}
pub fn read_queue(&self) -> VecDeque<Arc<GameDownloadAgentQueueStandin>> {
pub fn read_queue(&self) -> VecDeque<DownloadableMetadata> {
self.download_queue.read()
}
pub fn get_current_game_download_progress(&self) -> Option<f64> {
pub fn get_current_download_progress(&self) -> Option<f64> {
let progress_object = (*self.progress.lock().unwrap()).clone()?;
Some(progress_object.get_progress())
}
pub fn rearrange_string(&self, id: String, new_index: usize) {
pub fn rearrange_string(&self, meta: &DownloadableMetadata, new_index: usize) {
let mut queue = self.edit();
let current_index = get_index_from_id(&mut queue, id).unwrap();
let current_index = get_index_from_id(&mut queue, meta).unwrap();
let to_move = queue.remove(current_index).unwrap();
queue.insert(new_index, to_move);
self.command_sender
.send(DownloadManagerSignal::Update)
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
}
pub fn cancel(&self, game_id: String) {
pub fn cancel(&self, meta: DownloadableMetadata) {
self.command_sender
.send(DownloadManagerSignal::Remove(game_id))
.send(DownloadManagerSignal::Cancel(meta))
.unwrap();
}
pub fn rearrange(&self, current_index: usize, new_index: usize) {
@ -158,24 +150,27 @@ impl DownloadManager {
let needs_pause = current_index == 0 || new_index == 0;
if needs_pause {
self.command_sender
.send(DownloadManagerSignal::Cancel)
.send(DownloadManagerSignal::Stop)
.unwrap();
}
info!("moving {} to {}", current_index, new_index);
debug!(
"moving download at index {} to index {}",
current_index, new_index
);
let mut queue = self.edit();
let to_move = queue.remove(current_index).unwrap();
queue.insert(new_index, to_move);
info!("new queue: {:?}", queue);
drop(queue);
if needs_pause {
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
}
self.command_sender
.send(DownloadManagerSignal::Update)
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
}
pub fn pause_downloads(&self) {
self.command_sender
@ -185,21 +180,30 @@ impl DownloadManager {
pub fn resume_downloads(&self) {
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
}
pub fn ensure_terminated(self) -> Result<Result<(), ()>, Box<dyn Any + Send>> {
pub fn ensure_terminated(&self) -> Result<Result<(), ()>, Box<dyn Any + Send>> {
self.command_sender
.send(DownloadManagerSignal::Finish)
.unwrap();
self.terminator.join()
let terminator = self.terminator.lock().unwrap().take();
terminator.unwrap().join()
}
pub fn uninstall_application(&self, meta: DownloadableMetadata) {
self.command_sender
.send(DownloadManagerSignal::Uninstall(meta))
.unwrap();
}
pub fn get_sender(&self) -> Sender<DownloadManagerSignal> {
self.command_sender.clone()
}
}
/// Takes in the locked value from .edit() and attempts to
/// get the index of whatever game_id is passed in
/// get the index of whatever id is passed in
fn get_index_from_id(
queue: &mut MutexGuard<'_, VecDeque<Arc<GameDownloadAgentQueueStandin>>>,
id: String,
queue: &mut MutexGuard<'_, VecDeque<DownloadableMetadata>>,
meta: &DownloadableMetadata,
) -> Option<usize> {
queue
.iter()
.position(|download_agent| download_agent.id == id)
.position(|download_agent| download_agent == meta)
}

View File

@ -0,0 +1,385 @@
use std::{
collections::HashMap,
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex,
},
thread::{spawn, JoinHandle},
};
use log::{debug, error, info, warn};
use tauri::{AppHandle, Emitter};
use crate::{
database::models::data::DownloadableMetadata,
error::application_download_error::ApplicationDownloadError,
games::library::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent},
};
use super::{
download_manager::{DownloadManager, DownloadManagerSignal, DownloadManagerStatus},
downloadable::Downloadable,
util::{
download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag},
progress_object::ProgressObject,
queue::Queue,
},
};
pub type DownloadAgent = Arc<Box<dyn Downloadable + Send + Sync>>;
pub type CurrentProgressObject = Arc<Mutex<Option<Arc<ProgressObject>>>>;
/*
Welcome to the download manager, the most overengineered, glorious piece of bullshit.
The download manager takes a queue of ids and their associated
DownloadAgents, and then, one-by-one, executes them. It provides an interface
to interact with the currently downloading agent, and manage the queue.
When the DownloadManager is initialised, it is designed to provide a reference
which can be used to provide some instructions (the DownloadManagerInterface),
but other than that, it runs without any sort of interruptions.
It does this by opening up two data structures. Primarily is the command_receiver,
and mpsc (multi-channel-single-producer) which allows commands to be sent from
the Interface, and queued up for the Manager to process.
These have been mapped in the DownloadManagerSignal docs.
The other way to interact with the DownloadManager is via the donwload_queue,
which is just a collection of ids which may be rearranged to suit
whichever download queue order is required.
+----------------------------------------------------------------------------+
| DO NOT ATTEMPT TO ADD OR REMOVE FROM THE QUEUE WITHOUT USING SIGNALS!! |
| THIS WILL CAUSE A DESYNC BETWEEN THE DOWNLOAD AGENT REGISTRY AND THE QUEUE |
| WHICH HAS NOT BEEN ACCOUNTED FOR |
+----------------------------------------------------------------------------+
This download queue does not actually own any of the DownloadAgents. It is
simply an id-based reference system. The actual Agents are stored in the
download_agent_registry HashMap, as ordering is no issue here. This is why
appending or removing from the download_queue must be done via signals.
Behold, my madness - quexeky
*/
pub struct DownloadManagerBuilder {
download_agent_registry: HashMap<DownloadableMetadata, DownloadAgent>,
download_queue: Queue,
command_receiver: Receiver<DownloadManagerSignal>,
sender: Sender<DownloadManagerSignal>,
progress: CurrentProgressObject,
status: Arc<Mutex<DownloadManagerStatus>>,
app_handle: AppHandle,
current_download_agent: Option<DownloadAgent>, // Should be the only download agent in the map with the "Go" flag
current_download_thread: Mutex<Option<JoinHandle<()>>>,
active_control_flag: Option<DownloadThreadControl>,
}
impl DownloadManagerBuilder {
pub fn build(app_handle: AppHandle) -> DownloadManager {
let queue = Queue::new();
let (command_sender, command_receiver) = channel();
let active_progress = Arc::new(Mutex::new(None));
let status = Arc::new(Mutex::new(DownloadManagerStatus::Empty));
let manager = Self {
download_agent_registry: HashMap::new(),
download_queue: queue.clone(),
command_receiver,
status: status.clone(),
sender: command_sender.clone(),
progress: active_progress.clone(),
app_handle,
current_download_agent: None,
current_download_thread: Mutex::new(None),
active_control_flag: None,
};
let terminator = spawn(|| manager.manage_queue());
DownloadManager::new(terminator, queue, active_progress, command_sender)
}
fn set_status(&self, status: DownloadManagerStatus) {
*self.status.lock().unwrap() = status;
}
fn remove_and_cleanup_front_download(&mut self, meta: &DownloadableMetadata) -> DownloadAgent {
self.download_queue.pop_front();
let download_agent = self.download_agent_registry.remove(meta).unwrap();
self.cleanup_current_download();
download_agent
}
// CAREFUL WITH THIS FUNCTION
// Make sure the download thread is terminated
fn cleanup_current_download(&mut self) {
self.active_control_flag = None;
*self.progress.lock().unwrap() = None;
self.current_download_agent = None;
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
*download_thread_lock = None;
drop(download_thread_lock);
}
fn stop_and_wait_current_download(&self) {
self.set_status(DownloadManagerStatus::Paused);
if let Some(current_flag) = &self.active_control_flag {
current_flag.set(DownloadThreadControlFlag::Stop);
}
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
if let Some(current_download_thread) = download_thread_lock.take() {
current_download_thread.join().unwrap();
}
}
fn manage_queue(mut self) -> Result<(), ()> {
loop {
let signal = match self.command_receiver.recv() {
Ok(signal) => signal,
Err(_) => return Err(()),
};
match signal {
DownloadManagerSignal::Go => {
self.manage_go_signal();
}
DownloadManagerSignal::Stop => {
self.manage_stop_signal();
}
DownloadManagerSignal::Completed(meta) => {
self.manage_completed_signal(meta);
}
DownloadManagerSignal::Queue(download_agent) => {
self.manage_queue_signal(download_agent);
}
DownloadManagerSignal::Error(e) => {
self.manage_error_signal(e);
}
DownloadManagerSignal::UpdateUIQueue => {
self.push_ui_queue_update();
}
DownloadManagerSignal::UpdateUIStats(kbs, time) => {
self.push_ui_stats_update(kbs, time);
}
DownloadManagerSignal::Finish => {
self.stop_and_wait_current_download();
return Ok(());
}
DownloadManagerSignal::Cancel(meta) => {
self.manage_cancel_signal(&meta);
}
_ => {}
};
}
}
fn manage_queue_signal(&mut self, download_agent: DownloadAgent) {
debug!("got signal Queue");
let meta = download_agent.metadata();
debug!("queue metadata: {:?}", meta);
if self.download_queue.exists(meta.clone()) {
warn!("download with same ID already exists");
return;
}
download_agent.on_initialised(&self.app_handle);
self.download_queue.append(meta.clone());
self.download_agent_registry.insert(meta, download_agent);
self.sender
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
}
fn manage_go_signal(&mut self) {
debug!("got signal Go");
if self.download_agent_registry.is_empty() {
debug!(
"Download agent registry: {:?}",
self.download_agent_registry.len()
);
return;
}
if self.current_download_agent.is_some() {
if self.download_queue.read().front().unwrap()
== &self.current_download_agent.as_ref().unwrap().metadata()
{
debug!(
"Current download agent: {:?}",
self.current_download_agent.as_ref().unwrap().metadata()
);
return;
}
}
debug!("current download queue: {:?}", self.download_queue.read());
// Should always be Some if the above two statements keep going
let agent_data = self.download_queue.read().front().unwrap().clone();
info!("starting download for {:?}", agent_data);
let download_agent = self
.download_agent_registry
.get(&agent_data)
.unwrap()
.clone();
self.active_control_flag = Some(download_agent.control_flag());
self.current_download_agent = Some(download_agent.clone());
let sender = self.sender.clone();
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
let app_handle = self.app_handle.clone();
*download_thread_lock = Some(spawn(move || {
match download_agent.download(&app_handle) {
// Ok(true) is for completed and exited properly
Ok(true) => {
debug!("download {:?} has completed", download_agent.metadata());
match download_agent.validate() {
Ok(true) => {
download_agent.on_complete(&app_handle);
sender
.send(DownloadManagerSignal::Completed(download_agent.metadata()))
.unwrap();
}
Ok(false) => {
download_agent.on_incomplete(&app_handle);
}
Err(e) => {
error!(
"download {:?} has validation error {}",
download_agent.metadata(),
&e
);
download_agent.on_error(&app_handle, &e);
sender.send(DownloadManagerSignal::Error(e)).unwrap();
}
}
}
// Ok(false) is for incomplete but exited properly
Ok(false) => {
debug!("Donwload agent finished incomplete");
download_agent.on_incomplete(&app_handle);
}
Err(e) => {
error!("download {:?} has error {}", download_agent.metadata(), &e);
download_agent.on_error(&app_handle, &e);
sender.send(DownloadManagerSignal::Error(e)).unwrap();
}
}
sender.send(DownloadManagerSignal::UpdateUIQueue).unwrap();
}));
self.set_status(DownloadManagerStatus::Downloading);
let active_control_flag = self.active_control_flag.clone().unwrap();
active_control_flag.set(DownloadThreadControlFlag::Go);
}
fn manage_stop_signal(&mut self) {
debug!("got signal Stop");
if let Some(active_control_flag) = self.active_control_flag.clone() {
self.set_status(DownloadManagerStatus::Paused);
active_control_flag.set(DownloadThreadControlFlag::Stop);
}
}
fn manage_completed_signal(&mut self, meta: DownloadableMetadata) {
debug!("got signal Completed");
if let Some(interface) = &self.current_download_agent {
if interface.metadata() == meta {
self.remove_and_cleanup_front_download(&meta);
}
}
self.push_ui_queue_update();
self.sender.send(DownloadManagerSignal::Go).unwrap();
}
fn manage_error_signal(&mut self, error: ApplicationDownloadError) {
debug!("got signal Error");
if let Some(current_agent) = self.current_download_agent.clone() {
current_agent.on_error(&self.app_handle, &error);
self.stop_and_wait_current_download();
self.remove_and_cleanup_front_download(&current_agent.metadata());
}
self.set_status(DownloadManagerStatus::Error(error));
}
fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) {
debug!("got signal Cancel");
if let Some(current_download) = &self.current_download_agent {
if &current_download.metadata() == meta {
self.set_status(DownloadManagerStatus::Paused);
current_download.on_cancelled(&self.app_handle);
self.stop_and_wait_current_download();
self.download_queue.pop_front();
self.cleanup_current_download();
debug!("current download queue: {:?}", self.download_queue.read());
}
// TODO: Collapse these two into a single if statement somehow
else if let Some(download_agent) = self.download_agent_registry.get(meta) {
let index = self.download_queue.get_by_meta(meta);
if let Some(index) = index {
download_agent.on_cancelled(&self.app_handle);
let _ = self.download_queue.edit().remove(index).unwrap();
let removed = self.download_agent_registry.remove(meta);
debug!(
"removed {:?} from queue {:?}",
removed.map(|x| x.metadata()),
self.download_queue.read()
);
}
}
} else if let Some(download_agent) = self.download_agent_registry.get(meta) {
let index = self.download_queue.get_by_meta(meta);
if let Some(index) = index {
download_agent.on_cancelled(&self.app_handle);
let _ = self.download_queue.edit().remove(index).unwrap();
let removed = self.download_agent_registry.remove(meta);
debug!(
"removed {:?} from queue {:?}",
removed.map(|x| x.metadata()),
self.download_queue.read()
);
}
}
self.push_ui_queue_update();
}
fn push_ui_stats_update(&self, kbs: usize, time: usize) {
let event_data = StatsUpdateEvent { speed: kbs, time };
self.app_handle.emit("update_stats", event_data).unwrap();
}
fn push_ui_queue_update(&self) {
let queue = &self.download_queue.read();
let queue_objs = queue
.iter()
.map(|key| {
let val = self.download_agent_registry.get(key).unwrap();
QueueUpdateEventQueueData {
meta: DownloadableMetadata::clone(key),
status: val.status(),
progress: val.progress().get_progress(),
current: val.progress().sum(),
max: val.progress().get_max(),
}
})
.collect();
let event_data = QueueUpdateEvent { queue: queue_objs };
self.app_handle.emit("update_queue", event_data).unwrap();
}
}

View File

@ -0,0 +1,27 @@
use std::sync::Arc;
use tauri::AppHandle;
use crate::{
database::models::data::DownloadableMetadata,
error::application_download_error::ApplicationDownloadError,
};
use super::{
download_manager::DownloadStatus,
util::{download_thread_control_flag::DownloadThreadControl, progress_object::ProgressObject},
};
pub trait Downloadable: Send + Sync {
fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError>;
fn progress(&self) -> Arc<ProgressObject>;
fn control_flag(&self) -> DownloadThreadControl;
fn validate(&self) -> Result<bool, ApplicationDownloadError>;
fn status(&self) -> DownloadStatus;
fn metadata(&self) -> DownloadableMetadata;
fn on_initialised(&self, app_handle: &AppHandle);
fn on_error(&self, app_handle: &AppHandle, error: &ApplicationDownloadError);
fn on_complete(&self, app_handle: &AppHandle);
fn on_incomplete(&self, app_handle: &AppHandle);
fn on_cancelled(&self, app_handle: &AppHandle);
}

View File

@ -0,0 +1,5 @@
pub mod commands;
pub mod download_manager;
pub mod download_manager_builder;
pub mod downloadable;
pub mod util;

View File

@ -0,0 +1,4 @@
pub mod download_thread_control_flag;
pub mod progress_object;
pub mod queue;
pub mod rolling_progress_updates;

View File

@ -0,0 +1,170 @@
use std::{
sync::{
atomic::{AtomicUsize, Ordering},
mpsc::Sender,
Arc, Mutex,
},
time::{Duration, Instant},
};
use atomic_instant_full::AtomicInstant;
use throttle_my_fn::throttle;
use crate::download_manager::download_manager::DownloadManagerSignal;
use super::rolling_progress_updates::RollingProgressWindow;
#[derive(Clone)]
pub struct ProgressObject {
max: Arc<Mutex<usize>>,
progress_instances: Arc<Mutex<Vec<Arc<AtomicUsize>>>>,
start: Arc<Mutex<Instant>>,
sender: Sender<DownloadManagerSignal>,
//last_update: Arc<RwLock<Instant>>,
last_update_time: Arc<AtomicInstant>,
bytes_last_update: Arc<AtomicUsize>,
rolling: RollingProgressWindow<250>,
}
pub struct ProgressHandle {
progress: Arc<AtomicUsize>,
progress_object: Arc<ProgressObject>,
}
impl ProgressHandle {
pub fn new(progress: Arc<AtomicUsize>, progress_object: Arc<ProgressObject>) -> Self {
Self {
progress,
progress_object,
}
}
pub fn set(&self, amount: usize) {
self.progress.store(amount, Ordering::Relaxed);
}
pub fn add(&self, amount: usize) {
self.progress
.fetch_add(amount, std::sync::atomic::Ordering::Relaxed);
calculate_update(&self.progress_object);
}
pub fn skip(&self, amount: usize) {
self.progress
.fetch_add(amount, std::sync::atomic::Ordering::Relaxed);
// Offset the bytes at last offset by this amount
self.progress_object
.bytes_last_update
.fetch_add(amount, Ordering::Relaxed);
// Dont' fire update
}
}
impl ProgressObject {
pub fn new(max: usize, length: usize, sender: Sender<DownloadManagerSignal>) -> Self {
let arr = Mutex::new((0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect());
// TODO: consolidate this calculation with the set_max function below
Self {
max: Arc::new(Mutex::new(max)),
progress_instances: Arc::new(arr),
start: Arc::new(Mutex::new(Instant::now())),
sender,
last_update_time: Arc::new(AtomicInstant::now()),
bytes_last_update: Arc::new(AtomicUsize::new(0)),
rolling: RollingProgressWindow::new(),
}
}
pub fn set_time_now(&self) {
*self.start.lock().unwrap() = Instant::now();
}
pub fn sum(&self) -> usize {
self.progress_instances
.lock()
.unwrap()
.iter()
.map(|instance| instance.load(Ordering::Relaxed))
.sum()
}
pub fn reset(&self, size: usize) {
self.set_time_now();
self.set_size(size);
self.bytes_last_update.store(0, Ordering::Release);
self.rolling.reset();
self.progress_instances
.lock()
.unwrap()
.iter()
.for_each(|x| x.store(0, Ordering::Release));
}
pub fn get_max(&self) -> usize {
*self.max.lock().unwrap()
}
pub fn set_max(&self, new_max: usize) {
*self.max.lock().unwrap() = new_max;
}
pub fn set_size(&self, length: usize) {
*self.progress_instances.lock().unwrap() =
(0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect();
}
pub fn get_progress(&self) -> f64 {
self.sum() as f64 / self.get_max() as f64
}
pub fn get(&self, index: usize) -> Arc<AtomicUsize> {
self.progress_instances.lock().unwrap()[index].clone()
}
fn update_window(&self, kilobytes_per_second: usize) {
self.rolling.update(kilobytes_per_second);
}
}
#[throttle(1, Duration::from_millis(20))]
pub fn calculate_update(progress: &ProgressObject) {
let last_update_time = progress
.last_update_time
.swap(Instant::now(), Ordering::SeqCst);
let time_since_last_update = Instant::now().duration_since(last_update_time).as_millis();
let current_bytes_downloaded = progress.sum();
let max = progress.get_max();
let bytes_at_last_update = progress
.bytes_last_update
.swap(current_bytes_downloaded, Ordering::Relaxed);
let bytes_since_last_update = current_bytes_downloaded - bytes_at_last_update;
let kilobytes_per_second = bytes_since_last_update / (time_since_last_update as usize).max(1);
let bytes_remaining = if (max < current_bytes_downloaded) {
0
} else {
max - current_bytes_downloaded
}; // bytes
progress.update_window(kilobytes_per_second);
push_update(progress, bytes_remaining);
}
#[throttle(1, Duration::from_millis(500))]
pub fn push_update(progress: &ProgressObject, bytes_remaining: usize) {
let average_speed = progress.rolling.get_average();
let time_remaining = (bytes_remaining / 1000) / average_speed.max(1);
update_ui(progress, average_speed, time_remaining);
update_queue(progress);
}
fn update_ui(progress_object: &ProgressObject, kilobytes_per_second: usize, time_remaining: usize) {
progress_object
.sender
.send(DownloadManagerSignal::UpdateUIStats(
kilobytes_per_second,
time_remaining,
))
.unwrap();
}
fn update_queue(progress: &ProgressObject) {
progress
.sender
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
}

View File

@ -0,0 +1,80 @@
use std::{
collections::VecDeque,
sync::{Arc, Mutex, MutexGuard},
};
use crate::database::models::data::DownloadableMetadata;
#[derive(Clone)]
pub struct Queue {
inner: Arc<Mutex<VecDeque<DownloadableMetadata>>>,
}
#[allow(dead_code)]
impl Default for Queue {
fn default() -> Self {
Self::new()
}
}
impl Queue {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(VecDeque::new())),
}
}
pub fn read(&self) -> VecDeque<DownloadableMetadata> {
self.inner.lock().unwrap().clone()
}
pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> {
self.inner.lock().unwrap()
}
pub fn pop_front(&self) -> Option<DownloadableMetadata> {
self.edit().pop_front()
}
pub fn is_empty(&self) -> bool {
self.inner.lock().unwrap().len() == 0
}
pub fn exists(&self, meta: DownloadableMetadata) -> bool {
self.read().contains(&meta)
}
/// Either inserts `interface` at the specified index, or appends to
/// the back of the deque if index is greater than the length of the deque
pub fn insert(&self, interface: DownloadableMetadata, index: usize) {
if self.read().len() > index {
self.append(interface);
} else {
self.edit().insert(index, interface);
}
}
pub fn append(&self, interface: DownloadableMetadata) {
self.edit().push_back(interface);
}
pub fn pop_front_if_equal(&self, meta: &DownloadableMetadata) -> Option<DownloadableMetadata> {
let mut queue = self.edit();
let front = queue.front()?;
if front == meta {
return queue.pop_front();
}
None
}
pub fn get_by_meta(&self, meta: &DownloadableMetadata) -> Option<usize> {
self.read().iter().position(|data| data == meta)
}
pub fn move_to_index_by_meta(
&self,
meta: &DownloadableMetadata,
new_index: usize,
) -> Result<(), ()> {
let index = match self.get_by_meta(meta) {
Some(index) => index,
None => return Err(()),
};
let existing = match self.edit().remove(index) {
Some(existing) => existing,
None => return Err(()),
};
self.edit().insert(new_index, existing);
Ok(())
}
}

View File

@ -0,0 +1,38 @@
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
#[derive(Clone)]
pub struct RollingProgressWindow<const S: usize> {
window: Arc<[AtomicUsize; S]>,
current: Arc<AtomicUsize>,
}
impl<const S: usize> RollingProgressWindow<S> {
pub fn new() -> Self {
Self {
window: Arc::new([(); S].map(|_| AtomicUsize::new(0))),
current: Arc::new(AtomicUsize::new(0)),
}
}
pub fn update(&self, kilobytes_per_second: usize) {
let index = self.current.fetch_add(1, Ordering::SeqCst);
let current = &self.window[index % S];
current.store(kilobytes_per_second, Ordering::SeqCst);
}
pub fn get_average(&self) -> usize {
let current = self.current.load(Ordering::SeqCst);
self.window
.iter()
.enumerate()
.filter(|(i, _)| i < &current)
.map(|(_, x)| x.load(Ordering::Relaxed))
.sum::<usize>()
/ S
}
pub fn reset(&self) {
self.window
.iter()
.for_each(|x| x.store(0, Ordering::Release));
}
}

View File

@ -1,335 +0,0 @@
use crate::auth::generate_authorization_header;
use crate::db::DatabaseImpls;
use crate::downloads::manifest::{DropDownloadContext, DropManifest};
use crate::downloads::progress_object::ProgressHandle;
use crate::remote::RemoteAccessError;
use crate::DB;
use core::time;
use log::{debug, error, info};
use rayon::ThreadPoolBuilder;
use serde::ser::{Error, SerializeMap};
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use std::fs::{create_dir_all, File};
use std::io;
use std::path::Path;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use urlencoding::encode;
#[cfg(target_os = "linux")]
use rustix::fs::{fallocate, FallocateFlags};
use super::download_logic::download_game_chunk;
use super::download_manager::DownloadManagerSignal;
use super::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
use super::progress_object::ProgressObject;
use super::stored_manifest::StoredManifest;
pub struct GameDownloadAgent {
pub id: String,
pub version: String,
pub control_flag: DownloadThreadControl,
contexts: Vec<DropDownloadContext>,
completed_contexts: Mutex<Vec<usize>>,
pub manifest: Mutex<Option<DropManifest>>,
pub progress: Arc<ProgressObject>,
sender: Sender<DownloadManagerSignal>,
pub stored_manifest: StoredManifest,
}
#[derive(Debug)]
pub enum GameDownloadError {
Communication(RemoteAccessError),
Checksum,
Setup(SetupError),
Lock,
IoError(io::Error),
DownloadError,
}
#[derive(Debug)]
pub enum SetupError {
Context,
}
impl Display for GameDownloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
GameDownloadError::Communication(error) => write!(f, "{}", error),
GameDownloadError::Setup(error) => write!(f, "An error occurred while setting up the download: {}", error),
GameDownloadError::Lock => write!(f, "Failed to acquire lock. Something has gone very wrong internally. Please restart the application"),
GameDownloadError::Checksum => write!(f, "Checksum failed to validate for download"),
GameDownloadError::IoError(error) => write!(f, "{}", error),
GameDownloadError::DownloadError => write!(f, "Download failed. See Download Manager status for specific error"),
}
}
}
impl Display for SetupError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SetupError::Context => write!(f, "Failed to generate contexts for download"),
}
}
}
impl GameDownloadAgent {
pub fn new(
id: String,
version: String,
target_download_dir: usize,
sender: Sender<DownloadManagerSignal>,
) -> Self {
// Don't run by default
let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop);
let db_lock = DB.borrow_data().unwrap();
let base_dir = db_lock.games.install_dirs[target_download_dir].clone();
drop(db_lock);
let base_dir_path = Path::new(&base_dir);
let data_base_dir_path = base_dir_path.join(id.clone());
let stored_manifest =
StoredManifest::generate(id.clone(), version.clone(), data_base_dir_path.clone());
Self {
id,
version,
control_flag,
manifest: Mutex::new(None),
contexts: Vec::new(),
completed_contexts: Mutex::new(Vec::new()),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
stored_manifest,
}
}
// Blocking
pub fn setup_download(&mut self) -> Result<(), GameDownloadError> {
self.ensure_manifest_exists()?;
info!("Ensured manifest exists");
self.ensure_contexts()?;
info!("Ensured contexts exists");
self.control_flag.set(DownloadThreadControlFlag::Go);
Ok(())
}
// Blocking
pub fn download(&mut self) -> Result<(), GameDownloadError> {
self.setup_download()?;
self.set_progress_object_params();
let timer = Instant::now();
self.run().map_err(|_| GameDownloadError::DownloadError)?;
info!(
"{} took {}ms to download",
self.id,
timer.elapsed().as_millis()
);
Ok(())
}
pub fn ensure_manifest_exists(&self) -> Result<(), GameDownloadError> {
if self.manifest.lock().unwrap().is_some() {
return Ok(());
}
self.download_manifest()
}
fn download_manifest(&self) -> Result<(), GameDownloadError> {
let base_url = DB.fetch_base_url();
let manifest_url = base_url
.join(
format!(
"/api/v1/client/metadata/manifest?id={}&version={}",
self.id,
encode(&self.version)
)
.as_str(),
)
.unwrap();
let header = generate_authorization_header();
let client = reqwest::blocking::Client::new();
let response = client
.get(manifest_url.to_string())
.header("Authorization", header)
.send()
.unwrap();
if response.status() != 200 {
return Err(GameDownloadError::Communication(
RemoteAccessError::ManifestDownloadFailed(
response.status(),
response.text().unwrap(),
),
));
}
let manifest_download = response.json::<DropManifest>().unwrap();
if let Ok(mut manifest) = self.manifest.lock() {
*manifest = Some(manifest_download);
return Ok(());
}
Err(GameDownloadError::Lock)
}
fn set_progress_object_params(&self) {
// Avoid re-setting it
if self.progress.get_max() != 0 {
return;
}
let length = self.contexts.len();
let chunk_count = self.contexts.iter().map(|chunk| chunk.length).sum();
debug!("Setting ProgressObject max to {}", chunk_count);
self.progress.set_max(chunk_count);
debug!("Setting ProgressObject size to {}", length);
self.progress.set_size(length);
debug!("Setting ProgressObject time to now");
self.progress.set_time_now();
}
pub fn ensure_contexts(&mut self) -> Result<(), GameDownloadError> {
if !self.contexts.is_empty() {
return Ok(());
}
self.generate_contexts()?;
Ok(())
}
pub fn generate_contexts(&mut self) -> Result<(), GameDownloadError> {
let manifest = self.manifest.lock().unwrap().clone().unwrap();
let game_id = self.id.clone();
let mut contexts = Vec::new();
let base_path = Path::new(&self.stored_manifest.base_path);
create_dir_all(base_path).unwrap();
*self.completed_contexts.lock().unwrap() = self.stored_manifest.get_completed_contexts();
info!(
"Completed contexts: {:?}",
*self.completed_contexts.lock().unwrap()
);
for (raw_path, chunk) in manifest {
let path = base_path.join(Path::new(&raw_path));
let container = path.parent().unwrap();
create_dir_all(container).unwrap();
let file = File::create(path.clone()).unwrap();
let mut running_offset = 0;
for (index, length) in chunk.lengths.iter().enumerate() {
contexts.push(DropDownloadContext {
file_name: raw_path.to_string(),
version: chunk.version_name.to_string(),
offset: running_offset,
index,
game_id: game_id.to_string(),
path: path.clone(),
checksum: chunk.checksums[index].clone(),
length: *length,
permissions: chunk.permissions,
});
running_offset += *length as u64;
}
#[cfg(target_os = "linux")]
if running_offset > 0 {
let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset);
}
}
self.contexts = contexts;
Ok(())
}
pub fn run(&self) -> Result<(), ()> {
info!("downloading game: {}", self.id);
const DOWNLOAD_MAX_THREADS: usize = 1;
let pool = ThreadPoolBuilder::new()
.num_threads(DOWNLOAD_MAX_THREADS)
.build()
.unwrap();
let completed_indexes = Arc::new(Mutex::new(Vec::new()));
let completed_indexes_loop_arc = completed_indexes.clone();
pool.scope(move |scope| {
let completed_lock = self.completed_contexts.lock().unwrap();
for (index, context) in self.contexts.iter().enumerate() {
let progress = self.progress.get(index); // Clone arcs
let progress_handle = ProgressHandle::new(progress, self.progress.clone());
// If we've done this one already, skip it
if completed_lock.contains(&index) {
progress_handle.add(context.length);
continue;
}
let context = context.clone();
let control_flag = self.control_flag.clone(); // Clone arcs
let completed_indexes_ref = completed_indexes_loop_arc.clone();
scope.spawn(move |_| {
match download_game_chunk(context.clone(), control_flag, progress_handle) {
Ok(res) => {
if res {
let mut lock = completed_indexes_ref.lock().unwrap();
lock.push(index);
}
}
Err(e) => {
error!("GameDownloadError: {}", e);
self.sender.send(DownloadManagerSignal::Error(e)).unwrap();
}
}
});
}
});
let completed_lock_len = {
let mut completed_lock = self.completed_contexts.lock().unwrap();
let newly_completed_lock = completed_indexes.lock().unwrap();
completed_lock.extend(newly_completed_lock.iter());
completed_lock.len()
};
// If we're not out of contexts, we're not done, so we don't fire completed
if completed_lock_len != self.contexts.len() {
info!("da for {} exited without completing", self.id.clone());
self.stored_manifest
.set_completed_contexts(&self.completed_contexts);
info!("Setting completed contexts");
self.stored_manifest.write();
info!("Wrote completed contexts");
return Ok(());
}
// We've completed
self.sender
.send(DownloadManagerSignal::Completed(self.id.clone()))
.unwrap();
Ok(())
}
}

View File

@ -1,62 +0,0 @@
use std::sync::Mutex;
use crate::AppState;
#[tauri::command]
pub fn download_game(
game_id: String,
game_version: String,
install_dir: usize,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), String> {
state
.lock()
.unwrap()
.download_manager
.queue_game(game_id, game_version, install_dir)
.map_err(|_| "An error occurred while communicating with the download manager.".to_string())
}
#[tauri::command]
pub fn pause_game_downloads(state: tauri::State<'_, Mutex<AppState>>) {
state.lock().unwrap().download_manager.pause_downloads()
}
#[tauri::command]
pub fn resume_game_downloads(state: tauri::State<'_, Mutex<AppState>>) {
state.lock().unwrap().download_manager.resume_downloads()
}
#[tauri::command]
pub fn move_game_in_queue(
state: tauri::State<'_, Mutex<AppState>>,
old_index: usize,
new_index: usize,
) {
state
.lock()
.unwrap()
.download_manager
.rearrange(old_index, new_index)
}
#[tauri::command]
pub fn cancel_game(state: tauri::State<'_, Mutex<AppState>>, game_id: String) {
state.lock().unwrap().download_manager.cancel(game_id)
}
/*
#[tauri::command]
pub fn get_current_write_speed(state: tauri::State<'_, Mutex<AppState>>) {}
*/
/*
fn use_download_agent(
state: tauri::State<'_, Mutex<AppState>>,
game_id: String,
) -> Result<Arc<GameDownloadAgent>, String> {
let lock = state.lock().unwrap();
let download_agent = lock.download_manager.get(&game_id).ok_or("Invalid game ID")?;
Ok(download_agent.clone()) // Clones the Arc, not the underlying data structure
}
*/

View File

@ -1,417 +0,0 @@
use std::{
collections::HashMap,
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex, RwLockWriteGuard,
},
thread::{spawn, JoinHandle},
};
use log::{error, info};
use tauri::{AppHandle, Emitter};
use crate::{
db::{Database, GameStatus, GameTransientStatus},
library::{on_game_complete, GameUpdateEvent, QueueUpdateEvent, QueueUpdateEventQueueData},
state::GameStatusManager,
DB,
};
use super::{
download_agent::{GameDownloadAgent, GameDownloadError},
download_manager::{
DownloadManager, DownloadManagerSignal, DownloadManagerStatus,
GameDownloadAgentQueueStandin, GameDownloadStatus,
},
download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag},
progress_object::ProgressObject,
queue::Queue,
};
/*
Welcome to the download manager, the most overengineered, glorious piece of bullshit.
The download manager takes a queue of game_ids and their associated
GameDownloadAgents, and then, one-by-one, executes them. It provides an interface
to interact with the currently downloading agent, and manage the queue.
When the DownloadManager is initialised, it is designed to provide a reference
which can be used to provide some instructions (the DownloadManagerInterface),
but other than that, it runs without any sort of interruptions.
It does this by opening up two data structures. Primarily is the command_receiver,
and mpsc (multi-channel-single-producer) which allows commands to be sent from
the Interface, and queued up for the Manager to process.
These have been mapped in the DownloadManagerSignal docs.
The other way to interact with the DownloadManager is via the donwload_queue,
which is just a collection of ids which may be rearranged to suit
whichever download queue order is required.
+----------------------------------------------------------------------------+
| DO NOT ATTEMPT TO ADD OR REMOVE FROM THE QUEUE WITHOUT USING SIGNALS!! |
| THIS WILL CAUSE A DESYNC BETWEEN THE DOWNLOAD AGENT REGISTRY AND THE QUEUE |
| WHICH HAS NOT BEEN ACCOUNTED FOR |
+----------------------------------------------------------------------------+
This download queue does not actually own any of the GameDownloadAgents. It is
simply a id-based reference system. The actual Agents are stored in the
download_agent_registry HashMap, as ordering is no issue here. This is why
appending or removing from the download_queue must be done via signals.
Behold, my madness - quexeky
*/
// Refactored to consolidate this type. It's a monster.
pub type CurrentProgressObject = Arc<Mutex<Option<Arc<ProgressObject>>>>;
pub struct DownloadManagerBuilder {
download_agent_registry: HashMap<String, Arc<Mutex<GameDownloadAgent>>>,
download_queue: Queue,
command_receiver: Receiver<DownloadManagerSignal>,
sender: Sender<DownloadManagerSignal>,
progress: CurrentProgressObject,
status: Arc<Mutex<DownloadManagerStatus>>,
app_handle: AppHandle,
current_download_agent: Option<Arc<GameDownloadAgentQueueStandin>>, // Should be the only game download agent in the map with the "Go" flag
current_download_thread: Mutex<Option<JoinHandle<()>>>,
active_control_flag: Option<DownloadThreadControl>,
}
impl DownloadManagerBuilder {
pub fn build(app_handle: AppHandle) -> DownloadManager {
let queue = Queue::new();
let (command_sender, command_receiver) = channel();
let active_progress = Arc::new(Mutex::new(None));
let status = Arc::new(Mutex::new(DownloadManagerStatus::Empty));
let manager = Self {
download_agent_registry: HashMap::new(),
download_queue: queue.clone(),
command_receiver,
status: status.clone(),
sender: command_sender.clone(),
progress: active_progress.clone(),
app_handle,
current_download_agent: None,
current_download_thread: Mutex::new(None),
active_control_flag: None,
};
let terminator = spawn(|| manager.manage_queue());
DownloadManager::new(terminator, queue, active_progress, command_sender)
}
fn set_game_status<F: FnOnce(&mut RwLockWriteGuard<'_, Database>, &String) -> ()>(
&self,
id: String,
setter: F,
) {
let mut db_handle = DB.borrow_data_mut().unwrap();
setter(&mut db_handle, &id);
drop(db_handle);
DB.save().unwrap();
let status = GameStatusManager::fetch_state(&id);
self.app_handle
.emit(
&format!("update_game/{}", id),
GameUpdateEvent {
game_id: id,
status,
},
)
.unwrap();
}
fn push_manager_update(&self) {
let queue = self.download_queue.read();
let queue_objs: Vec<QueueUpdateEventQueueData> = queue
.iter()
.map(|interface| QueueUpdateEventQueueData {
id: interface.id.clone(),
status: interface.status.lock().unwrap().clone(),
progress: interface.progress.get_progress(),
})
.collect();
let event_data = QueueUpdateEvent { queue: queue_objs };
self.app_handle.emit("update_queue", event_data).unwrap();
}
fn stop_and_wait_current_download(&self) {
self.set_status(DownloadManagerStatus::Paused);
if let Some(current_flag) = &self.active_control_flag {
current_flag.set(DownloadThreadControlFlag::Stop);
}
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
if let Some(current_download_thread) = download_thread_lock.take() {
current_download_thread.join().unwrap();
}
drop(download_thread_lock);
}
fn sync_download_agent(&self) {}
fn remove_and_cleanup_game(&mut self, game_id: &String) -> Arc<Mutex<GameDownloadAgent>> {
self.download_queue.pop_front();
let download_agent = self.download_agent_registry.remove(game_id).unwrap();
self.cleanup_current_download();
download_agent
}
// CAREFUL WITH THIS FUNCTION
// Make sure the download thread is terminated
fn cleanup_current_download(&mut self) {
self.active_control_flag = None;
*self.progress.lock().unwrap() = None;
self.current_download_agent = None;
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
*download_thread_lock = None;
drop(download_thread_lock);
}
fn manage_queue(mut self) -> Result<(), ()> {
loop {
let signal = match self.command_receiver.recv() {
Ok(signal) => signal,
Err(_) => return Err(()),
};
match signal {
DownloadManagerSignal::Go => {
self.manage_go_signal();
}
DownloadManagerSignal::Stop => {
self.manage_stop_signal();
}
DownloadManagerSignal::Completed(game_id) => {
self.manage_completed_signal(game_id);
}
DownloadManagerSignal::Queue(game_id, version, target_download_dir) => {
self.manage_queue_signal(game_id, version, target_download_dir);
}
DownloadManagerSignal::Error(e) => {
self.manage_error_signal(e);
}
DownloadManagerSignal::Cancel => {
self.manage_cancel_signal();
}
DownloadManagerSignal::Update => {
self.push_manager_update();
}
DownloadManagerSignal::Finish => {
self.stop_and_wait_current_download();
return Ok(());
}
DownloadManagerSignal::Remove(game_id) => {
self.manage_remove_game(game_id);
}
};
}
}
fn manage_remove_game(&mut self, game_id: String) {
if let Some(current_download) = &self.current_download_agent {
if current_download.id == game_id {
self.manage_cancel_signal();
}
}
let index = self.download_queue.get_by_id(game_id.clone()).unwrap();
let mut queue_handle = self.download_queue.edit();
queue_handle.remove(index);
self.set_game_status(game_id, |db_handle, id| {
db_handle.games.transient_statuses.remove(id);
});
drop(queue_handle);
if self.current_download_agent.is_none() {
self.manage_go_signal();
}
self.push_manager_update();
}
fn manage_stop_signal(&mut self) {
info!("Got signal 'Stop'");
self.set_status(DownloadManagerStatus::Paused);
if let Some(active_control_flag) = self.active_control_flag.clone() {
active_control_flag.set(DownloadThreadControlFlag::Stop);
}
}
fn manage_completed_signal(&mut self, game_id: String) {
info!("Got signal 'Completed'");
if let Some(interface) = &self.current_download_agent {
// When if let chains are stabilised, combine these two statements
if interface.id == game_id {
info!("Popping consumed data");
let download_agent = self.remove_and_cleanup_game(&game_id);
let download_agent_lock = download_agent.lock().unwrap();
let version = download_agent_lock.version.clone();
let install_dir = download_agent_lock.stored_manifest.base_path.clone().to_string_lossy().to_string();
drop(download_agent_lock);
if let Err(error) =
on_game_complete(game_id, version, install_dir, &self.app_handle)
{
self.sender
.send(DownloadManagerSignal::Error(
GameDownloadError::Communication(error),
))
.unwrap();
}
}
}
self.sender.send(DownloadManagerSignal::Update).unwrap();
self.sender.send(DownloadManagerSignal::Go).unwrap();
}
fn manage_queue_signal(&mut self, id: String, version: String, target_download_dir: usize) {
info!("Got signal Queue");
let download_agent = Arc::new(Mutex::new(GameDownloadAgent::new(
id.clone(),
version,
target_download_dir,
self.sender.clone(),
)));
let download_agent_lock = download_agent.lock().unwrap();
let agent_status = GameDownloadStatus::Queued;
let interface_data = GameDownloadAgentQueueStandin {
id: id.clone(),
status: Mutex::new(agent_status),
progress: download_agent_lock.progress.clone(),
};
let version_name = download_agent_lock.version.clone();
drop(download_agent_lock);
self.download_agent_registry
.insert(interface_data.id.clone(), download_agent);
self.download_queue.append(interface_data);
self.set_game_status(id, |db, id| {
db.games.transient_statuses.insert(
id.to_string(),
GameTransientStatus::Downloading { version_name },
);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
fn manage_go_signal(&mut self) {
if !(!self.download_agent_registry.is_empty() && !self.download_queue.empty()) {
return;
}
if self.current_download_agent.is_some() {
info!("skipping go signal due to existing download job");
return;
}
info!("current download queue: {:?}", self.download_queue.read());
let agent_data = self.download_queue.read().front().unwrap().clone();
info!("starting download for {}", agent_data.id.clone());
let download_agent = self
.download_agent_registry
.get(&agent_data.id)
.unwrap()
.clone();
let download_agent_lock = download_agent.lock().unwrap();
self.current_download_agent = Some(agent_data);
// Cloning option should be okay because it only clones the Arc inside, not the AgentInterfaceData
let agent_data = self.current_download_agent.clone().unwrap();
let version_name = download_agent_lock.version.clone();
let progress_object = download_agent_lock.progress.clone();
*self.progress.lock().unwrap() = Some(progress_object);
let active_control_flag = download_agent_lock.control_flag.clone();
self.active_control_flag = Some(active_control_flag.clone());
let sender = self.sender.clone();
drop(download_agent_lock);
info!("Spawning download");
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
*download_thread_lock = Some(spawn(move || {
let mut download_agent_lock = download_agent.lock().unwrap();
match download_agent_lock.download() {
// Returns once we've exited the download
// (not necessarily completed)
// The download agent will fire the completed event for us
Ok(_) => {}
// If an error occurred while *starting* the download
Err(err) => {
error!("error while managing download: {}", err);
sender.send(DownloadManagerSignal::Error(err)).unwrap();
}
};
drop(download_agent_lock);
}));
// Set status for games
for queue_game in self.download_queue.read() {
let mut status_handle = queue_game.status.lock().unwrap();
if queue_game.id == agent_data.id {
*status_handle = GameDownloadStatus::Downloading;
} else {
*status_handle = GameDownloadStatus::Queued;
}
drop(status_handle);
}
// Set flags for download manager
active_control_flag.set(DownloadThreadControlFlag::Go);
self.set_status(DownloadManagerStatus::Downloading);
self.set_game_status(agent_data.id.clone(), |db, id| {
db.games.transient_statuses.insert(
id.to_string(),
GameTransientStatus::Downloading { version_name },
);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
fn manage_error_signal(&mut self, error: GameDownloadError) {
let current_status = self.current_download_agent.clone().unwrap();
self.remove_and_cleanup_game(&current_status.id); // Remove all the locks and shit
let mut lock = current_status.status.lock().unwrap();
*lock = GameDownloadStatus::Error;
self.set_status(DownloadManagerStatus::Error(error));
let game_id = current_status.id.clone();
self.set_game_status(game_id, |db_handle, id| {
db_handle.games.transient_statuses.remove(id);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
fn manage_cancel_signal(&mut self) {
self.stop_and_wait_current_download();
info!("cancel waited for download to finish");
self.cleanup_current_download();
}
fn set_status(&self, status: DownloadManagerStatus) {
*self.status.lock().unwrap() = status;
}
}

View File

@ -1,10 +0,0 @@
pub mod download_agent;
pub mod download_commands;
mod download_logic;
pub mod download_manager;
pub mod download_manager_builder;
mod download_thread_control_flag;
mod manifest;
mod progress_object;
pub mod queue;
mod stored_manifest;

View File

@ -1,111 +0,0 @@
use std::{
sync::{
atomic::{AtomicUsize, Ordering},
mpsc::Sender,
Arc, Mutex,
},
time::Instant,
};
use log::info;
use super::download_manager::DownloadManagerSignal;
#[derive(Clone)]
pub struct ProgressObject {
max: Arc<Mutex<usize>>,
progress_instances: Arc<Mutex<Vec<Arc<AtomicUsize>>>>,
start: Arc<Mutex<Instant>>,
sender: Sender<DownloadManagerSignal>,
points_towards_update: Arc<AtomicUsize>,
points_to_push_update: Arc<Mutex<usize>>,
}
pub struct ProgressHandle {
progress: Arc<AtomicUsize>,
progress_object: Arc<ProgressObject>,
}
impl ProgressHandle {
pub fn new(progress: Arc<AtomicUsize>, progress_object: Arc<ProgressObject>) -> Self {
Self {
progress,
progress_object,
}
}
pub fn set(&self, amount: usize) {
self.progress.store(amount, Ordering::Relaxed);
}
pub fn add(&self, amount: usize) {
self.progress
.fetch_add(amount, std::sync::atomic::Ordering::Relaxed);
self.progress_object.check_push_update(amount);
}
}
pub const PROGRESS_UPDATES: usize = 100;
impl ProgressObject {
pub fn new(max: usize, length: usize, sender: Sender<DownloadManagerSignal>) -> Self {
let arr = Mutex::new((0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect());
// TODO: consolidate this calculation with the set_max function below
let points_to_push_update = max / PROGRESS_UPDATES;
Self {
max: Arc::new(Mutex::new(max)),
progress_instances: Arc::new(arr),
start: Arc::new(Mutex::new(Instant::now())),
sender,
points_towards_update: Arc::new(AtomicUsize::new(0)),
points_to_push_update: Arc::new(Mutex::new(points_to_push_update)),
}
}
pub fn check_push_update(&self, amount_added: usize) {
let current_amount = self
.points_towards_update
.fetch_add(amount_added, Ordering::Relaxed);
let to_update_handle = self.points_to_push_update.lock().unwrap();
let to_update = *to_update_handle;
drop(to_update_handle);
if current_amount < to_update {
return;
}
self.points_towards_update
.fetch_sub(to_update, Ordering::Relaxed);
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
pub fn set_time_now(&self) {
*self.start.lock().unwrap() = Instant::now();
}
pub fn sum(&self) -> usize {
self.progress_instances
.lock()
.unwrap()
.iter()
.map(|instance| instance.load(Ordering::Relaxed))
.sum()
}
pub fn get_max(&self) -> usize {
*self.max.lock().unwrap()
}
pub fn set_max(&self, new_max: usize) {
*self.max.lock().unwrap() = new_max;
*self.points_to_push_update.lock().unwrap() = new_max / PROGRESS_UPDATES;
info!("points to push update: {}", new_max / PROGRESS_UPDATES);
}
pub fn set_size(&self, length: usize) {
*self.progress_instances.lock().unwrap() =
(0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect();
}
pub fn get_progress(&self) -> f64 {
self.sum() as f64 / self.get_max() as f64
}
pub fn get(&self, index: usize) -> Arc<AtomicUsize> {
self.progress_instances.lock().unwrap()[index].clone()
}
}

View File

@ -1,73 +0,0 @@
use std::{
collections::VecDeque,
sync::{Arc, Mutex, MutexGuard},
};
use super::download_manager::GameDownloadAgentQueueStandin;
#[derive(Clone)]
pub struct Queue {
inner: Arc<Mutex<VecDeque<Arc<GameDownloadAgentQueueStandin>>>>,
}
#[allow(dead_code)]
impl Queue {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(VecDeque::new())),
}
}
pub fn read(&self) -> VecDeque<Arc<GameDownloadAgentQueueStandin>> {
self.inner.lock().unwrap().clone()
}
pub fn edit(&self) -> MutexGuard<'_, VecDeque<Arc<GameDownloadAgentQueueStandin>>> {
self.inner.lock().unwrap()
}
pub fn pop_front(&self) -> Option<Arc<GameDownloadAgentQueueStandin>> {
self.edit().pop_front()
}
pub fn empty(&self) -> bool {
self.inner.lock().unwrap().len() == 0
}
/// Either inserts `interface` at the specified index, or appends to
/// the back of the deque if index is greater than the length of the deque
pub fn insert(&self, interface: GameDownloadAgentQueueStandin, index: usize) {
if self.read().len() > index {
self.append(interface);
} else {
self.edit().insert(index, Arc::new(interface));
}
}
pub fn append(&self, interface: GameDownloadAgentQueueStandin) {
self.edit().push_back(Arc::new(interface));
}
pub fn pop_front_if_equal(
&self,
game_id: String,
) -> Option<Arc<GameDownloadAgentQueueStandin>> {
let mut queue = self.edit();
let front = match queue.front() {
Some(front) => front,
None => return None,
};
if front.id == game_id {
return queue.pop_front();
}
None
}
pub fn get_by_id(&self, game_id: String) -> Option<usize> {
self.read().iter().position(|data| data.id == game_id)
}
pub fn move_to_index_by_id(&self, game_id: String, new_index: usize) -> Result<(), ()> {
let index = match self.get_by_id(game_id) {
Some(index) => index,
None => return Err(()),
};
let existing = match self.edit().remove(index) {
Some(existing) => existing,
None => return Err(()),
};
self.edit().insert(new_index, existing);
Ok(())
}
}

View File

@ -1,82 +0,0 @@
use std::{
default,
fs::File,
io::{Read, Write},
path::{Path, PathBuf},
sync::Mutex,
};
use log::{error, info};
use serde::{Deserialize, Serialize};
use serde_binary::binary_stream::Endian;
#[derive(Serialize, Deserialize, Debug)]
pub struct StoredManifest {
game_id: String,
game_version: String,
pub completed_contexts: Mutex<Vec<usize>>,
pub base_path: PathBuf,
}
static DROP_DATA_PATH: &str = ".dropdata";
impl StoredManifest {
pub fn new(game_id: String, game_version: String, base_path: PathBuf) -> Self {
Self {
base_path,
game_id,
game_version,
completed_contexts: Mutex::new(Vec::new()),
}
}
pub fn generate(game_id: String, game_version: String, base_path: PathBuf) -> Self {
let mut file = match File::open(base_path.join(DROP_DATA_PATH)) {
Ok(file) => file,
Err(_) => return StoredManifest::new(game_id, game_version, base_path),
};
let mut s = Vec::new();
match file.read_to_end(&mut s) {
Ok(_) => {}
Err(e) => {
error!("{}", e);
return StoredManifest::new(game_id, game_version, base_path);
}
};
let manifest = match serde_binary::from_vec::<StoredManifest>(s, Endian::Little) {
Ok(manifest) => manifest,
Err(e) => {
error!("{}", e);
StoredManifest::new(game_id, game_version, base_path)
}
};
return manifest;
}
pub fn write(&self) {
let manifest_raw = match serde_binary::to_vec(&self, Endian::Little) {
Ok(json) => json,
Err(_) => return,
};
let mut file = match File::create(self.base_path.join(DROP_DATA_PATH)) {
Ok(file) => file,
Err(e) => {
error!("{}", e);
return;
}
};
match file.write_all(&manifest_raw) {
Ok(_) => {}
Err(e) => error!("{}", e),
};
}
pub fn set_completed_contexts(&self, completed_contexts: &Mutex<Vec<usize>>) {
*self.completed_contexts.lock().unwrap() = completed_contexts.lock().unwrap().clone();
}
pub fn get_completed_contexts(&self) -> Vec<usize> {
self.completed_contexts.lock().unwrap().clone()
}
}

View File

@ -0,0 +1,32 @@
use std::{
fmt::{Display, Formatter},
io,
};
use serde_with::SerializeDisplay;
use super::{remote_access_error::RemoteAccessError, setup_error::SetupError};
// TODO: Rename / separate from downloads
#[derive(Debug, SerializeDisplay)]
pub enum ApplicationDownloadError {
Communication(RemoteAccessError),
Checksum,
Setup(SetupError),
Lock,
IoError(io::ErrorKind),
DownloadError,
}
impl Display for ApplicationDownloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ApplicationDownloadError::Communication(error) => write!(f, "{}", error),
ApplicationDownloadError::Setup(error) => write!(f, "an error occurred while setting up the download: {}", error),
ApplicationDownloadError::Lock => write!(f, "failed to acquire lock. Something has gone very wrong internally. Please restart the application"),
ApplicationDownloadError::Checksum => write!(f, "checksum failed to validate for download"),
ApplicationDownloadError::IoError(error) => write!(f, "io error: {}", error),
ApplicationDownloadError::DownloadError => write!(f, "download failed. See Download Manager status for specific error"),
}
}
}

View File

@ -0,0 +1,21 @@
use std::fmt::Display;
use serde_with::SerializeDisplay;
#[derive(Debug, SerializeDisplay, Clone, Copy)]
pub enum BackupError {
InvalidSystem,
NotFound,
ParseError,
}
impl Display for BackupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
BackupError::InvalidSystem => "Attempted to generate path for invalid system",
BackupError::NotFound => "Could not generate or find path",
BackupError::ParseError => "Failed to parse path",
};
write!(f, "{}", s)
}
}

View File

@ -0,0 +1,27 @@
use std::{fmt::Display, io, sync::mpsc::SendError};
use serde_with::SerializeDisplay;
#[derive(SerializeDisplay)]
pub enum DownloadManagerError<T> {
IOError(io::Error),
SignalError(SendError<T>),
}
impl<T> Display for DownloadManagerError<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DownloadManagerError::IOError(error) => write!(f, "{}", error),
DownloadManagerError::SignalError(send_error) => write!(f, "{}", send_error),
}
}
}
impl<T> From<SendError<T>> for DownloadManagerError<T> {
fn from(value: SendError<T>) -> Self {
DownloadManagerError::SignalError(value)
}
}
impl<T> From<io::Error> for DownloadManagerError<T> {
fn from(value: io::Error) -> Self {
DownloadManagerError::IOError(value)
}
}

View File

@ -0,0 +1,10 @@
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DropServerError {
pub status_code: usize,
pub status_message: String,
pub message: String,
pub url: String,
}

View File

@ -0,0 +1,19 @@
use std::fmt::Display;
use serde_with::SerializeDisplay;
#[derive(SerializeDisplay)]
pub enum LibraryError {
MetaNotFound(String),
}
impl Display for LibraryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LibraryError::MetaNotFound(id) => write!(
f,
"Could not locate any installed version of game ID {} in the database",
id
),
}
}
}

View File

@ -0,0 +1,8 @@
pub mod application_download_error;
pub mod backup_error;
pub mod download_manager_error;
pub mod drop_server_error;
pub mod library_error;
pub mod process_error;
pub mod remote_access_error;
pub mod setup_error;

View File

@ -0,0 +1,33 @@
use std::{fmt::Display, io::Error};
use serde_with::SerializeDisplay;
#[derive(SerializeDisplay)]
pub enum ProcessError {
SetupRequired,
NotInstalled,
AlreadyRunning,
NotDownloaded,
InvalidID,
InvalidVersion,
IOError(Error),
FormatError(String), // String errors supremacy
InvalidPlatform,
}
impl Display for ProcessError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
ProcessError::SetupRequired => "Game not set up",
ProcessError::NotInstalled => "Game not installed",
ProcessError::AlreadyRunning => "Game already running",
ProcessError::NotDownloaded => "Game not downloaded",
ProcessError::InvalidID => "Invalid Game ID",
ProcessError::InvalidVersion => "Invalid Game version",
ProcessError::IOError(error) => &error.to_string(),
ProcessError::InvalidPlatform => "This Game cannot be played on the current platform",
ProcessError::FormatError(e) => &format!("Failed to format template: {}", e),
};
write!(f, "{}", s)
}
}

View File

@ -0,0 +1,75 @@
use std::{
error::Error,
fmt::{Display, Formatter},
sync::Arc,
};
use http::StatusCode;
use serde_with::SerializeDisplay;
use url::ParseError;
use super::drop_server_error::DropServerError;
#[derive(Debug, SerializeDisplay)]
pub enum RemoteAccessError {
FetchError(Arc<reqwest::Error>),
ParsingError(ParseError),
InvalidEndpoint,
HandshakeFailed(String),
GameNotFound(String),
InvalidResponse(DropServerError),
InvalidRedirect,
ManifestDownloadFailed(StatusCode, String),
OutOfSync,
Cache(cacache::Error),
}
impl Display for RemoteAccessError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
RemoteAccessError::FetchError(error) => {
if error.is_connect() {
return write!(f, "Failed to connect to Drop server. Check if you access Drop through a browser, and then try again.");
}
write!(
f,
"{}: {}",
error,
error
.source()
.map(|e| e.to_string())
.or_else(|| Some("Unknown error".to_string()))
.unwrap()
)
},
RemoteAccessError::ParsingError(parse_error) => {
write!(f, "{}", parse_error)
}
RemoteAccessError::InvalidEndpoint => write!(f, "invalid drop endpoint"),
RemoteAccessError::HandshakeFailed(message) => write!(f, "failed to complete handshake: {}", message),
RemoteAccessError::GameNotFound(id) => write!(f, "could not find game on server: {}", id),
RemoteAccessError::InvalidResponse(error) => write!(f, "server returned an invalid response: {} {}", error.status_code, error.status_message),
RemoteAccessError::InvalidRedirect => write!(f, "server redirect was invalid"),
RemoteAccessError::ManifestDownloadFailed(status, response) => write!(
f,
"failed to download game manifest: {} {}",
status, response
),
RemoteAccessError::OutOfSync => write!(f, "server's and client's time are out of sync. Please ensure they are within at least 30 seconds of each other"),
RemoteAccessError::Cache(error) => write!(f, "Cache Error: {}", error),
}
}
}
impl From<reqwest::Error> for RemoteAccessError {
fn from(err: reqwest::Error) -> Self {
RemoteAccessError::FetchError(Arc::new(err))
}
}
impl From<ParseError> for RemoteAccessError {
fn from(err: ParseError) -> Self {
RemoteAccessError::ParsingError(err)
}
}
impl std::error::Error for RemoteAccessError {}

View File

@ -0,0 +1,14 @@
use std::fmt::{Display, Formatter};
#[derive(Debug, Clone)]
pub enum SetupError {
Context,
}
impl Display for SetupError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SetupError::Context => write!(f, "failed to generate contexts for download"),
}
}
}

View File

@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
use crate::games::library::Game;
pub type Collections = Vec<Collection>;
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct Collection {
id: String,
name: String,
is_default: bool,
user_id: String,
entries: Vec<CollectionObject>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct CollectionObject {
collection_id: String,
game_id: String,
game: Game,
}

View File

@ -0,0 +1,110 @@
use reqwest::blocking::Client;
use serde_json::json;
use url::Url;
use crate::{
database::db::DatabaseImpls,
error::remote_access_error::RemoteAccessError,
remote::{auth::generate_authorization_header, requests::make_request},
DB,
};
use super::collection::{Collection, Collections};
#[tauri::command]
pub fn fetch_collections() -> Result<Collections, RemoteAccessError> {
let client = Client::new();
let response = make_request(&client, &["/api/v1/client/collection"], &[], |r| {
r.header("Authorization", generate_authorization_header())
})?
.send()?;
Ok(response.json()?)
}
#[tauri::command]
pub fn fetch_collection(collection_id: String) -> Result<Collection, RemoteAccessError> {
let client = Client::new();
let response = make_request(
&client,
&["/api/v1/client/collection/", &collection_id],
&[],
|r| r.header("Authorization", generate_authorization_header()),
)?
.send()?;
Ok(response.json()?)
}
#[tauri::command]
pub fn create_collection(name: String) -> Result<Collection, RemoteAccessError> {
let client = Client::new();
let base_url = DB.fetch_base_url();
let base_url = Url::parse(&format!("{}api/v1/client/collection/", base_url))?;
let response = client
.post(base_url)
.header("Authorization", generate_authorization_header())
.json(&json!({"name": name}))
.send()?;
Ok(response.json()?)
}
#[tauri::command]
pub fn add_game_to_collection(
collection_id: String,
game_id: String,
) -> Result<(), RemoteAccessError> {
let client = Client::new();
let url = Url::parse(&format!(
"{}api/v1/client/collection/{}/entry/",
DB.fetch_base_url(),
collection_id
))?;
client
.post(url)
.header("Authorization", generate_authorization_header())
.json(&json!({"id": game_id}))
.send()?;
Ok(())
}
#[tauri::command]
pub fn delete_collection(collection_id: String) -> Result<bool, RemoteAccessError> {
let client = Client::new();
let base_url = Url::parse(&format!(
"{}api/v1/client/collection/{}",
DB.fetch_base_url(),
collection_id
))?;
let response = client
.delete(base_url)
.header("Authorization", generate_authorization_header())
.send()?;
Ok(response.json()?)
}
#[tauri::command]
pub fn delete_game_in_collection(
collection_id: String,
game_id: String,
) -> Result<(), RemoteAccessError> {
let client = Client::new();
let base_url = Url::parse(&format!(
"{}api/v1/client/collection/{}/entry",
DB.fetch_base_url(),
collection_id
))?;
client
.delete(base_url)
.header("Authorization", generate_authorization_header())
.json(&json!({"id": game_id}))
.send()?;
Ok(())
}

View File

@ -0,0 +1,2 @@
pub mod collection;
pub mod commands;

Some files were not shown because too many files have changed in this diff Show More