Compare commits

..

154 Commits

Author SHA1 Message Date
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
58 changed files with 3647 additions and 836 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
DATABASE_URL=./drop.db

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "drop-base"]
path = drop-base
url = https://github.com/drop-oss/drop-base

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,34 +1,78 @@
<template> <template>
<!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 -->
<div class="inline-flex divide-x divide-zinc-900"> <div class="inline-flex divide-x divide-zinc-900">
<button type="button" @click="() => buttonActions[props.status.type]()" :class="[ <button
styles[props.status.type], type="button"
showDropdown ? 'rounded-l-md' : 'rounded-md', @click="() => buttonActions[props.status.type]()"
'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', :class="[
]"> styles[props.status.type],
<component :is="buttonIcons[props.status.type]" class="-mr-0.5 size-5" aria-hidden="true" /> 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] }} {{ buttonNames[props.status.type] }}
</button> </button>
<Menu v-if="showDropdown" as="div" class="relative inline-block text-left grow"> <Menu
v-if="showDropdown"
as="div"
class="relative inline-block text-left grow"
>
<div class="h-full"> <div class="h-full">
<MenuButton :class="[ <MenuButton
styles[props.status.type], :class="[
'inline-flex w-full h-full justify-center items-center rounded-r-md px-1 py-2 text-sm font-semibold shadow-sm' 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" /> <ChevronDownIcon class="size-5" aria-hidden="true" />
</MenuButton> </MenuButton>
</div> </div>
<transition enter-active-class="transition ease-out duration-100" enter-from-class="transform opacity-0 scale-95" <transition
enter-to-class="transform opacity-100 scale-100" leave-active-class="transition ease-in duration-75" enter-active-class="transition ease-out duration-100"
leave-from-class="transform opacity-100 scale-100" leave-to-class="transform opacity-0 scale-95"> 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 <MenuItems
class="absolute right-0 z-50 mt-2 w-32 origin-top-right rounded-md bg-zinc-900 shadow-lg ring-1 ring-zinc-100/5 focus:outline-none"> 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"> <div class="py-1">
<MenuItem v-slot="{ active }"> <MenuItem v-slot="{ active }">
<button @click="() => emit('uninstall')" <button
: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 @click="() => emit('options')"
<TrashIcon class="size-5" /> :class="[
</button> 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> </MenuItem>
</div> </div>
</MenuItems> </MenuItems>
@ -43,13 +87,13 @@ import {
ChevronDownIcon, ChevronDownIcon,
PlayIcon, PlayIcon,
QueueListIcon, QueueListIcon,
TrashIcon,
WrenchIcon, WrenchIcon,
} from "@heroicons/vue/20/solid"; } from "@heroicons/vue/20/solid";
import type { Component } from "vue"; import type { Component } from "vue";
import { GameStatusEnum, type GameStatus } from "~/types.js"; import { GameStatusEnum, type GameStatus } from "~/types.js";
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue' import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { Cog6ToothIcon, TrashIcon } from "@heroicons/vue/24/outline";
const props = defineProps<{ status: GameStatus }>(); const props = defineProps<{ status: GameStatus }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -58,19 +102,32 @@ const emit = defineEmits<{
(e: "queue"): void; (e: "queue"): void;
(e: "uninstall"): void; (e: "uninstall"): void;
(e: "kill"): void; (e: "kill"): void;
(e: "options"): void;
}>(); }>();
const showDropdown = computed(() => props.status.type === GameStatusEnum.Installed || props.status.type === GameStatusEnum.SetupRequired); const showDropdown = computed(
() =>
props.status.type === GameStatusEnum.Installed ||
props.status.type === GameStatusEnum.SetupRequired
);
const styles: { [key in GameStatusEnum]: string } = { const styles: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]: "bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600", [GameStatusEnum.Remote]:
[GameStatusEnum.Queued]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700", "bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500",
[GameStatusEnum.Downloading]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700", [GameStatusEnum.Queued]:
[GameStatusEnum.SetupRequired]: "bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600", "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Installed]: "bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600", [GameStatusEnum.Downloading]:
[GameStatusEnum.Updating]: "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.Uninstalling]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700", [GameStatusEnum.SetupRequired]:
[GameStatusEnum.Running]: "bg-zinc-800 text-white focus-visible:outline-zinc-700" "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 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",
}; };
const buttonNames: { [key in GameStatusEnum]: string } = { const buttonNames: { [key in GameStatusEnum]: string } = {
@ -81,7 +138,7 @@ const buttonNames: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "Play", [GameStatusEnum.Installed]: "Play",
[GameStatusEnum.Updating]: "Updating", [GameStatusEnum.Updating]: "Updating",
[GameStatusEnum.Uninstalling]: "Uninstalling", [GameStatusEnum.Uninstalling]: "Uninstalling",
[GameStatusEnum.Running]: "Stop" [GameStatusEnum.Running]: "Stop",
}; };
const buttonIcons: { [key in GameStatusEnum]: Component } = { const buttonIcons: { [key in GameStatusEnum]: Component } = {
@ -92,7 +149,7 @@ const buttonIcons: { [key in GameStatusEnum]: Component } = {
[GameStatusEnum.Installed]: PlayIcon, [GameStatusEnum.Installed]: PlayIcon,
[GameStatusEnum.Updating]: ArrowDownTrayIcon, [GameStatusEnum.Updating]: ArrowDownTrayIcon,
[GameStatusEnum.Uninstalling]: TrashIcon, [GameStatusEnum.Uninstalling]: TrashIcon,
[GameStatusEnum.Running]: PlayIcon [GameStatusEnum.Running]: PlayIcon,
}; };
const buttonActions: { [key in GameStatusEnum]: () => void } = { const buttonActions: { [key in GameStatusEnum]: () => void } = {
@ -102,7 +159,7 @@ const buttonActions: { [key in GameStatusEnum]: () => void } = {
[GameStatusEnum.SetupRequired]: () => emit("launch"), [GameStatusEnum.SetupRequired]: () => emit("launch"),
[GameStatusEnum.Installed]: () => emit("launch"), [GameStatusEnum.Installed]: () => emit("launch"),
[GameStatusEnum.Updating]: () => emit("queue"), [GameStatusEnum.Updating]: () => emit("queue"),
[GameStatusEnum.Uninstalling]: () => { }, [GameStatusEnum.Uninstalling]: () => {},
[GameStatusEnum.Running]: () => emit("kill") [GameStatusEnum.Running]: () => emit("kill"),
}; };
</script> </script>

View File

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

View File

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

View File

@ -126,6 +126,7 @@ import { invoke } from "@tauri-apps/api/core";
const loading = ref(false); const loading = ref(false);
const error = ref<string | undefined>(); const error = ref<string | undefined>();
let offerManualTimeout: NodeJS.Timeout | undefined;
const offerManual = ref(false); const offerManual = ref(false);
const manualToken = ref(""); const manualToken = ref("");
const manualLoading = ref(false); const manualLoading = ref(false);
@ -139,8 +140,9 @@ function authWrapper_wrapper() {
auth().catch((e) => { auth().catch((e) => {
loading.value = false; loading.value = false;
error.value = e; error.value = e;
if(offerManualTimeout) clearTimeout(offerManualTimeout);
}); });
setTimeout(() => { offerManualTimeout = setTimeout(() => {
offerManual.value = true; offerManual.value = true;
}, 10000); }, 10000);
} }

View File

@ -0,0 +1,177 @@
<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",
};
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",
};
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.mIconId);
}
}
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

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

View File

@ -1,8 +1,9 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; 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> } = {}; const gameStatusRegistry: { [key: string]: Ref<GameStatus> } = {};
@ -31,27 +32,44 @@ export const parseStatus = (status: SerializedGameStatus): GameStatus => {
export const useGame = async (gameId: string) => { export const useGame = async (gameId: string) => {
if (!gameRegistry[gameId]) { if (!gameRegistry[gameId]) {
const data: { game: Game; status: SerializedGameStatus } = await invoke( const data: {
"fetch_game", game: Game;
{ status: SerializedGameStatus;
gameId, version?: GameVersion;
} } = await invoke("fetch_game", {
); gameId,
gameRegistry[gameId] = data.game; });
gameRegistry[gameId] = { game: data.game, version: data.version };
if (!gameStatusRegistry[gameId]) { if (!gameStatusRegistry[gameId]) {
gameStatusRegistry[gameId] = ref(parseStatus(data.status)); gameStatusRegistry[gameId] = ref(parseStatus(data.status));
listen(`update_game/${gameId}`, (event) => { listen(`update_game/${gameId}`, (event) => {
const payload: { const payload: {
status: SerializedGameStatus; status: SerializedGameStatus;
version?: GameVersion;
} = event.payload as any; } = event.payload as any;
console.log(payload.status); console.log(payload.status);
gameStatusRegistry[gameId].value = parseStatus(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[gameId]; const game = gameRegistry[gameId];
const status = gameStatusRegistry[gameId]; const status = gameStatusRegistry[gameId];
return { game, status }; return { ...game, status };
}; };
export type FrontendGameConfiguration = {
launchString: string;
};

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> <template>
<div class="flex flex-col bg-zinc-900 overflow-hidden"> <div class="flex flex-col bg-zinc-900 overflow-hidden h-screen">
<Header class="select-none" /> <NuxtErrorBoundary>
<div class="relative grow overflow-y-auto"> <Header class="select-none" />
<slot /> <div class="relative grow overflow-y-auto">
</div> <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> </div>
</template> </template>

View File

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

View File

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

View File

@ -1,7 +1,7 @@
{ {
"name": "drop-app", "name": "drop-app",
"private": true, "private": true,
"version": "0.2.0-beta", "version": "0.3.0-rc-1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
@ -19,9 +19,11 @@
"@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-dialog": "^2.0.1", "@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/plugin-os": "~2", "@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-shell": ">=2.0.0", "@tauri-apps/plugin-shell": "^2.2.1",
"koa": "^2.16.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"nuxt": "^3.13.0", "micromark": "^4.0.1",
"nuxt": "^3.16.0",
"scss": "^0.2.4", "scss": "^0.2.4",
"vue": "latest", "vue": "latest",
"vue-router": "latest", "vue-router": "latest",

View File

@ -1,72 +0,0 @@
<template>
<div class="mx-auto max-w-7xl px-8">
<div class="border-b border-zinc-700 py-5">
<h3 class="text-base font-semibold font-display leading-6 text-zinc-100">
Account
</h3>
</div>
<div class="mt-5">
<div class="divide-y divide-zinc-700">
<div class="py-6">
<div class="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>
</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>

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,78 +1,52 @@
<template> <template>
<div class="flex flex-row h-full"> <div class="flex flex-row h-full">
<!-- Sidebar -->
<div <div
class="flex-none max-h-full overflow-y-auto w-64 bg-zinc-950 px-2 py-1" 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"
> >
<ul class="flex flex-col gap-y-1"> <LibrarySearch />
<NuxtLink
v-for="(nav, navIdx) in navigation"
:key="nav.route"
:class="[
'transition-all duration-200 rounded-lg flex items-center py-1.5 px-3',
navIdx === currentNavigationIndex
? 'bg-zinc-800 text-zinc-100'
: 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">
<img
class="size-6 flex-none object-cover bg-zinc-900 rounded"
:src="icons[navIdx]"
alt=""
/>
<p class="truncate text-sm font-display leading-6 flex-1">
{{ nav.label }}
</p>
</div>
</NuxtLink>
</ul>
</div> </div>
<div class="grow overflow-y-auto"> <div class="grow overflow-y-auto">
<NuxtPage :libraryDownloadError = "libraryDownloadError" /> <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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts"></script>
import { invoke } from "@tauri-apps/api/core";
import { GameStatusEnum, type Game, type NavigationItem } from "~/types";
let libraryDownloadError = false; <style scoped>
.list-move,
async function calculateGames(): Promise<Game[]> { .list-enter-active,
try { .list-leave-active {
return await invoke("fetch_library"); transition: all 0.3s ease;
}
catch(e) {
libraryDownloadError = true;
return new Array();
}
} }
const rawGames: Array<Game> = await calculateGames(); .list-enter-from,
const games = await Promise.all(rawGames.map((e) => useGame(e.id))); .list-leave-to {
const icons = await Promise.all( opacity: 0;
games.map(({ game, status }) => useObject(game.mIconId)) transform: translateX(-30px);
); }
const navigation = games.map(({ game, status }) => { .list-leave-active {
const isInstalled = computed( position: absolute;
() => }
status.value.type == GameStatusEnum.Installed || </style>
status.value.type == GameStatusEnum.SetupRequired
);
const item = {
label: game.mName,
route: `/library/${game.id}`,
prefix: `/library/${game.id}`,
isInstalled,
};
return item;
});
const currentNavigationIndex = useCurrentNavigationIndex(navigation);
</script>

View File

@ -1,41 +1,153 @@
<template> <template>
<div <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 inset-0 z-0">
<div class="absolute flex top-0 h-fit inset-x-0 z-[-20]"> <img
<img :src="bannerUrl" class="w-full h-auto object-cover" /> :src="bannerUrl"
<h1 class="w-full h-[24rem] object-cover blur-sm scale-105"
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 <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> </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()"
@launch="() => launch()"
@queue="() => queue()"
@uninstall="() => uninstall()"
@kill="() => kill()"
: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"
>
<BuildingStorefrontIcon class="mr-2 size-5" aria-hidden="true" />
Store <div class="relative z-10">
</a> <div class="px-8 pb-4">
<h1
class="text-5xl text-zinc-100 font-bold font-display drop-shadow-lg mb-8"
>
{{ game.mName }}
</h1>
<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)"
: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"
>
<BuildingStorefrontIcon class="mr-2 size-5" aria-hidden="true" />
Store
</a>
</div>
</div>
<!-- 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>
<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="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
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-else
class="aspect-video rounded-lg overflow-hidden bg-zinc-900/50 flex flex-col items-center justify-center text-center px-4"
>
<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>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -44,9 +156,9 @@
<template #default> <template #default>
<div class="sm:flex sm:items-start"> <div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left"> <div class="mt-3 text-center sm:mt-0 sm:text-left">
<DialogTitle as="h3" class="text-base font-semibold text-zinc-100" <h3 class="text-base font-semibold text-zinc-100"
>Install {{ game.mName }}? >Install {{ game.mName }}?
</DialogTitle> </h3>
<div class="mt-2"> <div class="mt-2">
<p class="text-sm text-zinc-400"> <p class="text-sm text-zinc-400">
Drop will add {{ game.mName }} to the queue to be downloaded. Drop will add {{ game.mName }} to the queue to be downloaded.
@ -256,6 +368,74 @@
</button> </button>
</template> </template>
</ModalTemplate> </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> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -274,10 +454,17 @@ import {
CheckIcon, CheckIcon,
ChevronUpDownIcon, ChevronUpDownIcon,
WrenchIcon, WrenchIcon,
ChevronLeftIcon,
ChevronRightIcon,
XMarkIcon,
ArrowsPointingOutIcon,
PhotoIcon,
} from "@heroicons/vue/20/solid"; } from "@heroicons/vue/20/solid";
import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline"; import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline";
import { XCircleIcon } from "@heroicons/vue/24/solid"; import { XCircleIcon } from "@heroicons/vue/24/solid";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { micromark } from "micromark";
import { GameStatusEnum } from "~/types";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -292,11 +479,22 @@ const remoteUrl: string = await invoke("gen_drop_url", {
const bannerUrl = await useObject(game.value.mBannerId); const bannerUrl = await useObject(game.value.mBannerId);
// Get all available images
const mediaUrls = await Promise.all(
game.value.mImageCarousel.map((id) => useObject(id))
);
const htmlDescription = micromark(game.value.mDescription);
const installFlowOpen = ref(false); const installFlowOpen = ref(false);
const versionOptions = ref< const versionOptions = ref<
undefined | Array<{ versionName: string; platform: string }> undefined | Array<{ versionName: string; platform: string }>
>(); >();
const installDirs = ref<undefined | Array<string>>(); const installDirs = ref<undefined | Array<string>>();
const currentImageIndex = ref(0);
const configureModalOpen = ref(false);
async function installFlow() { async function installFlow() {
installFlowOpen.value = true; installFlowOpen.value = true;
versionOptions.value = undefined; versionOptions.value = undefined;
@ -319,8 +517,7 @@ const installVersionIndex = ref(0);
const installDir = ref(0); const installDir = ref(0);
async function install() { async function install() {
try { try {
if (!versionOptions.value) if (!versionOptions.value) throw new Error("Versions have not been loaded");
throw new Error("Versions have not been loaded");
installLoading.value = true; installLoading.value = true;
await invoke("download_game", { await invoke("download_game", {
gameId: game.value.id, gameId: game.value.id,
@ -376,4 +573,61 @@ async function kill() {
console.error(e); 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> </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,9 +1,19 @@
<script setup lang="ts">
const props = defineProps<{ libraryDownloadError: boolean }>();
</script>
<template> <template>
<div v-if="libraryDownloadError" class="mx-auto pt-10 text-center text-gray-500">
Library Failed to update
</div>
</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

@ -10,13 +10,13 @@
<ul role="list" class="-mx-2 space-y-1"> <ul role="list" class="-mx-2 space-y-1">
<li v-for="(item, itemIdx) in navigation" :key="item.prefix"> <li v-for="(item, itemIdx) in navigation" :key="item.prefix">
<NuxtLink :href="item.route" :class="[ <NuxtLink :href="item.route" :class="[
itemIdx === currentPageIndex itemIdx === currentNavigation
? 'bg-zinc-800/50 text-zinc-100' ? 'bg-zinc-800/50 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800/30 hover:text-zinc-200', : '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', 'transition group flex gap-x-3 rounded-md p-2 pr-12 text-sm font-semibold leading-6',
]"> ]">
<component :is="item.icon" :class="[ <component :is="item.icon" :class="[
itemIdx === currentPageIndex itemIdx === currentNavigation
? 'text-zinc-100' ? 'text-zinc-100'
: 'text-zinc-400 group-hover:text-zinc-200', : 'text-zinc-400 group-hover:text-zinc-200',
'transition h-6 w-6 shrink-0', 'transition h-6 w-6 shrink-0',
@ -45,6 +45,7 @@ import type { Component } from "vue";
import type { NavigationItem } from "~/types"; import type { NavigationItem } from "~/types";
import { platform } from '@tauri-apps/plugin-os'; import { platform } from '@tauri-apps/plugin-os';
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { UserIcon } from "@heroicons/vue/20/solid";
const systemData = await invoke<{ const systemData = await invoke<{
clientId: string; clientId: string;
@ -101,6 +102,12 @@ const navigation = computed(() => [
prefix: "/settings/downloads", prefix: "/settings/downloads",
icon: ArrowDownTrayIcon, icon: ArrowDownTrayIcon,
}, },
{
label: "Account",
route: "/settings/account",
prefix: "/settings/account",
icon: UserIcon
},
...(isDebugMode.value ? [{ ...(isDebugMode.value ? [{
label: "Debug Info", label: "Debug Info",
route: "/settings/debug", route: "/settings/debug",
@ -112,10 +119,10 @@ const navigation = computed(() => [
const currentPlatform = platform(); const currentPlatform = platform();
// Use .value to unwrap the computed ref // Use .value to unwrap the computed ref
const currentPageIndex = useCurrentNavigationIndex(navigation.value); const {currentNavigation} = useCurrentNavigationIndex(navigation.value);
// Watch for navigation changes and update currentPageIndex // Watch for navigation changes and update currentPageIndex
watch(navigation, (newNav) => { watch(navigation, (newNav) => {
currentPageIndex.value = useCurrentNavigationIndex(newNav).value; currentNavigation.value = useCurrentNavigationIndex(newNav).currentNavigation.value;
}); });
</script> </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>

View File

@ -1,10 +1,15 @@
<template> <template>
<div> <div class="border-b border-zinc-700 py-5">
<div class="border-b border-zinc-600 py-2 px-1"> <h3 class="text-base font-semibold font-display leading-6 text-zinc-100">
<div Downloads
class="-ml-4 -mt-2 flex flex-wrap items-center justify-between sm:flex-nowrap" </h3>
> </div>
<div class="ml-4 mt-2">
<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"> <h3 class="text-base font-display font-semibold text-zinc-100">
Install directories Install directories
</h3> </h3>
@ -15,27 +20,17 @@
</p> </p>
</div> </div>
<div class="ml-4 mt-2 shrink-0"> <div class="ml-4 mt-2 shrink-0">
<button <button @click="() => (open = true)" type="button"
@click="() => (open = true)" 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">
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 Add new directory
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<ul role="list" class="divide-y divide-gray-800"> <ul role="list" class="divide-y divide-gray-800">
<li <li v-for="(dir, dirIdx) in dirs" :key="dir" class="flex justify-between gap-x-6 py-5">
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"> <div class="flex min-w-0 gap-x-4">
<FolderIcon <FolderIcon class="h-6 w-6 text-blue-600 flex-none rounded-full" alt="" />
class="h-6 w-6 text-blue-600 flex-none rounded-full"
alt=""
/>
<div class="min-w-0 flex-auto"> <div class="min-w-0 flex-auto">
<p class="text-sm/6 text-zinc-100"> <p class="text-sm/6 text-zinc-100">
{{ dir }} {{ dir }}
@ -43,16 +38,12 @@
</div> </div>
</div> </div>
<div class="flex shrink-0 items-center gap-x-6"> <div class="flex shrink-0 items-center gap-x-6">
<button <button @click="() => deleteDirectory(dirIdx)" :disabled="dirs.length <= 1" :class="[
@click="() => deleteDirectory(dirIdx)" dirs.length <= 1
:disabled="dirs.length <= 1" ? 'text-zinc-700'
:class="[ : 'text-zinc-400 hover:text-zinc-100',
dirs.length <= 1 '-m-2.5 block p-2.5',
? 'text-zinc-700' ]">
: 'text-zinc-400 hover:text-zinc-100',
'-m-2.5 block p-2.5',
]"
>
<span class="sr-only">Open options</span> <span class="sr-only">Open options</span>
<TrashIcon class="size-5" aria-hidden="true" /> <TrashIcon class="size-5" aria-hidden="true" />
</button> </button>
@ -72,37 +63,44 @@
Maximum Download Threads Maximum Download Threads
</label> </label>
<div class="mt-2"> <div class="mt-2">
<input <input type="number" name="threads" id="threads" min="1" max="32" v-model="downloadThreads"
type="number" @keypress="validateNumberInput" @paste="validatePaste"
name="threads" 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" />
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> </div>
<p class="mt-2 text-sm text-zinc-400"> <p class="mt-2 text-sm text-zinc-400">
The maximum number of concurrent download threads. Higher values may The maximum number of concurrent download threads. Higher values may
download faster but use more system resources. Default is 4. download faster but use more system resources. Default is 4.
</p> </p>
</div> </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"> <div class="mt-6">
<button <button type="button" @click="saveSettings" :disabled="saveState.loading" :class="[
type="button" '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',
@click="saveDownloadThreads" saveState.success
:disabled="saveState.loading" ? 'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600'
:class="[ : 'bg-blue-600 hover:bg-blue-500 focus-visible:outline-blue-600',
'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', 'disabled:bg-blue-600/50 disabled:cursor-not-allowed'
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' }} {{ saveState.success ? 'Saved' : 'Save Changes' }}
</button> </button>
</div> </div>
@ -110,49 +108,27 @@
</div> </div>
<TransitionRoot as="template" :show="open"> <TransitionRoot as="template" :show="open">
<Dialog class="relative z-50" @close="open = false"> <Dialog class="relative z-50" @close="open = false">
<TransitionChild <TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100"
as="template" leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
enter="ease-out duration-300" <div class="fixed inset-0 bg-zinc-950 bg-opacity-75 transition-opacity" />
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> </TransitionChild>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto"> <div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
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"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100" enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100" 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 <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="sm:flex sm:items-start">
<div class="mt-3 w-full sm:ml-4 sm:mt-0"> <div class="mt-3 w-full sm:ml-4 sm:mt-0">
<div> <div>
<label <label for="dir" class="block text-sm/6 font-medium text-zinc-100">Select game directory</label>
for="dir"
class="block text-sm/6 font-medium text-zinc-100"
>Select game directory</label
>
<div class="mt-2"> <div class="mt-2">
<button <button @click="() => selectDirectory()"
@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">
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..." currentDirectory ?? "Click to select a directory..."
}} }}
@ -165,36 +141,25 @@
</div> </div>
</div> </div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse"> <div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<LoadingButton <LoadingButton :disabled="currentDirectory == undefined" type="button" :loading="createDirectoryLoading"
:disabled="currentDirectory == undefined" @click="() => submitDirectory()" :class="[
type="button"
:loading="createDirectoryLoading"
@click="() => submitDirectory()"
:class="[
'inline-flex w-full shadow-sm sm:ml-3 sm:w-auto', 'inline-flex w-full shadow-sm sm:ml-3 sm:w-auto',
currentDirectory === undefined currentDirectory === undefined
? 'text-zinc-400 bg-blue-600/10 hover:bg-blue-600/10' ? 'text-zinc-400 bg-blue-600/10 hover:bg-blue-600/10'
: 'text-white bg-blue-600 hover:bg-blue-500', : 'text-white bg-blue-600 hover:bg-blue-500',
]" ]">
>
Add Add
</LoadingButton> </LoadingButton>
<button <button type="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" 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()" @click="() => cancelDirectory()" ref="cancelButtonRef">
ref="cancelButtonRef"
>
Cancel Cancel
</button> </button>
</div> </div>
<div v-if="error" class="mt-3 rounded-md bg-red-600/10 p-4"> <div v-if="error" class="mt-3 rounded-md bg-red-600/10 p-4">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<XCircleIcon <XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
</div> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-medium text-red-600"> <h3 class="text-sm font-medium text-red-600">
@ -220,6 +185,7 @@ import {
} from "@headlessui/vue"; } from "@headlessui/vue";
import { FolderIcon, TrashIcon, XCircleIcon } from "@heroicons/vue/16/solid"; import { FolderIcon, TrashIcon, XCircleIcon } from "@heroicons/vue/16/solid";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { Switch } from '@headlessui/vue'
import { type Settings } from "~/types"; import { type Settings } from "~/types";
const open = ref(false); const open = ref(false);
@ -231,6 +197,7 @@ const dirs = ref<Array<string>>([]);
const settings = await invoke<Settings>("fetch_settings"); const settings = await invoke<Settings>("fetch_settings");
const downloadThreads = ref(settings?.maxDownloadThreads ?? 4); const downloadThreads = ref(settings?.maxDownloadThreads ?? 4);
const forceOffline = ref(settings?.forceOffline ?? false);
const saveState = reactive({ const saveState = reactive({
loading: false, loading: false,
@ -293,21 +260,21 @@ async function deleteDirectory(index: number) {
await updateDirs(); await updateDirs();
} }
async function saveDownloadThreads() { async function saveSettings() {
try { try {
saveState.loading = true; saveState.loading = true;
await invoke("update_settings", { await invoke("update_settings", {
newSettings: { maxDownloadThreads: downloadThreads.value }, newSettings: { maxDownloadThreads: downloadThreads.value, forceOffline: forceOffline.value },
}); });
// Show success state // Show success state
saveState.success = true; saveState.success = true;
// Reset back to normal state after 2 seconds // Reset back to normal state after 2 seconds
setTimeout(() => { setTimeout(() => {
saveState.success = false; saveState.success = false;
}, 2000); }, 2000);
} catch (error) { } catch (error) {
console.error('Failed to save settings:', error); console.error('Failed to save settings:', error);
} finally { } finally {
@ -317,8 +284,8 @@ async function saveDownloadThreads() {
function validateNumberInput(event: KeyboardEvent) { function validateNumberInput(event: KeyboardEvent) {
// Allow only numbers and basic control keys // Allow only numbers and basic control keys
if (!/^\d$/.test(event.key) && if (!/^\d$/.test(event.key) &&
!['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { !['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
event.preventDefault(); event.preventDefault();
} }
} }

View File

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

View File

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

1492
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "drop-app" name = "drop-app"
version = "0.2.0-beta-prerelease-1" version = "0.3.0-rc-1"
description = "The client application for the open-source, self-hosted game distribution platform Drop" description = "The client application for the open-source, self-hosted game distribution platform Drop"
authors = ["Drop OSS"] authors = ["Drop OSS"]
edition = "2021" edition = "2021"
@ -25,7 +25,7 @@ rustflags = ["-C", "target-feature=+aes,+sse2"]
tauri-build = { version = "2.0.0", features = [] } tauri-build = { version = "2.0.0", features = [] }
[dependencies] [dependencies]
tauri-plugin-shell = "2.0.0" tauri-plugin-shell = "2.2.1"
serde_json = "1" serde_json = "1"
serde-binary = "0.5.0" serde-binary = "0.5.0"
rayon = "1.10.0" rayon = "1.10.0"
@ -50,12 +50,25 @@ slice-deque = "0.3.0"
throttle_my_fn = "0.2.6" throttle_my_fn = "0.2.6"
parking_lot = "0.12.3" parking_lot = "0.12.3"
atomic-instant-full = "0.1.0" atomic-instant-full = "0.1.0"
cacache = "13.1.0"
bincode = "1.3.3"
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"
native_db = "0.8.1"
native_model = "0.6.1"
[dependencies.dynfmt]
version = "0.1.5"
features = ["curly"]
[dependencies.tauri] [dependencies.tauri]
version = "2.1.1" version = "2.1.1"
features = ["tray-icon"] features = ["tray-icon"]
[dependencies.tokio] [dependencies.tokio]
version = "1.40.0" version = "1.40.0"
features = ["rt", "tokio-macros", "signal"] features = ["rt", "tokio-macros", "signal"]
@ -76,10 +89,6 @@ features = [
"macro-diagnostics", # Enable better diagnostics for compile-time UUIDs "macro-diagnostics", # Enable better diagnostics for compile-time UUIDs
] ]
[dependencies.openssl]
version = "0.10.66"
features = ["vendored"]
[dependencies.rustbreak] [dependencies.rustbreak]
version = "2" version = "2"
features = [] # You can also use "yaml_enc" or "bin_enc" features = [] # You can also use "yaml_enc" or "bin_enc"

View File

@ -74,7 +74,8 @@ pub fn update_settings(new_settings: Value) {
} }
let new_settings: Settings = serde_json::from_value(current_settings).unwrap(); let new_settings: Settings = serde_json::from_value(current_settings).unwrap();
db_lock.settings = new_settings; db_lock.settings = new_settings;
println!("new Settings: {:?}", db_lock.settings); drop(db_lock);
save_db();
} }
#[tauri::command] #[tauri::command]
pub fn fetch_settings() -> Settings { pub fn fetch_settings() -> Settings {

View File

@ -28,6 +28,7 @@ pub struct DatabaseAuth {
pub private: String, pub private: String,
pub cert: String, pub cert: String,
pub client_id: String, pub client_id: String,
pub web_token: Option<String>,
} }
// Strings are version names for a particular game // Strings are version names for a particular game
@ -46,7 +47,7 @@ pub enum GameDownloadStatus {
} }
// Stuff that shouldn't be synced to disk // Stuff that shouldn't be synced to disk
#[derive(Clone, Serialize)] #[derive(Clone, Serialize, Deserialize)]
pub enum ApplicationTransientStatus { pub enum ApplicationTransientStatus {
Downloading { version_name: String }, Downloading { version_name: String },
Uninstalling {}, Uninstalling {},
@ -54,6 +55,10 @@ pub enum ApplicationTransientStatus {
Running {}, Running {},
} }
fn default_template() -> String {
"{}".to_owned()
}
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct GameVersion { pub struct GameVersion {
@ -64,9 +69,13 @@ pub struct GameVersion {
pub launch_command: String, pub launch_command: String,
pub launch_args: Vec<String>, pub launch_args: Vec<String>,
#[serde(default = "default_template")]
pub launch_command_template: String,
pub setup_command: String, pub setup_command: String,
pub setup_args: Vec<String>, pub setup_args: Vec<String>,
#[serde(default = "default_template")]
pub setup_command_template: String,
pub only_setup: bool, pub only_setup: bool,
@ -98,9 +107,14 @@ pub struct Database {
pub base_url: String, pub base_url: String,
pub applications: DatabaseApplications, pub applications: DatabaseApplications,
pub prev_database: Option<PathBuf>, pub prev_database: Option<PathBuf>,
pub cache_dir: PathBuf,
} }
impl Database { impl Database {
fn new<T: Into<PathBuf>>(games_base_dir: T, prev_database: Option<PathBuf>) -> Self { fn new<T: Into<PathBuf>>(
games_base_dir: T,
prev_database: Option<PathBuf>,
cache_dir: PathBuf,
) -> Self {
Self { Self {
applications: DatabaseApplications { applications: DatabaseApplications {
install_dirs: vec![games_base_dir.into()], install_dirs: vec![games_base_dir.into()],
@ -112,10 +126,8 @@ impl Database {
prev_database, prev_database,
base_url: "".to_owned(), base_url: "".to_owned(),
auth: None, auth: None,
settings: Settings { settings: Settings::default(),
autostart: false, cache_dir,
max_download_threads: 4,
},
} }
} }
} }
@ -150,21 +162,23 @@ impl DatabaseImpls for DatabaseInterface {
let db_path = data_root_dir.join("drop.db"); let db_path = data_root_dir.join("drop.db");
let games_base_dir = data_root_dir.join("games"); let games_base_dir = data_root_dir.join("games");
let logs_root_dir = data_root_dir.join("logs"); let logs_root_dir = data_root_dir.join("logs");
let cache_dir = data_root_dir.join("cache");
debug!("creating data directory at {:?}", data_root_dir); debug!("creating data directory at {:?}", data_root_dir);
create_dir_all(data_root_dir.clone()).unwrap(); create_dir_all(data_root_dir.clone()).unwrap();
create_dir_all(games_base_dir.clone()).unwrap(); create_dir_all(&games_base_dir).unwrap();
create_dir_all(logs_root_dir.clone()).unwrap(); create_dir_all(&logs_root_dir).unwrap();
create_dir_all(&cache_dir).unwrap();
let exists = fs::exists(db_path.clone()).unwrap(); let exists = fs::exists(db_path.clone()).unwrap();
match exists { match exists {
true => match PathDatabase::load_from_path(db_path.clone()) { true => match PathDatabase::load_from_path(db_path.clone()) {
Ok(db) => db, Ok(db) => db,
Err(e) => handle_invalid_database(e, db_path, games_base_dir), Err(e) => handle_invalid_database(e, db_path, games_base_dir, cache_dir),
}, },
false => { false => {
let default = Database::new(games_base_dir, None); let default = Database::new(games_base_dir, None, cache_dir);
debug!( debug!(
"Creating database at path {}", "Creating database at path {}",
db_path.as_os_str().to_str().unwrap() db_path.as_os_str().to_str().unwrap()
@ -185,25 +199,12 @@ impl DatabaseImpls for DatabaseInterface {
} }
} }
pub fn set_game_status<F: FnOnce(&mut RwLockWriteGuard<'_, Database>, &DownloadableMetadata)>(
app_handle: &AppHandle,
meta: DownloadableMetadata,
setter: F,
) {
let mut db_handle = borrow_db_mut_checked();
setter(&mut db_handle, &meta);
drop(db_handle);
save_db();
let status = GameStatusManager::fetch_state(&meta.id);
push_game_update(app_handle, &meta.id, status);
}
// TODO: Make the error relelvant rather than just assume that it's a Deserialize error // TODO: Make the error relelvant rather than just assume that it's a Deserialize error
fn handle_invalid_database( fn handle_invalid_database(
_e: RustbreakError, _e: RustbreakError,
db_path: PathBuf, db_path: PathBuf,
games_base_dir: PathBuf, games_base_dir: PathBuf,
cache_dir: PathBuf,
) -> rustbreak::Database<Database, rustbreak::backend::PathBackend, DropDatabaseSerializer> { ) -> rustbreak::Database<Database, rustbreak::backend::PathBackend, DropDatabaseSerializer> {
let new_path = { let new_path = {
let time = Utc::now().timestamp(); let time = Utc::now().timestamp();
@ -220,6 +221,7 @@ fn handle_invalid_database(
let db = Database::new( let db = Database::new(
games_base_dir.into_os_string().into_string().unwrap(), games_base_dir.into_os_string().into_string().unwrap(),
Some(new_path), Some(new_path),
cache_dir,
); );
PathDatabase::create_at_path(db_path, db).expect("Database could not be created") PathDatabase::create_at_path(db_path, db).expect("Database could not be created")

View File

@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
pub struct Settings { pub struct Settings {
pub autostart: bool, pub autostart: bool,
pub max_download_threads: usize, pub max_download_threads: usize,
pub force_offline: bool
// ... other settings ... // ... other settings ...
} }
impl Default for Settings { impl Default for Settings {
@ -12,6 +13,7 @@ impl Default for Settings {
Self { Self {
autostart: false, autostart: false,
max_download_threads: 4, max_download_threads: 4,
force_offline: false
} }
} }
} }

View File

@ -48,7 +48,7 @@ pub enum DownloadManagerSignal {
Uninstall(DownloadableMetadata), Uninstall(DownloadableMetadata),
} }
#[derive(Debug, Clone)] #[derive(Debug)]
pub enum DownloadManagerStatus { pub enum DownloadManagerStatus {
Downloading, Downloading,
Paused, Paused,

View File

@ -255,7 +255,7 @@ impl DownloadManagerBuilder {
} }
Err(e) => { Err(e) => {
error!("download {:?} has error {}", download_agent.metadata(), &e); error!("download {:?} has error {}", download_agent.metadata(), &e);
download_agent.on_error(&app_handle, e.clone()); download_agent.on_error(&app_handle, &e);
sender.send(DownloadManagerSignal::Error(e)).unwrap(); sender.send(DownloadManagerSignal::Error(e)).unwrap();
} }
} }
@ -287,7 +287,7 @@ impl DownloadManagerBuilder {
fn manage_error_signal(&mut self, error: ApplicationDownloadError) { fn manage_error_signal(&mut self, error: ApplicationDownloadError) {
debug!("got signal Error"); debug!("got signal Error");
if let Some(current_agent) = self.current_download_agent.clone() { if let Some(current_agent) = self.current_download_agent.clone() {
current_agent.on_error(&self.app_handle, error.clone()); current_agent.on_error(&self.app_handle, &error);
self.stop_and_wait_current_download(); self.stop_and_wait_current_download();
self.remove_and_cleanup_front_download(&current_agent.metadata()); self.remove_and_cleanup_front_download(&current_agent.metadata());

View File

@ -16,7 +16,7 @@ pub trait Downloadable: Send + Sync {
fn status(&self) -> DownloadStatus; fn status(&self) -> DownloadStatus;
fn metadata(&self) -> DownloadableMetadata; fn metadata(&self) -> DownloadableMetadata;
fn on_initialised(&self, app_handle: &AppHandle); fn on_initialised(&self, app_handle: &AppHandle);
fn on_error(&self, app_handle: &AppHandle, error: ApplicationDownloadError); fn on_error(&self, app_handle: &AppHandle, error: &ApplicationDownloadError);
fn on_complete(&self, app_handle: &AppHandle); fn on_complete(&self, app_handle: &AppHandle);
fn on_incomplete(&self, app_handle: &AppHandle); fn on_incomplete(&self, app_handle: &AppHandle);
fn on_cancelled(&self, app_handle: &AppHandle); fn on_cancelled(&self, app_handle: &AppHandle);

View File

@ -8,7 +8,7 @@ use serde_with::SerializeDisplay;
use super::{remote_access_error::RemoteAccessError, setup_error::SetupError}; use super::{remote_access_error::RemoteAccessError, setup_error::SetupError};
// TODO: Rename / separate from downloads // TODO: Rename / separate from downloads
#[derive(Debug, Clone, SerializeDisplay)] #[derive(Debug, SerializeDisplay)]
pub enum ApplicationDownloadError { pub enum ApplicationDownloadError {
Communication(RemoteAccessError), Communication(RemoteAccessError),
Checksum, Checksum,

View File

@ -11,6 +11,7 @@ pub enum ProcessError {
InvalidID, InvalidID,
InvalidVersion, InvalidVersion,
IOError(Error), IOError(Error),
FormatError(String), // String errors supremacy
InvalidPlatform, InvalidPlatform,
} }
@ -25,6 +26,7 @@ impl Display for ProcessError {
ProcessError::InvalidVersion => "Invalid Game version", ProcessError::InvalidVersion => "Invalid Game version",
ProcessError::IOError(error) => &error.to_string(), ProcessError::IOError(error) => &error.to_string(),
ProcessError::InvalidPlatform => "This Game cannot be played on the current platform", ProcessError::InvalidPlatform => "This Game cannot be played on the current platform",
ProcessError::FormatError(e) => &format!("Failed to format template: {}", e),
}; };
write!(f, "{}", s) write!(f, "{}", s)
} }

View File

@ -1,4 +1,5 @@
use std::{ use std::{
any::{Any, TypeId},
error::Error, error::Error,
fmt::{Display, Formatter}, fmt::{Display, Formatter},
sync::Arc, sync::Arc,
@ -10,39 +11,46 @@ use url::ParseError;
use super::drop_server_error::DropServerError; use super::drop_server_error::DropServerError;
#[derive(Debug, Clone, SerializeDisplay)] #[derive(Debug, SerializeDisplay)]
pub enum RemoteAccessError { pub enum RemoteAccessError {
FetchError(Arc<reqwest::Error>), FetchError(Arc<reqwest::Error>),
ParsingError(ParseError), ParsingError(ParseError),
InvalidEndpoint, InvalidEndpoint,
HandshakeFailed(String), HandshakeFailed(String),
GameNotFound, GameNotFound(String),
InvalidResponse(DropServerError), InvalidResponse(DropServerError),
InvalidRedirect, InvalidRedirect,
ManifestDownloadFailed(StatusCode, String), ManifestDownloadFailed(StatusCode, String),
OutOfSync, OutOfSync,
Cache(cacache::Error),
Generic(String), Generic(String),
} }
impl Display for RemoteAccessError { impl Display for RemoteAccessError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self { match self {
RemoteAccessError::FetchError(error) => write!( RemoteAccessError::FetchError(error) => {
f, 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.");
error, }
error
.source() write!(
.map(|e| e.to_string()) f,
.or_else(|| Some("Unknown error".to_string())) "{}: {}",
.unwrap() error,
), error
.source()
.map(|e| e.to_string())
.or_else(|| Some("Unknown error".to_string()))
.unwrap()
)
},
RemoteAccessError::ParsingError(parse_error) => { RemoteAccessError::ParsingError(parse_error) => {
write!(f, "{}", parse_error) write!(f, "{}", parse_error)
} }
RemoteAccessError::InvalidEndpoint => write!(f, "invalid drop endpoint"), RemoteAccessError::InvalidEndpoint => write!(f, "invalid drop endpoint"),
RemoteAccessError::HandshakeFailed(message) => write!(f, "failed to complete handshake: {}", message), RemoteAccessError::HandshakeFailed(message) => write!(f, "failed to complete handshake: {}", message),
RemoteAccessError::GameNotFound => write!(f, "could not find game on server"), 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::InvalidResponse(error) => write!(f, "server returned an invalid response: {} {}", error.status_code, error.status_message),
RemoteAccessError::InvalidRedirect => write!(f, "server redirect was invalid"), RemoteAccessError::InvalidRedirect => write!(f, "server redirect was invalid"),
RemoteAccessError::ManifestDownloadFailed(status, response) => write!( RemoteAccessError::ManifestDownloadFailed(status, response) => write!(
@ -52,6 +60,7 @@ impl Display for RemoteAccessError {
), ),
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::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::Generic(message) => write!(f, "{}", message), RemoteAccessError::Generic(message) => write!(f, "{}", message),
RemoteAccessError::Cache(error) => write!(f, "Cache Error: {}", error),
} }
} }
} }

View File

@ -0,0 +1,24 @@
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,85 @@
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 commands;
pub mod collection;

View File

@ -1,9 +1,9 @@
use std::sync::Mutex; use std::sync::Mutex;
use tauri::AppHandle; use tauri::{AppHandle, Manager};
use crate::{ use crate::{
database::db::GameVersion, error::{library_error::LibraryError, remote_access_error::RemoteAccessError}, games::library::{get_current_meta, uninstall_game_logic}, AppState database::db::GameVersion, error::{library_error::LibraryError, remote_access_error::RemoteAccessError}, games::library::{fetch_game_logic_offline, fetch_library_logic_offline, get_current_meta, uninstall_game_logic}, offline, AppState
}; };
use super::{ use super::{
@ -15,16 +15,16 @@ use super::{
}; };
#[tauri::command] #[tauri::command]
pub fn fetch_library(app: AppHandle) -> Result<Vec<Game>, RemoteAccessError> { pub fn fetch_library(state: tauri::State<'_, Mutex<AppState>>) -> Result<Vec<Game>, RemoteAccessError> {
fetch_library_logic(app) offline!(state, fetch_library_logic, fetch_library_logic_offline, state)
} }
#[tauri::command] #[tauri::command]
pub fn fetch_game( pub fn fetch_game(
game_id: String, game_id: String,
app: tauri::AppHandle, state: tauri::State<'_, Mutex<AppState>>
) -> Result<FetchGameStruct, RemoteAccessError> { ) -> Result<FetchGameStruct, RemoteAccessError> {
fetch_game_logic(game_id, app) offline!(state, fetch_game_logic, fetch_game_logic_offline, game_id, state)
} }
#[tauri::command] #[tauri::command]
@ -38,7 +38,6 @@ pub fn uninstall_game(game_id: String, app_handle: AppHandle) -> Result<(), Libr
Some(data) => data, Some(data) => data,
None => return Err(LibraryError::MetaNotFound(game_id)), None => return Err(LibraryError::MetaNotFound(game_id)),
}; };
println!("{:?}", meta);
uninstall_game_logic(meta, &app_handle); uninstall_game_logic(meta, &app_handle);
Ok(()) Ok(())

View File

@ -1,6 +1,6 @@
use crate::auth::generate_authorization_header; use crate::auth::generate_authorization_header;
use crate::database::db::{ use crate::database::db::{
borrow_db_checked, set_game_status, ApplicationTransientStatus, DatabaseImpls, borrow_db_checked, ApplicationTransientStatus, DatabaseImpls,
GameDownloadStatus, GameDownloadStatus,
}; };
use crate::download_manager::download_manager::{DownloadManagerSignal, DownloadStatus}; use crate::download_manager::download_manager::{DownloadManagerSignal, DownloadStatus};
@ -99,6 +99,7 @@ impl GameDownloadAgent {
push_game_update( push_game_update(
app_handle, app_handle,
&self.metadata().id, &self.metadata().id,
None,
( (
None, None,
Some(ApplicationTransientStatus::Downloading { Some(ApplicationTransientStatus::Downloading {
@ -135,7 +136,7 @@ impl GameDownloadAgent {
&[("id", &self.id), ("version", &self.version)], &[("id", &self.id), ("version", &self.version)],
|f| f.header("Authorization", header), |f| f.header("Authorization", header),
) )
.map_err(|e| ApplicationDownloadError::Communication(e))? .map_err(ApplicationDownloadError::Communication)?
.send() .send()
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?; .map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
@ -250,6 +251,7 @@ impl GameDownloadAgent {
let completed_indexes_loop_arc = completed_indexes.clone(); let completed_indexes_loop_arc = completed_indexes.clone();
let contexts = self.contexts.lock().unwrap(); let contexts = self.contexts.lock().unwrap();
debug!("{:#?}", contexts);
pool.scope(|scope| { pool.scope(|scope| {
let client = &reqwest::blocking::Client::new(); let client = &reqwest::blocking::Client::new();
for (index, context) in contexts.iter().enumerate() { for (index, context) in contexts.iter().enumerate() {
@ -280,9 +282,13 @@ impl GameDownloadAgent {
) { ) {
Ok(request) => request, Ok(request) => request,
Err(e) => { Err(e) => {
sender.send(DownloadManagerSignal::Error(ApplicationDownloadError::Communication(e))).unwrap(); sender
.send(DownloadManagerSignal::Error(
ApplicationDownloadError::Communication(e),
))
.unwrap();
continue; continue;
}, }
}; };
scope.spawn(move |_| { scope.spawn(move |_| {
@ -362,7 +368,7 @@ impl Downloadable for GameDownloadAgent {
*self.status.lock().unwrap() = DownloadStatus::Queued; *self.status.lock().unwrap() = DownloadStatus::Queued;
} }
fn on_error(&self, app_handle: &tauri::AppHandle, error: ApplicationDownloadError) { fn on_error(&self, app_handle: &tauri::AppHandle, error: &ApplicationDownloadError) {
*self.status.lock().unwrap() = DownloadStatus::Error; *self.status.lock().unwrap() = DownloadStatus::Error;
app_handle app_handle
.emit("download_error", error.to_string()) .emit("download_error", error.to_string())
@ -370,9 +376,8 @@ impl Downloadable for GameDownloadAgent {
error!("error while managing download: {}", error); error!("error while managing download: {}", error);
set_game_status(app_handle, self.metadata(), |db_handle, meta| { let mut handle = DB.borrow_data_mut().unwrap();
db_handle.applications.transient_statuses.remove(meta); handle.applications.transient_statuses.remove(&self.metadata());
});
} }
fn on_complete(&self, app_handle: &tauri::AppHandle) { fn on_complete(&self, app_handle: &tauri::AppHandle) {
@ -394,6 +399,7 @@ impl Downloadable for GameDownloadAgent {
GameUpdateEvent { GameUpdateEvent {
game_id: meta.id.clone(), game_id: meta.id.clone(),
status: (Some(GameDownloadStatus::Remote {}), None), status: (Some(GameDownloadStatus::Remote {}), None),
version: None,
}, },
) )
.unwrap(); .unwrap();

View File

@ -2,7 +2,7 @@ use std::fs::remove_dir_all;
use std::sync::Mutex; use std::sync::Mutex;
use std::thread::spawn; use std::thread::spawn;
use log::{debug, error, warn}; use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::Emitter; use tauri::Emitter;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
@ -11,19 +11,22 @@ use crate::database::db::{borrow_db_checked, borrow_db_mut_checked, save_db, Gam
use crate::database::db::{ApplicationTransientStatus, GameDownloadStatus}; use crate::database::db::{ApplicationTransientStatus, GameDownloadStatus};
use crate::download_manager::download_manager::DownloadStatus; use crate::download_manager::download_manager::DownloadStatus;
use crate::download_manager::downloadable_metadata::DownloadableMetadata; use crate::download_manager::downloadable_metadata::DownloadableMetadata;
use crate::error::library_error::LibraryError;
use crate::error::remote_access_error::RemoteAccessError; use crate::error::remote_access_error::RemoteAccessError;
use crate::games::state::{GameStatusManager, GameStatusWithTransient}; use crate::games::state::{GameStatusManager, GameStatusWithTransient};
use crate::remote::auth::generate_authorization_header; use crate::remote::auth::generate_authorization_header;
use crate::remote::cache::{cache_object, get_cached_object, get_cached_object_db};
use crate::remote::requests::make_request; use crate::remote::requests::make_request;
use crate::AppState; use crate::{AppState, DB};
#[derive(serde::Serialize)] #[derive(Serialize, Deserialize)]
pub struct FetchGameStruct { pub struct FetchGameStruct {
game: Game, game: Game,
status: GameStatusWithTransient, status: GameStatusWithTransient,
version: Option<GameVersion>,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Game { pub struct Game {
id: String, id: String,
@ -36,6 +39,7 @@ pub struct Game {
m_banner_id: String, m_banner_id: String,
m_cover_id: String, m_cover_id: String,
m_image_library: Vec<String>, m_image_library: Vec<String>,
m_image_carousel: Vec<String>,
} }
#[derive(serde::Serialize, Clone)] #[derive(serde::Serialize, Clone)]
pub struct GameUpdateEvent { pub struct GameUpdateEvent {
@ -44,6 +48,7 @@ pub struct GameUpdateEvent {
Option<GameDownloadStatus>, Option<GameDownloadStatus>,
Option<ApplicationTransientStatus>, Option<ApplicationTransientStatus>,
), ),
pub version: Option<GameVersion>,
} }
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
@ -66,7 +71,9 @@ pub struct StatsUpdateEvent {
pub time: usize, pub time: usize,
} }
pub fn fetch_library_logic(app: AppHandle) -> Result<Vec<Game>, RemoteAccessError> { pub fn fetch_library_logic(
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<Vec<Game>, RemoteAccessError> {
let header = generate_authorization_header(); let header = generate_authorization_header();
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
@ -81,9 +88,8 @@ pub fn fetch_library_logic(app: AppHandle) -> Result<Vec<Game>, RemoteAccessErro
return Err(RemoteAccessError::InvalidResponse(err)); return Err(RemoteAccessError::InvalidResponse(err));
} }
let games: Vec<Game> = response.json()?; let mut games: Vec<Game> = response.json()?;
let state = app.state::<Mutex<AppState>>();
let mut handle = state.lock().unwrap(); let mut handle = state.lock().unwrap();
let mut db_handle = borrow_db_mut_checked(); let mut db_handle = borrow_db_mut_checked();
@ -98,18 +104,63 @@ pub fn fetch_library_logic(app: AppHandle) -> Result<Vec<Game>, RemoteAccessErro
} }
} }
// Add games that are installed but no longer in library
for (_, meta) in &db_handle.applications.installed_game_version {
if games.iter().find(|e| e.id == meta.id).is_some() {
continue;
}
// We should always have a cache of the object
// Pass db_handle because otherwise we get a gridlock
let game = get_cached_object_db::<String, Game>(meta.id.clone(), &db_handle)?;
games.push(game);
}
drop(handle); drop(handle);
drop(db_handle);
cache_object("library", &games)?;
Ok(games) Ok(games)
} }
pub fn fetch_library_logic_offline(
_state: tauri::State<'_, Mutex<AppState>>,
) -> Result<Vec<Game>, RemoteAccessError> {
let mut games: Vec<Game> = get_cached_object("library")?;
let db_handle = borrow_db_checked();
games.retain(|game| {
db_handle
.applications
.installed_game_version
.contains_key(&game.id)
});
Ok(games)
}
pub fn fetch_game_logic( pub fn fetch_game_logic(
id: String, id: String,
app: tauri::AppHandle, state: tauri::State<'_, Mutex<AppState>>,
) -> Result<FetchGameStruct, RemoteAccessError> { ) -> Result<FetchGameStruct, RemoteAccessError> {
let state = app.state::<Mutex<AppState>>();
let mut state_handle = state.lock().unwrap(); let mut state_handle = state.lock().unwrap();
let handle = DB.borrow_data().unwrap();
let metadata_option = handle.applications.installed_game_version.get(&id);
let version = match metadata_option {
None => None,
Some(metadata) => Some(
handle
.applications
.game_versions
.get(&metadata.id)
.unwrap()
.get(metadata.version.as_ref().unwrap())
.unwrap()
.clone(),
),
};
drop(handle);
let game = state_handle.games.get(&id); let game = state_handle.games.get(&id);
if let Some(game) = game { if let Some(game) = game {
let status = GameStatusManager::fetch_state(&id); let status = GameStatusManager::fetch_state(&id);
@ -117,18 +168,21 @@ pub fn fetch_game_logic(
let data = FetchGameStruct { let data = FetchGameStruct {
game: game.clone(), game: game.clone(),
status, status,
version,
}; };
cache_object(id, game)?;
return Ok(data); return Ok(data);
} }
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let response = make_request(&client, &["/api/v1/game/", &id], &[], |r| { let response = make_request(&client, &["/api/v1/client/game/", &id], &[], |r| {
r.header("Authorization", generate_authorization_header()) r.header("Authorization", generate_authorization_header())
})? })?
.send()?; .send()?;
if response.status() == 404 { if response.status() == 404 {
return Err(RemoteAccessError::GameNotFound); return Err(RemoteAccessError::GameNotFound(id));
} }
if response.status() != 200 { if response.status() != 200 {
let err = response.json().unwrap(); let err = response.json().unwrap();
@ -153,11 +207,45 @@ pub fn fetch_game_logic(
let data = FetchGameStruct { let data = FetchGameStruct {
game: game.clone(), game: game.clone(),
status, status,
version,
}; };
cache_object(id, &game)?;
Ok(data) Ok(data)
} }
pub fn fetch_game_logic_offline(
id: String,
_state: tauri::State<'_, Mutex<AppState>>,
) -> Result<FetchGameStruct, RemoteAccessError> {
let handle = DB.borrow_data().unwrap();
let metadata_option = handle.applications.installed_game_version.get(&id);
let version = match metadata_option {
None => None,
Some(metadata) => Some(
handle
.applications
.game_versions
.get(&metadata.id)
.unwrap()
.get(metadata.version.as_ref().unwrap())
.unwrap()
.clone(),
),
};
drop(handle);
let status = GameStatusManager::fetch_state(&id);
let game = get_cached_object::<String, Game>(id)?;
Ok(FetchGameStruct {
game,
status,
version,
})
}
pub fn fetch_game_verion_options_logic( pub fn fetch_game_verion_options_logic(
game_id: String, game_id: String,
state: tauri::State<'_, Mutex<AppState>>, state: tauri::State<'_, Mutex<AppState>>,
@ -193,7 +281,7 @@ pub fn fetch_game_verion_options_logic(
} }
pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) { pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) {
println!("triggered uninstall for agent"); debug!("triggered uninstall for agent");
let mut db_handle = borrow_db_mut_checked(); let mut db_handle = borrow_db_mut_checked();
db_handle db_handle
.applications .applications
@ -204,6 +292,7 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle)
push_game_update( push_game_update(
app_handle, app_handle,
&meta.id, &meta.id,
None,
(None, Some(ApplicationTransientStatus::Uninstalling {})), (None, Some(ApplicationTransientStatus::Uninstalling {})),
); );
@ -213,6 +302,7 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle)
return; return;
} }
let previous_state = previous_state.unwrap(); let previous_state = previous_state.unwrap();
if let Some((_, install_dir)) = match previous_state { if let Some((_, install_dir)) = match previous_state {
GameDownloadStatus::Installed { GameDownloadStatus::Installed {
version_name, version_name,
@ -229,6 +319,7 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle)
.transient_statuses .transient_statuses
.entry(meta.clone()) .entry(meta.clone())
.and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {}); .and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {});
drop(db_handle); drop(db_handle);
let app_handle = app_handle.clone(); let app_handle = app_handle.clone();
@ -239,6 +330,10 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle)
Ok(_) => { Ok(_) => {
let mut db_handle = borrow_db_mut_checked(); let mut db_handle = borrow_db_mut_checked();
db_handle.applications.transient_statuses.remove(&meta); db_handle.applications.transient_statuses.remove(&meta);
db_handle
.applications
.installed_game_version
.remove(&meta.id);
db_handle db_handle
.applications .applications
.game_statuses .game_statuses
@ -248,10 +343,12 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle)
save_db(); save_db();
debug!("uninstalled game id {}", &meta.id); debug!("uninstalled game id {}", &meta.id);
app_handle.emit("update_library", {}).unwrap();
push_game_update( push_game_update(
&app_handle, &app_handle,
&meta.id, &meta.id,
None,
(Some(GameDownloadStatus::Remote {}), None), (Some(GameDownloadStatus::Remote {}), None),
); );
} }
@ -274,7 +371,7 @@ pub fn on_game_complete(
) -> Result<(), RemoteAccessError> { ) -> Result<(), RemoteAccessError> {
// Fetch game version information from remote // Fetch game version information from remote
if meta.version.is_none() { if meta.version.is_none() {
return Err(RemoteAccessError::GameNotFound); return Err(RemoteAccessError::GameNotFound(meta.id.clone()));
} }
let header = generate_authorization_header(); let header = generate_authorization_header();
@ -282,7 +379,7 @@ pub fn on_game_complete(
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let response = make_request( let response = make_request(
&client, &client,
&["/api/v1/client/metadata/version"], &["/api/v1/client/game/version"],
&[ &[
("id", &meta.id), ("id", &meta.id),
("version", meta.version.as_ref().unwrap()), ("version", meta.version.as_ref().unwrap()),
@ -291,7 +388,7 @@ pub fn on_game_complete(
)? )?
.send()?; .send()?;
let data: GameVersion = response.json()?; let game_version: GameVersion = response.json()?;
let mut handle = borrow_db_mut_checked(); let mut handle = borrow_db_mut_checked();
handle handle
@ -299,7 +396,7 @@ pub fn on_game_complete(
.game_versions .game_versions
.entry(meta.id.clone()) .entry(meta.id.clone())
.or_default() .or_default()
.insert(meta.version.clone().unwrap(), data.clone()); .insert(meta.version.clone().unwrap(), game_version.clone());
handle handle
.applications .applications
.installed_game_version .installed_game_version
@ -308,7 +405,7 @@ pub fn on_game_complete(
drop(handle); drop(handle);
save_db(); save_db();
let status = if data.setup_command.is_empty() { let status = if game_version.setup_command.is_empty() {
GameDownloadStatus::Installed { GameDownloadStatus::Installed {
version_name: meta.version.clone().unwrap(), version_name: meta.version.clone().unwrap(),
install_dir, install_dir,
@ -333,6 +430,7 @@ pub fn on_game_complete(
GameUpdateEvent { GameUpdateEvent {
game_id: meta.id.clone(), game_id: meta.id.clone(),
status: (Some(status), None), status: (Some(status), None),
version: Some(game_version),
}, },
) )
.unwrap(); .unwrap();
@ -340,14 +438,63 @@ pub fn on_game_complete(
Ok(()) Ok(())
} }
pub fn push_game_update(app_handle: &AppHandle, game_id: &String, status: GameStatusWithTransient) { pub fn push_game_update(app_handle: &AppHandle, game_id: &String, version: Option<GameVersion>, status: GameStatusWithTransient) {
app_handle app_handle
.emit( .emit(
&format!("update_game/{}", game_id), &format!("update_game/{}", game_id),
GameUpdateEvent { GameUpdateEvent {
game_id: game_id.clone(), game_id: game_id.clone(),
status, status,
version,
}, },
) )
.unwrap(); .unwrap();
} }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontendGameOptions {
launch_string: String,
}
#[tauri::command]
pub fn update_game_configuration(
game_id: String,
options: FrontendGameOptions,
) -> Result<(), LibraryError> {
let mut handle = DB.borrow_data_mut().unwrap();
let installed_version = handle
.applications
.installed_game_version
.get(&game_id)
.ok_or(LibraryError::MetaNotFound(game_id))?;
let id = installed_version.id.clone();
let version = installed_version.version.clone().unwrap();
let mut existing_configuration = handle
.applications
.game_versions
.get(&id)
.unwrap()
.get(&version)
.unwrap()
.clone();
// Add more options in here
existing_configuration.launch_command_template = options.launch_string;
// Add no more options past here
handle
.applications
.game_versions
.get_mut(&id)
.unwrap()
.insert(version.to_string(), existing_configuration);
drop(handle);
save_db();
Ok(())
}

View File

@ -2,3 +2,4 @@ pub mod commands;
pub mod downloads; pub mod downloads;
pub mod library; pub mod library;
pub mod state; pub mod state;
pub mod collections;

View File

@ -25,11 +25,15 @@ use download_manager::commands::{
}; };
use download_manager::download_manager::DownloadManager; use download_manager::download_manager::DownloadManager;
use download_manager::download_manager_builder::DownloadManagerBuilder; use download_manager::download_manager_builder::DownloadManagerBuilder;
use games::collections::commands::{
add_game_to_collection, create_collection, delete_collection, delete_game_in_collection,
fetch_collection, fetch_collections,
};
use games::commands::{ use games::commands::{
fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game, fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game,
}; };
use games::downloads::commands::download_game; use games::downloads::commands::download_game;
use games::library::Game; use games::library::{update_game_configuration, Game};
use http::Response; use http::Response;
use http::{header::*, response::Builder as ResponseBuilder}; use http::{header::*, response::Builder as ResponseBuilder};
use log::{debug, info, warn, LevelFilter}; use log::{debug, info, warn, LevelFilter};
@ -40,11 +44,15 @@ use log4rs::encode::pattern::PatternEncoder;
use log4rs::Config; use log4rs::Config;
use process::commands::{kill_game, launch_game}; use process::commands::{kill_game, launch_game};
use process::process_manager::ProcessManager; use process::process_manager::ProcessManager;
use remote::auth::{self, generate_authorization_header, recieve_handshake}; use remote::auth::{self, recieve_handshake};
use remote::commands::{ use remote::commands::{
auth_initiate, gen_drop_url, manual_recieve_handshake, retry_connect, sign_out, use_remote, auth_initiate, fetch_drop_object, gen_drop_url, manual_recieve_handshake, retry_connect,
sign_out, use_remote,
}; };
use remote::fetch_object::{fetch_object, fetch_object_offline};
use remote::requests::make_request; use remote::requests::make_request;
use remote::server_proto::{handle_server_proto, handle_server_proto_offline};
use reqwest::blocking::Body;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::env; use std::env;
use std::path::Path; use std::path::Path;
@ -60,9 +68,10 @@ use tauri::{AppHandle, Manager, RunEvent, WindowEvent};
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
#[derive(Clone, Copy, Serialize)] #[derive(Clone, Copy, Serialize, Eq, PartialEq)]
pub enum AppStatus { pub enum AppStatus {
NotConfigured, NotConfigured,
Offline,
ServerError, ServerError,
SignedOut, SignedOut,
SignedIn, SignedIn,
@ -80,6 +89,7 @@ pub struct User {
profile_picture: String, profile_picture: String,
} }
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AppState<'a> { pub struct AppState<'a> {
@ -236,6 +246,7 @@ pub fn run() {
// Remote // Remote
use_remote, use_remote,
gen_drop_url, gen_drop_url,
fetch_drop_object,
// Library // Library
fetch_library, fetch_library,
fetch_game, fetch_game,
@ -244,6 +255,14 @@ pub fn run() {
fetch_download_dir_stats, fetch_download_dir_stats,
fetch_game_status, fetch_game_status,
fetch_game_verion_options, fetch_game_verion_options,
update_game_configuration,
// Collections
fetch_collections,
fetch_collection,
create_collection,
add_game_to_collection,
delete_collection,
delete_game_in_collection,
// Downloads // Downloads
download_game, download_game,
move_download_in_queue, move_download_in_queue,
@ -327,7 +346,7 @@ pub fn run() {
} }
_ => { _ => {
println!("menu event not handled: {:?}", event.id); warn!("menu event not handled: {:?}", event.id);
} }
}) })
.build(app) .build(app)
@ -356,35 +375,26 @@ pub fn run() {
Ok(()) Ok(())
}) })
.register_asynchronous_uri_scheme_protocol("object", move |_ctx, request, responder| { .register_asynchronous_uri_scheme_protocol("object", move |ctx, request, responder| {
// Drop leading / let state: tauri::State<'_, Mutex<AppState>> = ctx.app_handle().state();
let object_id = &request.uri().path()[1..]; offline!(
state,
let header = generate_authorization_header(); fetch_object,
let client: reqwest::blocking::Client = reqwest::blocking::Client::new(); fetch_object_offline,
let response = make_request(&client, &["/api/v1/client/object/", object_id], &[], |f| { request,
f.header("Authorization", header) responder
}) );
.unwrap() })
.send(); .register_asynchronous_uri_scheme_protocol("server", move |ctx, request, responder| {
if response.is_err() { let state: tauri::State<'_, Mutex<AppState>> = ctx.app_handle().state();
warn!( offline!(
"failed to fetch object with error: {}", state,
response.err().unwrap() handle_server_proto,
); handle_server_proto_offline,
responder.respond(Response::builder().status(500).body(Vec::new()).unwrap()); request,
return; responder
}
let response = response.unwrap();
let resp_builder = ResponseBuilder::new().header(
CONTENT_TYPE,
response.headers().get("Content-Type").unwrap(),
); );
let data = Vec::from(response.bytes().unwrap());
let resp = resp_builder.body(data).unwrap();
responder.respond(resp);
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event { if let WindowEvent::CloseRequested { api, .. } = event {

View File

@ -4,10 +4,13 @@ use std::{
io::{self, Error}, io::{self, Error},
path::{Path, PathBuf}, path::{Path, PathBuf},
process::{Child, Command, ExitStatus}, process::{Child, Command, ExitStatus},
str::FromStr,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
thread::spawn, thread::spawn,
}; };
use dynfmt::Format;
use dynfmt::SimpleCurlyFormat;
use log::{debug, info, warn}; use log::{debug, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use shared_child::SharedChild; use shared_child::SharedChild;
@ -16,7 +19,8 @@ use umu_wrapper_lib::command_builder::UmuCommandBuilder;
use crate::{ use crate::{
database::db::{ database::db::{
borrow_db_mut_checked, ApplicationTransientStatus, GameDownloadStatus, GameVersion, DATA_ROOT_DIR borrow_db_mut_checked, ApplicationTransientStatus, GameDownloadStatus, GameVersion,
DATA_ROOT_DIR,
}, },
download_manager::downloadable_metadata::{DownloadType, DownloadableMetadata}, download_manager::downloadable_metadata::{DownloadType, DownloadableMetadata},
error::process_error::ProcessError, error::process_error::ProcessError,
@ -39,11 +43,14 @@ impl ProcessManager<'_> {
drop(root_dir_lock); drop(root_dir_lock);
ProcessManager { ProcessManager {
current_platform: if cfg!(windows) { #[cfg(target_os = "windows")]
Platform::Windows current_platform: Platform::Windows,
} else {
Platform::Linux #[cfg(target_os = "macos")]
}, current_platform: Platform::macOS,
#[cfg(target_os = "linux")]
current_platform: Platform::Linux,
app_handle, app_handle,
processes: HashMap::new(), processes: HashMap::new(),
@ -58,6 +65,10 @@ impl ProcessManager<'_> {
(Platform::Linux, Platform::Linux), (Platform::Linux, Platform::Linux),
&NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static), &NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
), ),
(
(Platform::macOS, Platform::macOS),
&NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
( (
(Platform::Linux, Platform::Windows), (Platform::Linux, Platform::Windows),
&UMULauncher {} as &(dyn ProcessHandler + Sync + Send + 'static), &UMULauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
@ -66,20 +77,6 @@ impl ProcessManager<'_> {
} }
} }
fn process_command(&self, install_dir: &String, command: Vec<String>) -> (PathBuf, Vec<String>) {
let root = &command[0];
let install_dir = Path::new(install_dir);
let absolute_exe = install_dir.join(root);
/*
let args = command_components[1..]
.iter()
.map(|v| v.to_string())
.collect();
*/
(absolute_exe, Vec::new())
}
pub fn kill_game(&mut self, game_id: String) -> Result<(), io::Error> { pub fn kill_game(&mut self, game_id: String) -> Result<(), io::Error> {
match self.processes.get(&game_id) { match self.processes.get(&game_id) {
Some(child) => { Some(child) => {
@ -137,7 +134,7 @@ impl ProcessManager<'_> {
let status = GameStatusManager::fetch_state(&game_id); let status = GameStatusManager::fetch_state(&game_id);
push_game_update(&self.app_handle, &game_id, status); push_game_update(&self.app_handle, &game_id, None, status);
// TODO better management // TODO better management
} }
@ -198,7 +195,6 @@ impl ProcessManager<'_> {
_ => return Err(ProcessError::NotDownloaded), _ => return Err(ProcessError::NotDownloaded),
}; };
let game_version = db_lock let game_version = db_lock
.applications .applications
.game_versions .game_versions
@ -207,37 +203,6 @@ impl ProcessManager<'_> {
.get(version_name) .get(version_name)
.ok_or(ProcessError::InvalidVersion)?; .ok_or(ProcessError::InvalidVersion)?;
let mut command: Vec<String> = Vec::new();
match game_status {
GameDownloadStatus::Installed {
version_name: _,
install_dir: _,
} => {
command.extend([game_version.launch_command.clone()]);
command.extend(game_version.launch_args.clone());
},
GameDownloadStatus::SetupRequired {
version_name: _,
install_dir: _,
} => {
command.extend([game_version.setup_command.clone()]);
command.extend(game_version.setup_args.clone());
},
_ => panic!("unreachable code"),
};
info!("Command: {:?}", &command);
let (command, args) = self.process_command(install_dir, command);
let target_current_dir = command.parent().unwrap().to_str().unwrap();
info!(
"launching process {} in {}",
command.to_str().unwrap(),
target_current_dir
);
let current_time = chrono::offset::Local::now(); let current_time = chrono::offset::Local::now();
let log_file = OpenOptions::new() let log_file = OpenOptions::new()
.write(true) .write(true)
@ -273,19 +238,55 @@ impl ProcessManager<'_> {
.get(&(current_platform, target_platform)) .get(&(current_platform, target_platform))
.ok_or(ProcessError::InvalidPlatform)?; .ok_or(ProcessError::InvalidPlatform)?;
let launch_process = game_launcher let (launch, args) = match game_status {
.launch_process( GameDownloadStatus::Installed {
&meta, version_name: _,
command.to_string_lossy().to_string(), install_dir: _,
game_version, } => (&game_version.launch_command, &game_version.launch_args),
target_current_dir, GameDownloadStatus::SetupRequired {
log_file, version_name: _,
error_file, install_dir: _,
) } => (&game_version.setup_command, &game_version.setup_args),
.map_err(ProcessError::IOError)?; GameDownloadStatus::Remote {} => unreachable!("nuh uh"),
};
let launch = PathBuf::from_str(&install_dir).unwrap().join(launch);
let launch = launch.to_str().unwrap();
let launch_string = game_launcher.create_launch_process(
&meta,
launch.to_string(),
args.to_vec(),
game_version,
install_dir,
);
let launch_string = SimpleCurlyFormat
.format(&game_version.launch_command_template, &[launch_string])
.map_err(|e| ProcessError::FormatError(e.to_string()))?
.to_string();
#[cfg(target_os = "windows")]
let mut command = Command::new("cmd");
#[cfg(target_os = "windows")]
command.args(["/C", &launch_string]);
info!("launching (in {}): {}", install_dir, launch_string,);
#[cfg(unix)]
let mut command: Command = Command::new("sh");
#[cfg(unix)]
command.arg("-c").arg(launch_string);
command
.stderr(error_file)
.stdout(log_file)
.current_dir(install_dir);
let child = command.spawn().map_err(ProcessError::IOError)?;
let launch_process_handle = let launch_process_handle =
Arc::new(SharedChild::new(launch_process).map_err(ProcessError::IOError)?); Arc::new(SharedChild::new(child).map_err(ProcessError::IOError)?);
db_lock db_lock
.applications .applications
@ -295,6 +296,7 @@ impl ProcessManager<'_> {
push_game_update( push_game_update(
&self.app_handle, &self.app_handle,
&meta.id, &meta.id,
None,
(None, Some(ApplicationTransientStatus::Running {})), (None, Some(ApplicationTransientStatus::Running {})),
); );
@ -326,62 +328,58 @@ impl ProcessManager<'_> {
pub enum Platform { pub enum Platform {
Windows, Windows,
Linux, Linux,
macOS,
} }
pub trait ProcessHandler: Send + 'static { pub trait ProcessHandler: Send + 'static {
fn launch_process( fn create_launch_process(
&self, &self,
meta: &DownloadableMetadata, meta: &DownloadableMetadata,
launch_command: String, launch_command: String,
args: Vec<String>,
game_version: &GameVersion, game_version: &GameVersion,
current_dir: &str, current_dir: &str,
log_file: File, ) -> String;
error_file: File,
) -> Result<Child, Error>;
} }
struct NativeGameLauncher; struct NativeGameLauncher;
impl ProcessHandler for NativeGameLauncher { impl ProcessHandler for NativeGameLauncher {
fn launch_process( fn create_launch_process(
&self, &self,
_meta: &DownloadableMetadata, _meta: &DownloadableMetadata,
launch_command: String, launch_command: String,
args: Vec<String>,
game_version: &GameVersion, game_version: &GameVersion,
current_dir: &str, current_dir: &str,
log_file: File, ) -> String {
error_file: File, format!("\"{}\" {}", launch_command, args.join(" "))
) -> Result<Child, Error> {
Command::new(PathBuf::from(launch_command))
.current_dir(current_dir)
.stdout(log_file)
.stderr(error_file)
.args(game_version.launch_args.clone())
.spawn()
} }
} }
const UMU_LAUNCHER_EXECUTABLE: &str = "umu-run"; const UMU_LAUNCHER_EXECUTABLE: &str = "umu-run";
struct UMULauncher; struct UMULauncher;
impl ProcessHandler for UMULauncher { impl ProcessHandler for UMULauncher {
fn launch_process( fn create_launch_process(
&self, &self,
_meta: &DownloadableMetadata, _meta: &DownloadableMetadata,
launch_command: String, launch_command: String,
args: Vec<String>,
game_version: &GameVersion, game_version: &GameVersion,
_current_dir: &str, _current_dir: &str,
_log_file: File, ) -> String {
_error_file: File, debug!("Game override: \"{:?}\"", &game_version.umu_id_override);
) -> Result<Child, Error> {
println!("Game override: .{:?}.", &game_version.umu_id_override);
let game_id = match &game_version.umu_id_override { let game_id = match &game_version.umu_id_override {
Some(game_override) => game_override.is_empty().then_some(game_version.game_id.clone()).unwrap_or(game_override.clone()) , Some(game_override) => game_override
None => game_version.game_id.clone() .is_empty()
.then_some(game_version.game_id.clone())
.unwrap_or(game_override.clone()),
None => game_version.game_id.clone(),
}; };
info!("Game ID: {}", game_id); format!(
UmuCommandBuilder::new(UMU_LAUNCHER_EXECUTABLE, launch_command) "GAMEID={game_id} {umu} \"{launch}\" {args}",
.game_id(game_id) umu = UMU_LAUNCHER_EXECUTABLE,
.launch_args(game_version.launch_args.clone()) launch = launch_command,
.build() args = args.join(" ")
.spawn() )
} }
} }

View File

@ -1,9 +1,11 @@
use std::{env, sync::Mutex}; use std::{env, sync::Mutex};
use chrono::Utc; use chrono::Utc;
use droplet_rs::ssl::sign_nonce;
use gethostname::gethostname;
use log::{debug, error, warn}; use log::{debug, error, warn};
use openssl::{ec::EcKey, hash::MessageDigest, pkey::PKey, sign::Signer};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use url::Url; use url::Url;
@ -15,7 +17,10 @@ use crate::{
AppState, AppStatus, User, DB, AppState, AppStatus, User, DB,
}; };
use super::requests::make_request; use super::{
cache::{cache_object, get_cached_object},
requests::make_request,
};
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -39,20 +44,6 @@ struct HandshakeResponse {
id: String, id: String,
} }
// TODO: Change return value on Err
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 { pub fn generate_authorization_header() -> String {
let certs = { let certs = {
let db = borrow_db_checked(); let db = borrow_db_checked();
@ -123,16 +114,51 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc
private: response_struct.private, private: response_struct.private,
cert: response_struct.certificate, cert: response_struct.certificate,
client_id: response_struct.id, client_id: response_struct.id,
web_token: None, // gets created later
}); });
drop(handle); drop(handle);
save_db(); save_db();
} }
let web_token = {
let header = generate_authorization_header();
let token = client
.post(base_url.join("/api/v1/client/user/webtoken").unwrap())
.header("Authorization", header)
.send()
.unwrap();
token.text().unwrap()
};
let mut handle = borrow_db_mut_checked();
let mut_auth = handle.auth.as_mut().unwrap();
mut_auth.web_token = Some(web_token);
drop(handle);
save_db();
{ {
let app_state = app.state::<Mutex<AppState>>(); let app_state = app.state::<Mutex<AppState>>();
let mut app_state_handle = app_state.lock().unwrap(); let mut app_state_handle = app_state.lock().unwrap();
app_state_handle.status = AppStatus::SignedIn; app_state_handle.status = AppStatus::SignedIn;
app_state_handle.user = Some(fetch_user()?); app_state_handle.user = Some(fetch_user()?);
// Setup capabilities
let endpoint = base_url.join("/api/v1/client/capability")?;
let header = generate_authorization_header();
let body = json!({
"capability": "cloudSaves",
"configuration": {}
});
let response = client
.post(endpoint)
.header("Authorization", header)
.json(&body)
.send()?;
if response.status().is_success() {
debug!("registered client for 'cloudSaves' capability")
}
} }
Ok(()) Ok(())
@ -158,9 +184,11 @@ pub fn auth_initiate_logic() -> Result<(), RemoteAccessError> {
Url::parse(&db_lock.base_url.clone())? Url::parse(&db_lock.base_url.clone())?
}; };
let hostname = gethostname();
let endpoint = base_url.join("/api/v1/client/auth/initiate")?; let endpoint = base_url.join("/api/v1/client/auth/initiate")?;
let body = InitiateRequestBody { let body = InitiateRequestBody {
name: "Drop Desktop Client".to_string(), name: format!("{} (Desktop)", hostname.into_string().unwrap()),
platform: env::consts::OS.to_string(), platform: env::consts::OS.to_string(),
}; };
@ -191,9 +219,13 @@ pub fn setup() -> (AppStatus, Option<User>) {
if auth.is_some() { if auth.is_some() {
let user_result = match fetch_user() { let user_result = match fetch_user() {
Ok(data) => data, Ok(data) => data,
Err(RemoteAccessError::FetchError(_)) => return (AppStatus::ServerUnavailable, None), Err(RemoteAccessError::FetchError(_)) => {
let user = get_cached_object::<String, User>("user".to_owned()).unwrap();
return (AppStatus::Offline, Some(user));
}
Err(_) => return (AppStatus::SignedInNeedsReauth, None), Err(_) => return (AppStatus::SignedInNeedsReauth, None),
}; };
cache_object("user", &user_result).unwrap();
return (AppStatus::SignedIn, Some(user_result)); return (AppStatus::SignedIn, Some(user_result));
} }

View File

@ -0,0 +1,71 @@
use std::sync::RwLockReadGuard;
use crate::{
database::db::{borrow_db_checked, Database},
error::remote_access_error::RemoteAccessError,
};
use cacache::Integrity;
use http::{header::CONTENT_TYPE, response::Builder as ResponseBuilder, Response};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_binary::binary_stream::Endian;
#[macro_export]
macro_rules! offline {
($var:expr, $func1:expr, $func2:expr, $( $arg:expr ),* ) => {
if crate::borrow_db_checked().settings.force_offline || $var.lock().unwrap().status == crate::AppStatus::Offline {
$func2( $( $arg ), *)
} else {
$func1( $( $arg ), *)
}
}
}
pub fn cache_object<'a, K: AsRef<str>, D: Serialize + DeserializeOwned>(
key: K,
data: &D,
) -> Result<Integrity, RemoteAccessError> {
let bytes = serde_binary::to_vec(data, Endian::Little).unwrap();
cacache::write_sync(&borrow_db_checked().cache_dir, key, bytes)
.map_err(|e| RemoteAccessError::Cache(e))
}
pub fn get_cached_object<'a, K: AsRef<str>, D: Serialize + DeserializeOwned>(
key: K,
) -> Result<D, RemoteAccessError> {
get_cached_object_db::<K, D>(key, &borrow_db_checked())
}
pub fn get_cached_object_db<'a, K: AsRef<str>, D: Serialize + DeserializeOwned>(
key: K,
db: &Database,
) -> Result<D, RemoteAccessError> {
let bytes = cacache::read_sync(&db.cache_dir, key).map_err(|e| RemoteAccessError::Cache(e))?;
let data = serde_binary::from_slice::<D>(&bytes, Endian::Little).unwrap();
Ok(data)
}
#[derive(Serialize, Deserialize)]
pub struct ObjectCache {
content_type: String,
body: Vec<u8>,
}
impl From<Response<Vec<u8>>> for ObjectCache {
fn from(value: Response<Vec<u8>>) -> Self {
ObjectCache {
content_type: value
.headers()
.get(CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap()
.to_owned(),
body: value.body().clone(),
}
}
}
impl From<ObjectCache> for Response<Vec<u8>> {
fn from(value: ObjectCache) -> Self {
let resp_builder = ResponseBuilder::new().header(CONTENT_TYPE, value.content_type);
resp_builder.body(value.body).unwrap()
}
}

View File

@ -1,17 +1,16 @@
use std::sync::Mutex; use std::sync::Mutex;
use log::debug;
use reqwest::blocking::Client;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use url::Url; use url::Url;
use crate::{ use crate::{
database::db::{borrow_db_checked, borrow_db_mut_checked, save_db}, database::db::{borrow_db_checked, borrow_db_mut_checked, save_db}, error::remote_access_error::RemoteAccessError, remote::{auth::generate_authorization_header, requests::make_request}, AppState, AppStatus
error::remote_access_error::RemoteAccessError,
AppState, AppStatus,
}; };
use super::{ use super::{
auth::{auth_initiate_logic, recieve_handshake, setup}, auth::{auth_initiate_logic, recieve_handshake, setup}, cache::{cache_object, get_cached_object}, remote::use_remote_logic
remote::use_remote_logic,
}; };
#[tauri::command] #[tauri::command]
@ -35,6 +34,28 @@ pub fn gen_drop_url(path: String) -> Result<String, RemoteAccessError> {
Ok(url.to_string()) Ok(url.to_string())
} }
#[tauri::command]
pub fn fetch_drop_object(path: String) -> Result<Vec<u8>, RemoteAccessError> {
let drop_url = gen_drop_url(path.clone());
let req = make_request(
&Client::new(),
&[&path],
&[],
|r| { r.header("Authorization", generate_authorization_header()) }
)?.send();
match req {
Ok(data) => {
let data = data.bytes()?.to_vec();
cache_object(&path, &data)?;
Ok(data)
},
Err(e) => {
debug!("{}", e);
get_cached_object::<&str, Vec<u8>>(&path)
},
}
}
#[tauri::command] #[tauri::command]
pub fn sign_out(app: AppHandle) { pub fn sign_out(app: AppHandle) {
// Clear auth from database // Clear auth from database

View File

@ -0,0 +1,55 @@
use http::{header::CONTENT_TYPE, response::Builder as ResponseBuilder};
use log::warn;
use tauri::UriSchemeResponder;
use super::{auth::generate_authorization_header, cache::{cache_object, get_cached_object, ObjectCache}, requests::make_request};
pub fn fetch_object(
request: http::Request<Vec<u8>>,
responder: UriSchemeResponder,
) {
// Drop leading /
let object_id = &request.uri().path()[1..];
let header = generate_authorization_header();
let client: reqwest::blocking::Client = reqwest::blocking::Client::new();
let response = make_request(&client, &["/api/v1/client/object/", object_id], &[], |f| {
f.header("Authorization", header)
})
.unwrap()
.send();
if response.is_err() {
let data = get_cached_object::<&str, ObjectCache>(object_id);
match data {
Ok(data) => responder.respond(data.into()),
Err(e) => {
warn!("{}", e)
},
}
return;
}
let response = response.unwrap();
let resp_builder = ResponseBuilder::new().header(
CONTENT_TYPE,
response.headers().get("Content-Type").unwrap(),
);
let data = Vec::from(response.bytes().unwrap());
let resp = resp_builder.body(data).unwrap();
cache_object::<&str, ObjectCache>(object_id, &resp.clone().into()).unwrap();
responder.respond(resp);
}
pub fn fetch_object_offline(
request: http::Request<Vec<u8>>,
responder: UriSchemeResponder,
) {
let object_id = &request.uri().path()[1..];
let data = get_cached_object::<&str, ObjectCache>(object_id);
match data {
Ok(data) => responder.respond(data.into()),
Err(e) => warn!("{}", e),
}
}

View File

@ -1,4 +1,8 @@
pub mod auth; pub mod auth;
#[macro_use]
pub mod cache;
pub mod commands; pub mod commands;
pub mod fetch_object;
pub mod remote; pub mod remote;
pub mod requests; pub mod requests;
pub mod server_proto;

View File

@ -0,0 +1,66 @@
use std::{path::PathBuf, str::FromStr};
use http::{
uri::{Authority, PathAndQuery},
Request, Response, StatusCode, Uri,
};
use log::info;
use reqwest::blocking::Client;
use tauri::UriSchemeResponder;
use crate::database::db::borrow_db_checked;
pub fn handle_server_proto_offline(_request: Request<Vec<u8>>, responder: UriSchemeResponder) {
let four_oh_four = Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Vec::new())
.unwrap();
responder.respond(four_oh_four);
}
pub fn handle_server_proto(request: Request<Vec<u8>>, responder: UriSchemeResponder) {
let db_handle = borrow_db_checked();
let web_token = match &db_handle.auth.as_ref().unwrap().web_token {
Some(e) => e,
None => return,
};
let remote_uri = db_handle.base_url.parse::<Uri>().unwrap();
let path = request.uri().path();
let mut new_uri = request.uri().clone().into_parts();
new_uri.path_and_query =
Some(PathAndQuery::from_str(&format!("{}?noWrapper=true", path)).unwrap());
new_uri.authority = remote_uri.authority().cloned();
new_uri.scheme = remote_uri.scheme().cloned();
let new_uri = Uri::from_parts(new_uri).unwrap();
let whitelist_prefix = vec!["/store", "/api", "/_", "/fonts"];
if whitelist_prefix
.iter()
.map(|f| !path.starts_with(f))
.all(|f| f)
{
webbrowser::open(&new_uri.to_string()).unwrap();
return;
}
let client = Client::new();
let response = client
.request(request.method().clone(), new_uri.to_string())
.header("Authorization", format!("Bearer {}", web_token))
.headers(request.headers().clone())
.send()
.unwrap();
let response_status = response.status();
let response_body = response.bytes().unwrap();
let http_response = Response::builder()
.status(response_status)
.body(response_body.to_vec())
.unwrap();
responder.respond(http_response);
}

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2.0.0", "$schema": "https://schema.tauri.app/config/2.0.0",
"productName": "Drop Desktop Client", "productName": "Drop Desktop Client",
"version": "0.2.1-beta", "version": "0.3.0-rc-1",
"identifier": "dev.drop.app", "identifier": "dev.drop.app",
"build": { "build": {
"beforeDevCommand": "yarn dev --port 1432", "beforeDevCommand": "yarn dev --port 1432",

View File

@ -34,10 +34,16 @@ export type Game = {
mBannerId: string; mBannerId: string;
mCoverId: string; mCoverId: string;
mImageLibrary: string[]; mImageLibrary: string[];
mImageCarousel: string[];
};
export type GameVersion = {
launchCommandTemplate: string;
}; };
export enum AppStatus { export enum AppStatus {
NotConfigured = "NotConfigured", NotConfigured = "NotConfigured",
Offline = "Offline",
SignedOut = "SignedOut", SignedOut = "SignedOut",
SignedIn = "SignedIn", SignedIn = "SignedIn",
SignedInNeedsReauth = "SignedInNeedsReauth", SignedInNeedsReauth = "SignedInNeedsReauth",
@ -52,7 +58,7 @@ export enum GameStatusEnum {
Updating = "Updating", Updating = "Updating",
Uninstalling = "Uninstalling", Uninstalling = "Uninstalling",
SetupRequired = "SetupRequired", SetupRequired = "SetupRequired",
Running = "Running" Running = "Running",
} }
export type GameStatus = { export type GameStatus = {
@ -64,16 +70,17 @@ export enum DownloadableType {
Game = "Game", Game = "Game",
Tool = "Tool", Tool = "Tool",
DLC = "DLC", DLC = "DLC",
Mod = "Mod" Mod = "Mod",
} }
export type DownloadableMetadata = { export type DownloadableMetadata = {
id: string, id: string;
version: string, version: string;
downloadType: DownloadableType downloadType: DownloadableType;
} };
export type Settings = { export type Settings = {
autostart: boolean, autostart: boolean;
maxDownloadThreads: number, maxDownloadThreads: number;
} forceOffline: boolean;
};

259
yarn.lock
View File

@ -1399,10 +1399,10 @@
dependencies: dependencies:
"@tauri-apps/api" "^2.0.0" "@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-shell@>=2.0.0": "@tauri-apps/plugin-shell@^2.2.1":
version "2.0.0" version "2.2.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-shell/-/plugin-shell-2.0.0.tgz#b6fc88ab070fd5f620e46405715779aa44eb8428" resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-shell/-/plugin-shell-2.2.1.tgz#586ab725ef622ba65a946bff1a3e166cee181903"
integrity sha512-OpW2+ycgJLrEoZityWeWYk+6ZWP9VyiAfbO+N/O8VfLkqyOym8kXh7odKDfINx9RAotkSGBtQM4abyKfJDkcUg== integrity sha512-G1GFYyWe/KlCsymuLiNImUgC8zGY0tI0Y3p8JgBCWduR5IEXlIJS+JuG1qtveitwYXlfJrsExt3enhv5l2/yhA==
dependencies: dependencies:
"@tauri-apps/api" "^2.0.0" "@tauri-apps/api" "^2.0.0"
@ -1411,6 +1411,13 @@
resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad"
integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==
"@types/debug@^4.0.0":
version "4.1.12"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917"
integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==
dependencies:
"@types/ms" "*"
"@types/estree@*", "@types/estree@1.0.6", "@types/estree@^1.0.0": "@types/estree@*", "@types/estree@1.0.6", "@types/estree@^1.0.0":
version "1.0.6" version "1.0.6"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50"
@ -1441,6 +1448,11 @@
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
"@types/ms@*":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78"
integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
"@types/node@*": "@types/node@*":
version "22.7.4" version "22.7.4"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.4.tgz#e35d6f48dca3255ce44256ddc05dee1c23353fcc" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.4.tgz#e35d6f48dca3255ce44256ddc05dee1c23353fcc"
@ -2068,6 +2080,11 @@ chalk@^5.3.0:
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385"
integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==
character-entities@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22"
integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==
chokidar@^3.5.1, chokidar@^3.5.3, chokidar@^3.6.0: chokidar@^3.5.1, chokidar@^3.5.3, chokidar@^3.6.0:
version "3.6.0" version "3.6.0"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
@ -2458,13 +2475,20 @@ debug@^3.1.0, debug@^3.2.7:
dependencies: dependencies:
ms "^2.1.1" ms "^2.1.1"
debug@^4.3.2: debug@^4.0.0, debug@^4.3.2:
version "4.4.0" version "4.4.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a"
integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==
dependencies: dependencies:
ms "^2.1.3" ms "^2.1.3"
decode-named-character-reference@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e"
integrity sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==
dependencies:
character-entities "^2.0.0"
deep-equal@~1.0.1: deep-equal@~1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
@ -2523,6 +2547,11 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
dequal@^2.0.0:
version "2.0.3"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
destr@^2.0.3: destr@^2.0.3:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.3.tgz#7f9e97cb3d16dbdca7be52aca1644ce402cfe449" resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.3.tgz#7f9e97cb3d16dbdca7be52aca1644ce402cfe449"
@ -2548,6 +2577,13 @@ devalue@^5.0.0:
resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.1.1.tgz#a71887ac0f354652851752654e4bd435a53891ae" resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.1.1.tgz#a71887ac0f354652851752654e4bd435a53891ae"
integrity sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw== integrity sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==
devlop@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018"
integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==
dependencies:
dequal "^2.0.0"
didyoumean@^1.2.2: didyoumean@^1.2.2:
version "1.2.2" version "1.2.2"
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
@ -3604,6 +3640,35 @@ koa@^2.14.2:
type-is "^1.6.16" type-is "^1.6.16"
vary "^1.1.2" vary "^1.1.2"
koa@^2.16.1:
version "2.16.1"
resolved "https://registry.yarnpkg.com/koa/-/koa-2.16.1.tgz#ba1aae04d8319d7dac4a17a0d289d7482501e194"
integrity sha512-umfX9d3iuSxTQP4pnzLOz0HKnPg0FaUUIKcye2lOiz3KPu1Y3M3xlz76dISdFPQs37P9eJz1wUpcTS6KDPn9fA==
dependencies:
accepts "^1.3.5"
cache-content-type "^1.0.0"
content-disposition "~0.5.2"
content-type "^1.0.4"
cookies "~0.9.0"
debug "^4.3.2"
delegates "^1.0.0"
depd "^2.0.0"
destroy "^1.0.4"
encodeurl "^1.0.2"
escape-html "^1.0.3"
fresh "~0.5.2"
http-assert "^1.3.0"
http-errors "^1.6.3"
is-generator-function "^1.0.7"
koa-compose "^4.1.0"
koa-convert "^2.0.0"
on-finished "^2.3.0"
only "~0.0.2"
parseurl "^1.3.2"
statuses "^1.5.0"
type-is "^1.6.16"
vary "^1.1.2"
kolorist@^1.8.0: kolorist@^1.8.0:
version "1.8.0" version "1.8.0"
resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c"
@ -3812,6 +3877,190 @@ methods@^1.1.2:
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
micromark-core-commonmark@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.2.tgz#6a45bbb139e126b3f8b361a10711ccc7c6e15e93"
integrity sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==
dependencies:
decode-named-character-reference "^1.0.0"
devlop "^1.0.0"
micromark-factory-destination "^2.0.0"
micromark-factory-label "^2.0.0"
micromark-factory-space "^2.0.0"
micromark-factory-title "^2.0.0"
micromark-factory-whitespace "^2.0.0"
micromark-util-character "^2.0.0"
micromark-util-chunked "^2.0.0"
micromark-util-classify-character "^2.0.0"
micromark-util-html-tag-name "^2.0.0"
micromark-util-normalize-identifier "^2.0.0"
micromark-util-resolve-all "^2.0.0"
micromark-util-subtokenize "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-factory-destination@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639"
integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==
dependencies:
micromark-util-character "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-factory-label@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1"
integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==
dependencies:
devlop "^1.0.0"
micromark-util-character "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-factory-space@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc"
integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==
dependencies:
micromark-util-character "^2.0.0"
micromark-util-types "^2.0.0"
micromark-factory-title@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94"
integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==
dependencies:
micromark-factory-space "^2.0.0"
micromark-util-character "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-factory-whitespace@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1"
integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==
dependencies:
micromark-factory-space "^2.0.0"
micromark-util-character "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-util-character@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6"
integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==
dependencies:
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-util-chunked@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051"
integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==
dependencies:
micromark-util-symbol "^2.0.0"
micromark-util-classify-character@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629"
integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==
dependencies:
micromark-util-character "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-util-combine-extensions@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9"
integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==
dependencies:
micromark-util-chunked "^2.0.0"
micromark-util-types "^2.0.0"
micromark-util-decode-numeric-character-reference@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5"
integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==
dependencies:
micromark-util-symbol "^2.0.0"
micromark-util-encode@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8"
integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==
micromark-util-html-tag-name@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825"
integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==
micromark-util-normalize-identifier@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d"
integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==
dependencies:
micromark-util-symbol "^2.0.0"
micromark-util-resolve-all@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b"
integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==
dependencies:
micromark-util-types "^2.0.0"
micromark-util-sanitize-uri@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7"
integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==
dependencies:
micromark-util-character "^2.0.0"
micromark-util-encode "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-subtokenize@^2.0.0:
version "2.0.4"
resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.4.tgz#50d8ca981373c717f497dc64a0dbfccce6c03ed2"
integrity sha512-N6hXjrin2GTJDe3MVjf5FuXpm12PGm80BrUAeub9XFXca8JZbP+oIwY4LJSVwFUCL1IPm/WwSVUN7goFHmSGGQ==
dependencies:
devlop "^1.0.0"
micromark-util-chunked "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-util-symbol@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8"
integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==
micromark-util-types@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.1.tgz#a3edfda3022c6c6b55bfb049ef5b75d70af50709"
integrity sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==
micromark@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.1.tgz#294c2f12364759e5f9e925a767ae3dfde72223ff"
integrity sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw==
dependencies:
"@types/debug" "^4.0.0"
debug "^4.0.0"
decode-named-character-reference "^1.0.0"
devlop "^1.0.0"
micromark-core-commonmark "^2.0.0"
micromark-factory-space "^2.0.0"
micromark-util-character "^2.0.0"
micromark-util-chunked "^2.0.0"
micromark-util-combine-extensions "^2.0.0"
micromark-util-decode-numeric-character-reference "^2.0.0"
micromark-util-encode "^2.0.0"
micromark-util-normalize-identifier "^2.0.0"
micromark-util-resolve-all "^2.0.0"
micromark-util-sanitize-uri "^2.0.0"
micromark-util-subtokenize "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5, micromatch@^4.0.8: micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5, micromatch@^4.0.8:
version "4.0.8" version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"