Compare commits

...

229 Commits

Author SHA1 Message Date
8e08f3b7e7 chore: Revert Clients to using static LazyLock
Signed-off-by: quexeky <git@quexeky.dev>
2025-10-13 09:26:22 +11:00
f0e46c4a46 refactor: Convert some things from static to const and refactor into drop-consts
Signed-off-by: quexeky <git@quexeky.dev>
2025-10-13 09:07:24 +11:00
ef9f8caa54 Squashed commit of the following:
commit 3b09dcfb73
Author: quexeky <git@quexeky.dev>
Date:   Mon Oct 13 08:10:52 2025 +1100

    fix: #159

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

commit 2859a59622
Author: quexeky <git@quexeky.dev>
Date:   Mon Oct 13 08:03:49 2025 +1100

    Squashed commit of the following:

    commit 0f48f3fb44
    Author: quexeky <git@quexeky.dev>
    Date:   Sun Oct 12 19:35:04 2025 +1100

        chore: Run cargo clippy && cargo fmt

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

    commit 974666efe2
    Author: quexeky <git@quexeky.dev>
    Date:   Sun Oct 12 19:17:40 2025 +1100

        refactor: Finish refactor

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

    commit 9e1bf9852f
    Author: quexeky <git@quexeky.dev>
    Date:   Sun Oct 12 18:33:43 2025 +1100

        refactor: Builds, but some logic still left to move back

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

    commit 5d22b883d5
    Author: quexeky <git@quexeky.dev>
    Date:   Sun Oct 12 17:04:27 2025 +1100

        refactor: Improvements to src-tauri

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

    commit 62a2561539
    Author: quexeky <git@quexeky.dev>
    Date:   Sat Oct 11 09:51:04 2025 +1100

        fix: Remote tauri dependency from process

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

    commit 59f040bc8b
    Author: quexeky <git@quexeky.dev>
    Date:   Thu Oct 9 07:46:17 2025 +1100

        chore: Major refactoring

        Still needs a massive go-over because there shouldn't be anything referencing tauri in any of the workspaces except the original one. Process manager has been refactored as an example

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

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

Signed-off-by: quexeky <git@quexeky.dev>
2025-10-13 08:21:27 +11:00
cc57ca7076 139 add and resolve clippy lints to prevent unwrap and expect functions (#154)
* fix: Add lint and remove all unwraps from lib.rs

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

* chore: Remove all unwraps from util.rs and add state_lock macro

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

* chore: Add CacheError and remove unwraps from fetch_object

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

* chore: Remove unwraps from fetch_object and server_proto

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

* chore: Remove unwraps from auth.rs

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

* chore: Remove unwraps from process_handlers

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

* chore: Clippy unwrap linting

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

* chore: Remove lint

Because not everything is actually resolved yet: will be resolved with a restructure of the library

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

* chore: Make the rest of clippy happy

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

* fix: Send download signal instead of triggering self.on_error

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

* fix: Corrupted state should panic

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

* fix: Use debug instead of display for specific errors

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

* fix: Settings now log error instead of panicking

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

---------

Signed-off-by: quexeky <git@quexeky.dev>
2025-10-08 16:17:24 +11:00
70cecdad19 Update README.md 2025-09-11 08:16:33 +10:00
3f18d15d39 Collections & download stability, UI (#130)
* feat: different local path in dev #73

* feat: better error output for downloads

* feat: collections in library view

* feat: improve download manager reliability

* feat: new download UI, more stable downloads

* fix: clippy

* fix: only show admin link if user is admin

* feat: check for libs before building
2025-09-07 15:57:06 +10:00
97b5cd5e78 Native model fixes (#137)
* fix: Fix native_model from requirements and  add version requirements for models

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

* fix: Use Drop-OSS/native_model

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

* chore: Bump version to include logging

(Albeit, logging occurs before we initialise the logger, but oh well)

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

* chore: Make clippy happy

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

---------

Signed-off-by: quexeky <git@quexeky.dev>
2025-09-05 15:31:28 +10:00
7e70a17a43 Bump version to v0.3.3 2025-08-28 18:23:12 +10:00
8d61a68b8a Add placeholders to unfinished pages (#126)
* feat: add placeholders for community & news pages

* feat: add placeholder to interface in settings menu
2025-08-28 18:22:33 +10:00
44a1be6991 Fix for multi-version downloads (#125)
* fix: multi version downloads

* fix: remove debug utils

* fix: clippy
2025-08-28 18:05:05 +10:00
4f5fccf0c1 Add umu-run discovery (#122)
Signed-off-by: quexeky <git@quexeky.dev>
2025-08-28 18:05:05 +10:00
5eef2bf60f Fix Tauri builds (#119)
* fix: attempt to use local user keychain

* chore: tmp disable non-macos builds

* fix: windows process fix patch

* fix: re-enable windows

* fix: remove sudo

* fix: tmp disable windows again

* fix: windows build again

* chore: re-disable windows

* fix: pin to macos 14

* fix: re-enable other builds
2025-08-15 23:55:34 +10:00
ec6294b8e7 Fix #117 (#118)
* fix: version data not being attached to process manager push

* fix: clippy
2025-08-15 23:02:01 +10:00
17c375bcab UI & error fixes & QoL (#116)
* fix: use Arc<Error> instead of just ErrorKind

* fix: game status updates for UI

* fix: missing game version on push_game_update calls

* feat: wait if library load takes <300ms

* fix: clippy
2025-08-15 22:56:49 +10:00
cb55ac2bf5 Fix platform builds 2025-08-12 15:08:50 +10:00
e11db851a5 fix: #92 (#115) 2025-08-11 14:37:46 +10:00
16365713cf v2 download API and fixes (#112)
* fix: potential download fixes

* fix: show installed games not on remote

* fix: more download_logic error handling

* partial: move to async

* feat: interactivity improvements

* feat: v2 download API

* fix: download seek offsets

* fix: clippy

* fix: apply clippy suggestion

* fix: performance improvements starting up download

* fix: finished bucket file

* fix: ui tweaks and fixes

* fix: revert version to 0.3.2

* fix: clippy
2025-08-09 15:50:21 +10:00
3b830e2a44 Move frontend to main folder (#109)
* feat: small refactor

* fix: appimage build script

* fix: add NO_STRIP to AppImage build

* fix: build and dev mode from refactor

* fix: submodule step 1

* fix: submodules step 2
2025-08-05 16:09:47 +10:00
75a4b73ee1 QoL Download Manager (#108)
* feat: retry specific download errors

* fix: potential fix for cmd window on Windows

* feat: add disk space check for download

* fix: update game fix formatting

* fix: clippy
2025-08-04 16:30:45 +10:00
339d707092 Fix errors with caching when cache is deleted (#101) 2025-08-04 15:02:32 +10:00
776dc8fe7a Fixes reqwest client setup, #87 (#107) 2025-08-04 15:01:44 +10:00
dbe8c8df4d Process manager templating & game importing (#96)
* feat: add new template options, asahi support, and refactoring

* feat: install dir scanning, validation fixes, progress fixes, download manager refactor

This kind of ballooned out of scope, but I implemented some much
needed fixes for the download manager.

First off, I cleanup the Downloadable trait, there was some
duplication of function.

Second, I refactored the "validate" into the GameDownloadAgent,
which calls a 'validate_chunk_logic' yada, same structure as
downloading.

Third, I fixed the progress and validation issues.

Fourth, I added game scanning

* feat: out of box support for Asahi Linux

* fix: clippy

* fix: don't break database
2025-08-02 20:17:27 +10:00
35f49b8811 macOS app signing (#95)
* feat: add macos signing args

* fix: update all versions to -mac specific

* fix: fetch signing identity

* feat: add signing pre-steps like the docs say

* fix: remove apple requirement from signing

* fix: add drop cert to keychain when signing

* fix: add drop.pem to add-trusted-cert

* fix: re-order and specify import operation

* fix: let's try the user store

* fix: password required to update trust

* fix: try another non-interactive fix

* fix: try sudo

* fix: revert attempt fix

* fix: add cert id debug

* fix: attempt to use id rather than name

* fix: revert code id to name
2025-08-02 15:01:53 +10:00
cc5339a389 Reqwest optionally load certificates from disk (#94)
* feat: Add get_client function

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

* chore: Converted all instances of reqwest::blocking::Client::new() and reqwest::Client::new() to DROP_CLIENT_SYNC and DROP_CLIENT_ASYNC respectively

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

* fix: use_remote_logic not using certificates

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

* fix: add log statement to certificates

* chore: add more logging

* fix: clippy

* refactor: into single fetch_certificates func

---------

Signed-off-by: quexeky <git@quexeky.dev>
Co-authored-by: quexeky <git@quexeky.dev>
2025-08-02 11:59:50 +10:00
6104bfda72 Bump version to v0.3.1 (#85) 2025-08-01 14:13:13 +10:00
be688cb18f Version bump: v0.3.0 2025-08-01 14:09:16 +10:00
13cc69f10e Device code authorization (#83)
* feat: device code authorization

* Fix for setup executable unable to be launched (#81)

* Fix for redownload invalid chunks (#84)

* feat: Redownloading invalid chunks

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

* fix: clippy

* fix: clippy x2

---------

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

* chore: Run clippy fix pedantic

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

* feat: add better error handling

* fix: clippy

---------

Signed-off-by: quexeky <git@quexeky.dev>
Co-authored-by: quexeky <git@quexeky.dev>
2025-08-01 13:12:05 +10:00
574782f445 chore: Run clippy fix pedantic
Signed-off-by: quexeky <git@quexeky.dev>
2025-08-01 08:42:45 +10:00
b5a8543194 Fix for redownload invalid chunks (#84)
* feat: Redownloading invalid chunks

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

* fix: clippy

* fix: clippy x2

---------

Signed-off-by: quexeky <git@quexeky.dev>
Co-authored-by: quexeky <git@quexeky.dev>
2025-07-31 18:25:38 +10:00
d0e4aea5ce Fix for setup executable unable to be launched (#81) 2025-07-30 09:57:07 +10:00
739e6166c5 Cache-first object fetching (#76)
* fix: submillisecond cache hits

* fix: async object loading to hand control back to renderer

* fix: clippy
2025-07-27 12:04:50 +10:00
682c6e9c0b Bump version to v0.3.0-rc-8 (#74) 2025-07-25 22:21:59 +10:00
46e1f16cdd Process manager fixes (#71)
* fix: launching on linux

* feat: #70

* feat: add dummy store page

* feat: add store redir and refresh button to library

* feat: cache first object fetching

* feat: Remove let_chains feature and update to Rust 2024

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

* feat: Check for if process was manually stopped

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

* fix: use bitcode instead of serde

* chore: remove logs

* fix: clippy

* fix: clippy 2

* fix: swap to stop icon

---------

Signed-off-by: quexeky <git@quexeky.dev>
Co-authored-by: quexeky <git@quexeky.dev>
2025-07-25 10:44:40 +10:00
d19f9bbc31 Fix client running behind reverse proxy (#69)
* fix: reverse proxy 400 due to duplicate header

* fix: clippy

* bump version and update ci
2025-07-18 20:08:12 +10:00
2913fdf35b Release v0.3.0-rc-6 (#68) 2025-07-18 17:38:36 +10:00
f9fdf151ea Clippy CI/CD (#67)
* feat: add clippy ci

* fix: clippy errors

* fix: ci/cd

* fix: update ci packages

* fix: add gtk3 to ci deps

* fix: add webkit to ci deps

* fix: ci deps and perms

* fix: add clippy settings to lib.rs
2025-07-18 17:36:04 +10:00
495d93705e Panic hook to generate crash dumps #65 (#66) 2025-07-18 16:35:02 +10:00
c477dd4872 Fix windows build by removing linux extension import (#64) 2025-07-14 16:43:11 +10:00
f560a62c8f Download fixes (#63)
* refactor: Rename StoredManifest to DropData

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

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

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

* chore: Didn't import debug macro

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

* fix: Download chunks with wrong indexes

Migrated to using checksums as indexes instead

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

* feat: Resume download button

Also added DBWrite and DBRead structs to make database management easier

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

* feat: Download resuming

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

* feat: Resume button and PartiallyInstalled status

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

* feat: Download validation

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

* chore: Ran cargo fix & cargo fmt

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

* fix: download validation, installs, etc

* chore: version bump

---------

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

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

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

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

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

* chore: Progress on amend_settings command

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

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

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

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

* chore(errors): Added drop server error

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

* feat(core): Update to using nightly compiler

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

* chore(errors): More progress on error handling

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

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

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

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

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

* fix(errors): Added missing files

* chore(errors): Convert match statement to map_err

* feat(settings): Implemented settings editing from UI

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

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

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

---------

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

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

* Update settings.vue

* Update debug.vue

* Update lib.rs

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

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

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

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

* Update settings.vue to add tab for debug

* Update main.scss to add light theme

* Update interface.vue to add light mode

* Create debug.vue

* Update debug.vue too add open log button

* Update lib.rs

* Update debug.rs

* Update debug.rs

* Update lib.rs

* Update lib.rs

* Update debug.rs

* Update debug.vue

* fix(debug): refactor and cleanup

* revert(theme): revert light theming

---------

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

* Update auth.rs to add signout command

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

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

    Update lib.rs for the DB sync of autostart

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

    Update db.rs to accomidate the settings sync

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

    Update autostart.rs to include DB

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

    Add files via upload

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

    Delete src-tauri/Cargo.toml

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

    Add files via upload

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

    Delete src-tauri/src/lib.rs

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

    Add files via upload

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

    Add files via upload

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

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

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

23
.github/workflows/clippy.yml vendored Normal file
View File

@ -0,0 +1,23 @@
on: push
name: Clippy check
jobs:
clippy_check:
runs-on: ubuntu-24.04
permissions:
checks: write
steps:
- uses: actions/checkout@v1
- name: install dependencies (ubuntu only)
run: |
sudo apt-get update
sudo apt-get install -y libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly
components: clippy
override: true
- uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --manifest-path ./src-tauri/Cargo.toml

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

@ -0,0 +1,104 @@
name: 'publish'
on:
workflow_dispatch: {}
release:
types: [published]
# This can be used to automatically publish nightlies at UTC nighttime
# schedule:
# - cron: "0 2 * * *" # run at 2 AM UTC
# This workflow will trigger on each push to the `release` branch to create or update a GitHub release, build your app, and upload the artifacts to the release.
jobs:
publish-tauri:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # for Arm based macs (M1 and above).
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest' # for Intel based macs.
args: '--target x86_64-apple-darwin'
- platform: 'ubuntu-22.04' # for Tauri v1 you could replace this with ubuntu-20.04.
args: ''
- platform: 'ubuntu-22.04-arm'
args: '--target aarch64-unknown-linux-gnu'
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
with:
submodules: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm' # This must match the platform value defined above.
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
# webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-latest'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
curl https://droposs.org/drop.crt --output drop.pem
sudo security authorizationdb write com.apple.trust-settings.user allow
security add-trusted-cert -r trustRoot -k build.keychain -p codeSign -u -1 drop.pem
sudo security authorizationdb remove com.apple.trust-settings.user
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security find-identity -v -p codesigning build.keychain
- name: Verify Certificate
if: matrix.platform == 'macos-latest'
run: |
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Drop OSS")
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported. Using identity: $CERT_ID"
- name: install frontend dependencies
run: yarn install # change this to npm, pnpm or bun depending on which one you use.
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
NO_STRIP: true
with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
releaseName: 'Auto-release v__VERSION__'
releaseBody: 'See the assets to download this version and install. This release was created automatically.'
releaseDraft: false
prerelease: true
args: ${{ matrix.args }}

7
.gitignore vendored
View File

@ -26,4 +26,9 @@ dist-ssr
.output
src-tauri/flamegraph.svg
src-tauri/perf*
src-tauri/perf*
/*.AppImage
/squashfs-root
/target/

View File

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

6
.gitmodules vendored Normal file
View File

@ -0,0 +1,6 @@
[submodule "src-tauri/tailscale/libtailscale"]
path = src-tauri/tailscale/libtailscale
url = https://github.com/tailscale/libtailscale.git
[submodule "libs/drop-base"]
path = libs/drop-base
url = https://github.com/drop-oss/drop-base.git

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
23

8303
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

14
Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[workspace]
members = [
"client",
"database",
"src-tauri",
"process",
"remote",
"utils",
"cloud_saves",
"download_manager",
"games", "drop-consts",
]
resolver = "3"

View File

@ -1,16 +1,21 @@
# Drop App
# Drop Desktop Client
Drop app is the companion app for [Drop](https://github.com/Drop-OSS/drop). It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI.
The Drop Desktop Client is the companion app for [Drop](https://github.com/Drop-OSS/drop). It is the official & intended way to download and play games on your Drop server.
## Internals
It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI.
## Development
Before setting up a development environemnt, be sure that you have a server set up. The instructions for this can be found on the [Drop Docs](https://docs.droposs.org/docs/guides/quickstart).
Install dependencies with `yarn`
Then, install dependencies with `yarn`. This'll install the custom builder's dependencies. Then, check everything works properly with `yarn tauri build`.
Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use shell script `./nvidia-prop-dev.sh`
Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use shell script `./nvidia-prop-dev.sh`
To manually specify the logging level, add the environment variable `RUST_LOG=[debug, info, warn, error]` to `yarn tauri dev`:
e.g. `RUST_LOG=debug yarn taudi dev`
e.g. `RUST_LOG=debug yarn tauri dev`
## Contributing
Check the original [Drop repo](https://github.com/Drop-OSS/drop/blob/main/CONTRIBUTING.md) for contributing guidelines.
Check out the contributing guide on our Developer Docs: [Drop Developer Docs - Contributing](https://developer.droposs.org/contributing).

35
app.vue
View File

@ -1,35 +0,0 @@
<template>
<NuxtLayout class="select-none w-screen h-screen">
<NuxtPage />
</NuxtLayout>
<ModalStack />
</template>
<script setup lang="ts">
import "~/composables/queue";
import { invoke } from "@tauri-apps/api/core";
import { AppStatus } from "~/types";
import { listen } from "@tauri-apps/api/event";
import { useAppState } from "./composables/app-state.js";
import {
initialNavigation,
setupHooks,
} from "./composables/state-navigation.js";
const router = useRouter();
const state = useAppState();
state.value = await invoke("fetch_state");
router.beforeEach(async () => {
state.value = await invoke("fetch_state");
});
setupHooks();
initialNavigation(state);
useHead({
title: "Drop",
});
</script>

55
build.mjs Normal file
View File

@ -0,0 +1,55 @@
import fs from "fs";
import process from "process";
import childProcess from "child_process";
import createLogger from "pino";
const OUTPUT = "./.output";
const logger = createLogger({ transport: { target: "pino-pretty" } });
async function spawn(exec, opts) {
const output = childProcess.spawn(exec, { ...opts, shell: true });
output.stdout.on("data", (data) => {
process.stdout.write(data);
});
output.stderr.on("data", (data) => {
process.stderr.write(data);
});
return await new Promise((resolve, reject) => {
output.on("error", (err) => reject(err));
output.on("exit", () => resolve());
});
}
const expectedLibs = ["drop-base/package.json"];
for (const lib of expectedLibs) {
const path = `./libs/${lib}`;
if (!fs.existsSync(path)) throw `Missing "${expectedLibs}". Run "git submodule update --init --recursive"`;
}
const views = fs.readdirSync(".").filter((view) => {
const expectedPath = `./${view}/package.json`;
return fs.existsSync(expectedPath);
});
fs.mkdirSync(OUTPUT, { recursive: true });
for (const view of views) {
const loggerChild = logger.child({});
process.chdir(`./${view}`);
loggerChild.info(`Install deps for "${view}"`);
await spawn("yarn");
loggerChild.info(`Building "${view}"`);
await spawn("yarn build", {
env: { ...process.env, NUXT_APP_BASE_URL: `/${view}/` },
});
process.chdir("..");
fs.cpSync(`./${view}/.output/public`, `${OUTPUT}/${view}`, {
recursive: true,
});
}

View File

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

4862
client/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

12
client/Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "client"
version = "0.1.0"
edition = "2024"
[dependencies]
bitcode = "0.6.7"
database = { version = "0.1.0", path = "../database" }
log = "0.4.28"
serde = { version = "1.0.228", features = ["derive"] }
tauri = "2.8.5"
tauri-plugin-autostart = "2.5.0"

0
client/src/app_state.rs Normal file
View File

12
client/src/app_status.rs Normal file
View File

@ -0,0 +1,12 @@
use serde::Serialize;
#[derive(Clone, Copy, Serialize, Eq, PartialEq)]
pub enum AppStatus {
NotConfigured,
Offline,
ServerError,
SignedOut,
SignedIn,
SignedInNeedsReauth,
ServerUnavailable,
}

26
client/src/autostart.rs Normal file
View File

@ -0,0 +1,26 @@
use database::borrow_db_checked;
use log::debug;
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;
// New function to sync state on startup
pub fn sync_autostart_on_startup(app: &AppHandle) -> Result<(), String> {
let db_handle = borrow_db_checked();
let should_be_enabled = db_handle.settings.autostart;
drop(db_handle);
let manager = app.autolaunch();
let current_state = manager.is_enabled().map_err(|e| e.to_string())?;
if current_state != should_be_enabled {
if should_be_enabled {
manager.enable().map_err(|e| e.to_string())?;
debug!("synced autostart: enabled");
} else {
manager.disable().map_err(|e| e.to_string())?;
debug!("synced autostart: disabled");
}
}
Ok(())
}

49
client/src/compat.rs Normal file
View File

@ -0,0 +1,49 @@
use std::{
cell::LazyCell, ffi::OsStr, path::PathBuf, process::{Command, Stdio}
};
use log::info;
pub const COMPAT_INFO: LazyCell<Option<CompatInfo>> = LazyCell::new(create_new_compat_info);
pub const UMU_LAUNCHER_EXECUTABLE: LazyCell<Option<PathBuf>> = LazyCell::new(|| {
let x = get_umu_executable();
info!("{:?}", &x);
x
});
#[derive(Clone)]
pub struct CompatInfo {
pub umu_installed: bool,
}
fn create_new_compat_info() -> Option<CompatInfo> {
#[cfg(target_os = "windows")]
return None;
let has_umu_installed = UMU_LAUNCHER_EXECUTABLE.is_some();
Some(CompatInfo {
umu_installed: has_umu_installed,
})
}
const UMU_BASE_LAUNCHER_EXECUTABLE: &str = "umu-run";
const UMU_INSTALL_DIRS: [&str; 4] = ["/app/share", "/use/local/share", "/usr/share", "/opt"];
fn get_umu_executable() -> Option<PathBuf> {
if check_executable_exists(UMU_BASE_LAUNCHER_EXECUTABLE) {
return Some(PathBuf::from(UMU_BASE_LAUNCHER_EXECUTABLE));
}
for dir in UMU_INSTALL_DIRS {
let p = PathBuf::from(dir).join(UMU_BASE_LAUNCHER_EXECUTABLE);
if check_executable_exists(&p) {
return Some(p);
}
}
None
}
fn check_executable_exists<P: AsRef<OsStr>>(exec: P) -> bool {
let has_umu_installed = Command::new(exec).stdout(Stdio::null()).output();
has_umu_installed.is_ok()
}

4
client/src/lib.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod app_status;
pub mod autostart;
pub mod compat;
pub mod user;

12
client/src/user.rs Normal file
View File

@ -0,0 +1,12 @@
use bitcode::{Decode, Encode};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize, Encode, Decode)]
#[serde(rename_all = "camelCase")]
pub struct User {
id: String,
username: String,
admin: bool,
display_name: String,
profile_picture_object_id: String,
}

20
cloud_saves/Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "cloud_saves"
version = "0.1.0"
edition = "2024"
[dependencies]
database = { version = "0.1.0", path = "../database" }
dirs = "6.0.0"
drop-consts = { version = "0.1.0", path = "../drop-consts" }
log = "0.4.28"
regex = "1.11.3"
rustix = "1.1.2"
serde = "1.0.228"
serde_json = "1.0.145"
serde_with = "3.15.0"
tar = "0.4.44"
tempfile = "3.23.0"
uuid = "1.18.1"
whoami = "1.6.1"
zstd = "0.13.3"

View File

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

View File

@ -0,0 +1,7 @@
use database::platform::Platform;
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Condition {
Os(Platform),
Other
}

27
cloud_saves/src/error.rs Normal file
View File

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

8
cloud_saves/src/lib.rs Normal file
View File

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

View File

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

7
cloud_saves/src/mod.rs Normal file
View File

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

View File

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

48
cloud_saves/src/path.rs Normal file
View File

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

View File

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

216
cloud_saves/src/resolver.rs Normal file
View File

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

View File

View File

@ -1,81 +0,0 @@
<template>
<button
type="button"
@click="() => buttonActions[props.status.type]()"
:class="[
styles[props.status.type],
'inline-flex uppercase font-display items-center gap-x-2 rounded-md px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]"
>
<component
:is="buttonIcons[props.status.type]"
class="-mr-0.5 size-5"
aria-hidden="true"
/>
{{ buttonNames[props.status.type] }}
</button>
</template>
<script setup lang="ts">
import {
ArrowDownTrayIcon,
PlayIcon,
QueueListIcon,
TrashIcon,
WrenchIcon,
} from "@heroicons/vue/20/solid";
import type { Component } from "vue";
import { GameStatusEnum, type GameStatus } from "~/types.js";
const props = defineProps<{ status: GameStatus }>();
const emit = defineEmits<{
(e: "install"): void;
(e: "play"): void;
(e: "queue"): void;
}>();
const styles: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]:
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600",
[GameStatusEnum.Queued]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatusEnum.Downloading]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatusEnum.SetupRequired]:
"bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600",
[GameStatusEnum.Installed]:
"bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600",
[GameStatusEnum.Updating]: "",
[GameStatusEnum.Uninstalling]: "",
};
const buttonNames: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]: "Install",
[GameStatusEnum.Queued]: "Queued",
[GameStatusEnum.Downloading]: "Downloading",
[GameStatusEnum.SetupRequired]: "Setup",
[GameStatusEnum.Installed]: "Play",
[GameStatusEnum.Updating]: "Updating",
[GameStatusEnum.Uninstalling]: "Uninstalling",
};
const buttonIcons: { [key in GameStatusEnum]: Component } = {
[GameStatusEnum.Remote]: ArrowDownTrayIcon,
[GameStatusEnum.Queued]: QueueListIcon,
[GameStatusEnum.Downloading]: ArrowDownTrayIcon,
[GameStatusEnum.SetupRequired]: WrenchIcon,
[GameStatusEnum.Installed]: PlayIcon,
[GameStatusEnum.Updating]: ArrowDownTrayIcon,
[GameStatusEnum.Uninstalling]: TrashIcon,
};
const buttonActions: { [key in GameStatusEnum]: () => void } = {
[GameStatusEnum.Remote]: () => emit("install"),
[GameStatusEnum.Queued]: () => emit("queue"),
[GameStatusEnum.Downloading]: () => emit("queue"),
[GameStatusEnum.SetupRequired]: () => {},
[GameStatusEnum.Installed]: () => emit("play"),
[GameStatusEnum.Updating]: () => emit("queue"),
[GameStatusEnum.Uninstalling]: () => {},
};
</script>

View File

@ -1,5 +0,0 @@
<template>
<button class="transition h-10 w-10 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 p-2">
<slot />
</button>
</template>

View File

@ -1,3 +0,0 @@
import type { AppState } from "~/types";
export const useAppState = () => useState<AppState>("state");

View File

@ -1,55 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Game, GameStatus, GameStatusEnum } from "~/types";
const gameRegistry: { [key: string]: Game } = {};
const gameStatusRegistry: { [key: string]: Ref<GameStatus> } = {};
type OptionGameStatus = { [key in GameStatusEnum]: { version_name?: string } };
export type SerializedGameStatus = [
{ type: GameStatusEnum },
OptionGameStatus | null
];
const parseStatus = (status: SerializedGameStatus): GameStatus => {
if (status[0]) {
return {
type: status[0].type,
};
} else if (status[1]) {
const [[gameStatus, options]] = Object.entries(status[1]);
return {
type: gameStatus as GameStatusEnum,
...options,
};
} else {
throw new Error("No game status");
}
};
export const useGame = async (id: string) => {
if (!gameRegistry[id]) {
const data: { game: Game; status: SerializedGameStatus } = await invoke(
"fetch_game",
{
id,
}
);
gameRegistry[id] = data.game;
if (!gameStatusRegistry[id]) {
gameStatusRegistry[id] = ref(parseStatus(data.status));
listen(`update_game/${id}`, (event) => {
const payload: {
status: SerializedGameStatus;
} = event.payload as any;
gameStatusRegistry[id].value = parseStatus(payload.status);
});
}
}
const game = gameRegistry[id];
const status = gameStatusRegistry[id];
return { game, status };
};

View File

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

View File

@ -1,52 +0,0 @@
import { listen } from "@tauri-apps/api/event";
import { AppStatus, type AppState } from "~/types";
export function setupHooks() {
const router = useRouter();
listen("auth/processing", (event) => {
router.push("/auth/processing");
});
listen("auth/failed", (event) => {
router.push(
`/auth/failed?error=${encodeURIComponent(event.payload as string)}`
);
});
listen("auth/finished", (event) => {
router.push("/store");
});
/*
document.addEventListener("contextmenu", (event) => {
event.target?.dispatchEvent(new Event("contextmenu"));
event.preventDefault();
});
*/
}
export function initialNavigation(state: Ref<AppState>) {
const router = useRouter();
switch (state.value.status) {
case AppStatus.NotConfigured:
router.push({ path: "/setup" }).then(() => {
console.log("Pushed Setup");
});
break;
case AppStatus.SignedOut:
router.push("/auth");
break;
case AppStatus.SignedInNeedsReauth:
router.push("/auth/signedout");
break;
case AppStatus.ServerUnavailable:
router.push("/error/serverunavailable");
break;
default:
router.push("/store");
}
}

16
database/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "database"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4.42"
dirs = "6.0.0"
drop-consts = { version = "0.1.0", path = "../drop-consts" }
log = "0.4.28"
native_model = { version = "0.6.4", features = ["rmp_serde_1_3"], git = "https://github.com/Drop-OSS/native_model.git"}
rustbreak = "2.0.0"
serde = "1.0.228"
serde_with = "3.15.0"
url = "2.5.7"
whoami = "1.6.1"

31
database/src/db.rs Normal file
View File

@ -0,0 +1,31 @@
use std::{
sync::LazyLock,
};
use rustbreak::{DeSerError, DeSerializer};
use serde::{Serialize, de::DeserializeOwned};
use crate::interface::{DatabaseImpls, DatabaseInterface};
pub static DB: LazyLock<DatabaseInterface> = LazyLock::new(DatabaseInterface::set_up_database);
// Custom JSON serializer to support everything we need
#[derive(Debug, Default, Clone)]
pub struct DropDatabaseSerializer;
impl<T: native_model::Model + Serialize + DeserializeOwned> DeSerializer<T>
for DropDatabaseSerializer
{
fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult<Vec<u8>> {
native_model::encode(val).map_err(|e| DeSerError::Internal(e.to_string()))
}
fn deserialize<R: std::io::Read>(&self, mut s: R) -> rustbreak::error::DeSerResult<T> {
let mut buf = Vec::new();
s.read_to_end(&mut buf)
.map_err(|e| rustbreak::error::DeSerError::Other(e.into()))?;
let (val, _version) =
native_model::decode(buf).map_err(|e| rustbreak::error::DeSerError::Internal(e.to_string()))?;
Ok(val)
}
}

21
database/src/debug.rs Normal file
View File

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

189
database/src/interface.rs Normal file
View File

@ -0,0 +1,189 @@
use std::{
fs::{self, create_dir_all},
mem::ManuallyDrop,
ops::{Deref, DerefMut},
path::PathBuf,
sync::{RwLockReadGuard, RwLockWriteGuard},
};
use chrono::Utc;
use drop_consts::DATA_ROOT_DIR;
use log::{debug, error, info, warn};
use rustbreak::{PathDatabase, RustbreakError};
use url::Url;
use crate::{
db::{DB, DropDatabaseSerializer},
models::data::Database,
};
pub type DatabaseInterface =
rustbreak::Database<Database, rustbreak::backend::PathBackend, DropDatabaseSerializer>;
pub trait DatabaseImpls {
fn set_up_database() -> DatabaseInterface;
fn database_is_set_up(&self) -> bool;
fn fetch_base_url(&self) -> Url;
}
impl DatabaseImpls for DatabaseInterface {
fn set_up_database() -> DatabaseInterface {
let db_path = DATA_ROOT_DIR.join("drop.db");
let games_base_dir = DATA_ROOT_DIR.join("games");
let logs_root_dir = DATA_ROOT_DIR.join("logs");
let cache_dir = DATA_ROOT_DIR.join("cache");
let pfx_dir = DATA_ROOT_DIR.join("pfx");
debug!("creating data directory at {DATA_ROOT_DIR:?}");
create_dir_all(DATA_ROOT_DIR.as_path()).unwrap_or_else(|e| {
panic!(
"Failed to create directory {} with error {}",
DATA_ROOT_DIR.display(),
e
)
});
create_dir_all(&games_base_dir).unwrap_or_else(|e| {
panic!(
"Failed to create directory {} with error {}",
games_base_dir.display(),
e
)
});
create_dir_all(&logs_root_dir).unwrap_or_else(|e| {
panic!(
"Failed to create directory {} with error {}",
logs_root_dir.display(),
e
)
});
create_dir_all(&cache_dir).unwrap_or_else(|e| {
panic!(
"Failed to create directory {} with error {}",
cache_dir.display(),
e
)
});
create_dir_all(&pfx_dir).unwrap_or_else(|e| {
panic!(
"Failed to create directory {} with error {}",
pfx_dir.display(),
e
)
});
let exists = fs::exists(db_path.clone()).unwrap_or_else(|e| {
panic!(
"Failed to find if {} exists with error {}",
db_path.display(),
e
)
});
if exists {
match PathDatabase::load_from_path(db_path.clone()) {
Ok(db) => db,
Err(e) => handle_invalid_database(e, db_path, games_base_dir, cache_dir),
}
} else {
let default = Database::new(games_base_dir, None, cache_dir);
debug!("Creating database at path {}", db_path.display());
PathDatabase::create_at_path(db_path, default).expect("Database could not be created")
}
}
fn database_is_set_up(&self) -> bool {
!borrow_db_checked().base_url.is_empty()
}
fn fetch_base_url(&self) -> Url {
let handle = borrow_db_checked();
Url::parse(&handle.base_url)
.unwrap_or_else(|_| panic!("Failed to parse base url {}", handle.base_url))
}
}
// TODO: Make the error relelvant rather than just assume that it's a Deserialize error
fn handle_invalid_database(
_e: RustbreakError,
db_path: PathBuf,
games_base_dir: PathBuf,
cache_dir: PathBuf,
) -> rustbreak::Database<Database, rustbreak::backend::PathBackend, DropDatabaseSerializer> {
warn!("{_e}");
let new_path = {
let time = Utc::now().timestamp();
let mut base = db_path.clone();
base.set_file_name(format!("drop.db.backup-{time}"));
base
};
info!("old database stored at: {}", new_path.to_string_lossy());
fs::rename(&db_path, &new_path).unwrap_or_else(|e| {
panic!(
"Could not rename database {} to {} with error {}",
db_path.display(),
new_path.display(),
e
)
});
let db = Database::new(games_base_dir, Some(new_path), cache_dir);
PathDatabase::create_at_path(db_path, db).expect("Database could not be created")
}
// To automatically save the database upon drop
pub struct DBRead<'a>(RwLockReadGuard<'a, Database>);
pub struct DBWrite<'a>(ManuallyDrop<RwLockWriteGuard<'a, Database>>);
impl<'a> Deref for DBWrite<'a> {
type Target = Database;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'a> DerefMut for DBWrite<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<'a> Deref for DBRead<'a> {
type Target = Database;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Drop for DBWrite<'_> {
fn drop(&mut self) {
unsafe {
ManuallyDrop::drop(&mut self.0);
}
match DB.save() {
Ok(()) => {}
Err(e) => {
error!("database failed to save with error {e}");
panic!("database failed to save with error {e}")
}
}
}
}
pub fn borrow_db_checked<'a>() -> DBRead<'a> {
match DB.borrow_data() {
Ok(data) => DBRead(data),
Err(e) => {
error!("database borrow failed with error {e}");
panic!("database borrow failed with error {e}");
}
}
}
pub fn borrow_db_mut_checked<'a>() -> DBWrite<'a> {
match DB.borrow_data_mut() {
Ok(data) => DBWrite(ManuallyDrop::new(data)),
Err(e) => {
error!("database borrow mut failed with error {e}");
panic!("database borrow mut failed with error {e}");
}
}
}

14
database/src/lib.rs Normal file
View File

@ -0,0 +1,14 @@
#![feature(nonpoison_rwlock)]
pub mod db;
pub mod debug;
pub mod interface;
pub mod models;
pub mod platform;
pub use db::DB;
pub use interface::{borrow_db_checked, borrow_db_mut_checked};
pub use models::data::{
ApplicationTransientStatus, Database, DatabaseApplications, DatabaseAuth, DownloadType,
DownloadableMetadata, GameDownloadStatus, GameVersion, Settings,
};

373
database/src/models.rs Normal file
View File

@ -0,0 +1,373 @@
pub mod data {
use std::{hash::Hash, path::PathBuf};
use native_model::native_model;
use serde::{Deserialize, Serialize};
// NOTE: Within each version, you should NEVER use these types.
// Declare it using the actual version that it is from, i.e. v1::Settings rather than just Settings from here
pub type GameVersion = v1::GameVersion;
pub type Database = v3::Database;
pub type Settings = v1::Settings;
pub type DatabaseAuth = v1::DatabaseAuth;
pub type GameDownloadStatus = v2::GameDownloadStatus;
pub type ApplicationTransientStatus = v1::ApplicationTransientStatus;
/**
* Need to be universally accessible by the ID, and the version is just a couple sprinkles on top
*/
pub type DownloadableMetadata = v1::DownloadableMetadata;
pub type DownloadType = v1::DownloadType;
pub type DatabaseApplications = v2::DatabaseApplications;
// pub type DatabaseCompatInfo = v2::DatabaseCompatInfo;
use std::collections::HashMap;
impl PartialEq for DownloadableMetadata {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.download_type == other.download_type
}
}
impl Hash for DownloadableMetadata {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
self.download_type.hash(state);
}
}
mod v1 {
use serde_with::serde_as;
use std::{collections::HashMap, path::PathBuf};
use crate::platform::Platform;
use super::{Deserialize, Serialize, native_model};
fn default_template() -> String {
"{}".to_owned()
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[native_model(id = 2, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct GameVersion {
pub game_id: String,
pub version_name: String,
pub platform: Platform,
pub launch_command: String,
pub launch_args: Vec<String>,
#[serde(default = "default_template")]
pub launch_command_template: String,
pub setup_command: String,
pub setup_args: Vec<String>,
#[serde(default = "default_template")]
pub setup_command_template: String,
pub only_setup: bool,
pub version_index: usize,
pub delta: bool,
pub umu_id_override: Option<String>,
}
#[serde_as]
#[derive(Serialize, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[native_model(id = 3, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct DatabaseApplications {
pub install_dirs: Vec<PathBuf>,
// Guaranteed to exist if the game also exists in the app state map
pub game_statuses: HashMap<String, GameDownloadStatus>,
pub game_versions: HashMap<String, HashMap<String, GameVersion>>,
pub installed_game_version: HashMap<String, DownloadableMetadata>,
#[serde(skip)]
pub transient_statuses: HashMap<DownloadableMetadata, ApplicationTransientStatus>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
#[native_model(id = 4, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct Settings {
pub autostart: bool,
pub max_download_threads: usize,
pub force_offline: bool, // ... other settings ...
}
impl Default for Settings {
fn default() -> Self {
Self {
autostart: false,
max_download_threads: 4,
force_offline: false,
}
}
}
// Strings are version names for a particular game
#[derive(Serialize, Clone, Deserialize)]
#[serde(tag = "type")]
#[native_model(id = 5, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub enum GameDownloadStatus {
Remote {},
SetupRequired {
version_name: String,
install_dir: String,
},
Installed {
version_name: String,
install_dir: String,
},
}
// Stuff that shouldn't be synced to disk
#[derive(Clone, Serialize, Deserialize, Debug)]
pub enum ApplicationTransientStatus {
Queued { version_name: String },
Downloading { version_name: String },
Uninstalling {},
Updating { version_name: String },
Validating { version_name: String },
Running {},
}
#[derive(serde::Serialize, Clone, Deserialize)]
#[native_model(id = 6, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct DatabaseAuth {
pub private: String,
pub cert: String,
pub client_id: String,
pub web_token: Option<String>,
}
#[native_model(id = 8, version = 1)]
#[derive(
Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone, Copy,
)]
pub enum DownloadType {
Game,
Tool,
Dlc,
Mod,
}
#[native_model(id = 7, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
#[derive(Debug, Eq, PartialOrd, Ord, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DownloadableMetadata {
pub id: String,
pub version: Option<String>,
pub download_type: DownloadType,
}
impl DownloadableMetadata {
pub fn new(id: String, version: Option<String>, download_type: DownloadType) -> Self {
Self {
id,
version,
download_type,
}
}
}
#[native_model(id = 1, version = 1)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database {
#[serde(default)]
pub settings: Settings,
pub auth: Option<DatabaseAuth>,
pub base_url: String,
pub applications: DatabaseApplications,
pub prev_database: Option<PathBuf>,
pub cache_dir: PathBuf,
}
}
mod v2 {
use std::{collections::HashMap, path::PathBuf};
use serde_with::serde_as;
use super::{Deserialize, Serialize, native_model, v1};
#[native_model(id = 1, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from = v1::Database)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database {
#[serde(default)]
pub settings: v1::Settings,
pub auth: Option<v1::DatabaseAuth>,
pub base_url: String,
pub applications: v1::DatabaseApplications,
#[serde(skip)]
pub prev_database: Option<PathBuf>,
pub cache_dir: PathBuf,
pub compat_info: Option<DatabaseCompatInfo>,
}
#[native_model(id = 9, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct DatabaseCompatInfo {
pub umu_installed: bool,
}
impl From<v1::Database> for Database {
fn from(value: v1::Database) -> Self {
Self {
settings: value.settings,
auth: value.auth,
base_url: value.base_url,
applications: value.applications,
prev_database: value.prev_database,
cache_dir: value.cache_dir,
compat_info: None,
}
}
}
// Strings are version names for a particular game
#[derive(Serialize, Clone, Deserialize, Debug)]
#[serde(tag = "type")]
#[native_model(id = 5, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from = v1::GameDownloadStatus)]
pub enum GameDownloadStatus {
Remote {},
SetupRequired {
version_name: String,
install_dir: String,
},
Installed {
version_name: String,
install_dir: String,
},
PartiallyInstalled {
version_name: String,
install_dir: String,
},
}
impl From<v1::GameDownloadStatus> for GameDownloadStatus {
fn from(value: v1::GameDownloadStatus) -> Self {
match value {
v1::GameDownloadStatus::Remote {} => Self::Remote {},
v1::GameDownloadStatus::SetupRequired {
version_name,
install_dir,
} => Self::SetupRequired {
version_name,
install_dir,
},
v1::GameDownloadStatus::Installed {
version_name,
install_dir,
} => Self::Installed {
version_name,
install_dir,
},
}
}
}
#[serde_as]
#[derive(Serialize, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[native_model(id = 3, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from=v1::DatabaseApplications)]
pub struct DatabaseApplications {
pub install_dirs: Vec<PathBuf>,
// Guaranteed to exist if the game also exists in the app state map
pub game_statuses: HashMap<String, GameDownloadStatus>,
pub game_versions: HashMap<String, HashMap<String, v1::GameVersion>>,
pub installed_game_version: HashMap<String, v1::DownloadableMetadata>,
#[serde(skip)]
pub transient_statuses:
HashMap<v1::DownloadableMetadata, v1::ApplicationTransientStatus>,
}
impl From<v1::DatabaseApplications> for DatabaseApplications {
fn from(value: v1::DatabaseApplications) -> Self {
Self {
game_statuses: value
.game_statuses
.into_iter()
.map(|x| (x.0, x.1.into()))
.collect::<HashMap<String, GameDownloadStatus>>(),
install_dirs: value.install_dirs,
game_versions: value.game_versions,
installed_game_version: value.installed_game_version,
transient_statuses: value.transient_statuses,
}
}
}
}
mod v3 {
use std::path::PathBuf;
use super::{Deserialize, Serialize, native_model, v1, v2};
#[native_model(id = 1, version = 3, with = native_model::rmp_serde_1_3::RmpSerde, from = v2::Database)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database {
#[serde(default)]
pub settings: v1::Settings,
pub auth: Option<v1::DatabaseAuth>,
pub base_url: String,
pub applications: v2::DatabaseApplications,
#[serde(skip)]
pub prev_database: Option<PathBuf>,
pub cache_dir: PathBuf,
pub compat_info: Option<v2::DatabaseCompatInfo>,
}
impl From<v2::Database> for Database {
fn from(value: v2::Database) -> Self {
Self {
settings: value.settings,
auth: value.auth,
base_url: value.base_url,
applications: value.applications.into(),
prev_database: value.prev_database,
cache_dir: value.cache_dir,
compat_info: None,
}
}
}
}
impl Database {
pub fn new<T: Into<PathBuf>>(
games_base_dir: T,
prev_database: Option<PathBuf>,
cache_dir: PathBuf,
) -> Self {
Self {
applications: DatabaseApplications {
install_dirs: vec![games_base_dir.into()],
game_statuses: HashMap::new(),
game_versions: HashMap::new(),
installed_game_version: HashMap::new(),
transient_statuses: HashMap::new(),
},
prev_database,
base_url: String::new(),
auth: None,
settings: Settings::default(),
cache_dir,
compat_info: None,
}
}
}
impl DatabaseAuth {
pub fn new(
private: String,
cert: String,
client_id: String,
web_token: Option<String>,
) -> Self {
Self {
private,
cert,
client_id,
web_token,
}
}
}
}

46
database/src/platform.rs Normal file
View File

@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone, Copy, Debug)]
pub enum Platform {
Windows,
Linux,
MacOs,
}
impl Platform {
#[cfg(target_os = "windows")]
pub const HOST: Platform = Self::Windows;
#[cfg(target_os = "macos")]
pub const HOST: Platform = Self::MacOs;
#[cfg(target_os = "linux")]
pub const HOST: Platform = Self::Linux;
pub fn is_case_sensitive(&self) -> bool {
match self {
Self::Windows | Self::MacOs => false,
Self::Linux => true,
}
}
}
impl From<&str> for Platform {
fn from(value: &str) -> Self {
match value.to_lowercase().trim() {
"windows" => Self::Windows,
"linux" => Self::Linux,
"mac" | "macos" => Self::MacOs,
_ => unimplemented!(),
}
}
}
impl From<whoami::Platform> for Platform {
fn from(value: whoami::Platform) -> Self {
match value {
whoami::Platform::Windows => Platform::Windows,
whoami::Platform::Linux => Platform::Linux,
whoami::Platform::MacOS => Platform::MacOs,
platform => unimplemented!("Playform {} is not supported", platform),
}
}
}

View File

@ -0,0 +1,17 @@
[package]
name = "download_manager"
version = "0.1.0"
edition = "2024"
[dependencies]
atomic-instant-full = "0.1.0"
database = { version = "0.1.0", path = "../database" }
humansize = "2.1.3"
log = "0.4.28"
parking_lot = "0.12.5"
remote = { version = "0.1.0", path = "../remote" }
serde = "1.0.228"
serde_with = "3.15.0"
tauri = "2.8.5"
throttle_my_fn = "0.2.6"
utils = { version = "0.1.0", path = "../utils" }

View File

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

View File

@ -0,0 +1,186 @@
use std::{
any::Any,
collections::VecDeque,
fmt::Debug,
sync::{
Mutex, MutexGuard,
mpsc::{SendError, Sender},
},
thread::JoinHandle,
};
use database::DownloadableMetadata;
use log::{debug, info};
use serde::Serialize;
use utils::{lock, send};
use crate::error::ApplicationDownloadError;
use super::{
download_manager_builder::{CurrentProgressObject, DownloadAgent},
util::queue::Queue,
};
pub enum DownloadManagerSignal {
/// Resumes (or starts) the `DownloadManager`
Go,
/// Pauses the `DownloadManager`
Stop,
/// Called when a `DownloadAgent` has fully completed a download.
Completed(DownloadableMetadata),
/// Generates and appends a `DownloadAgent`
/// to the registry and queue
Queue(DownloadAgent),
/// Tells the Manager to stop the current
/// download, sync everything to disk, and
/// then exit
Finish,
/// Stops, removes, and tells a download to cleanup
Cancel(DownloadableMetadata),
/// Any error which occurs in the agent
Error(ApplicationDownloadError),
/// Pushes UI update
UpdateUIQueue,
UpdateUIStats(usize, usize), //kb/s and seconds
}
#[derive(Debug)]
pub enum DownloadManagerStatus {
Downloading,
Paused,
Empty,
Error,
}
impl Serialize for DownloadManagerStatus {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&format!["{self:?}"])
}
}
#[derive(Serialize, Clone, Debug, PartialEq)]
pub enum DownloadStatus {
Queued,
Downloading,
Validating,
Error,
}
/// Accessible front-end for the `DownloadManager`
///
/// The system works entirely through signals, both internally and externally,
/// all of which are accessible through the `DownloadManagerSignal` type, but
/// should not be used directly. Rather, signals are abstracted through this
/// interface.
///
/// The actual download queue may be accessed through the .`edit()` function,
/// which provides raw access to the underlying queue.
/// THIS EDITING IS BLOCKING!!!
#[derive(Debug)]
pub struct DownloadManager {
terminator: Mutex<Option<JoinHandle<Result<(), ()>>>>,
download_queue: Queue,
progress: CurrentProgressObject,
command_sender: Sender<DownloadManagerSignal>,
}
#[allow(dead_code)]
impl DownloadManager {
pub fn new(
terminator: JoinHandle<Result<(), ()>>,
download_queue: Queue,
progress: CurrentProgressObject,
command_sender: Sender<DownloadManagerSignal>,
) -> Self {
Self {
terminator: Mutex::new(Some(terminator)),
download_queue,
progress,
command_sender,
}
}
pub fn queue_download(
&self,
download: DownloadAgent,
) -> Result<(), SendError<DownloadManagerSignal>> {
info!("creating download with meta {:?}", download.metadata());
self.command_sender
.send(DownloadManagerSignal::Queue(download))?;
self.command_sender.send(DownloadManagerSignal::Go)
}
pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> {
self.download_queue.edit()
}
pub fn read_queue(&self) -> VecDeque<DownloadableMetadata> {
self.download_queue.read()
}
pub fn get_current_download_progress(&self) -> Option<f64> {
let progress_object = (*lock!(self.progress)).clone()?;
Some(progress_object.get_progress())
}
pub fn rearrange_string(&self, meta: &DownloadableMetadata, new_index: usize) {
let mut queue = self.edit();
let current_index =
get_index_from_id(&mut queue, meta).expect("Failed to get meta index from id");
let to_move = queue
.remove(current_index)
.expect("Failed to remove meta at index from queue");
queue.insert(new_index, to_move);
send!(self.command_sender, DownloadManagerSignal::UpdateUIQueue);
}
pub fn cancel(&self, meta: DownloadableMetadata) {
send!(self.command_sender, DownloadManagerSignal::Cancel(meta));
}
pub fn rearrange(&self, current_index: usize, new_index: usize) {
if current_index == new_index {
return;
}
let needs_pause = current_index == 0 || new_index == 0;
if needs_pause {
send!(self.command_sender, DownloadManagerSignal::Stop);
}
debug!("moving download at index {current_index} to index {new_index}");
let mut queue = self.edit();
let to_move = queue.remove(current_index).expect("Failed to get");
queue.insert(new_index, to_move);
drop(queue);
if needs_pause {
send!(self.command_sender, DownloadManagerSignal::Go);
}
send!(self.command_sender, DownloadManagerSignal::UpdateUIQueue);
send!(self.command_sender, DownloadManagerSignal::Go);
}
pub fn pause_downloads(&self) {
send!(self.command_sender, DownloadManagerSignal::Stop);
}
pub fn resume_downloads(&self) {
send!(self.command_sender, DownloadManagerSignal::Go);
}
pub fn ensure_terminated(&self) -> Result<Result<(), ()>, Box<dyn Any + Send>> {
send!(self.command_sender, DownloadManagerSignal::Finish);
let terminator = lock!(self.terminator).take();
terminator.unwrap().join()
}
pub fn get_sender(&self) -> Sender<DownloadManagerSignal> {
self.command_sender.clone()
}
}
/// Takes in the locked value from .`edit()` and attempts to
/// get the index of whatever id is passed in
fn get_index_from_id(
queue: &mut MutexGuard<'_, VecDeque<DownloadableMetadata>>,
meta: &DownloadableMetadata,
) -> Option<usize> {
queue
.iter()
.position(|download_agent| download_agent == meta)
}

View File

@ -0,0 +1,31 @@
use std::sync::Arc;
use database::DownloadableMetadata;
use tauri::AppHandle;
use crate::error::ApplicationDownloadError;
use super::{
download_manager_frontend::DownloadStatus,
util::{download_thread_control_flag::DownloadThreadControl, progress_object::ProgressObject},
};
/**
* Downloadables are responsible for managing their specific object's download state
* e.g, the GameDownloadAgent is responsible for pushing game updates
*
* But the download manager manages the queue state
*/
pub trait Downloadable: Send + Sync {
fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError>;
fn validate(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError>;
fn progress(&self) -> Arc<ProgressObject>;
fn control_flag(&self) -> DownloadThreadControl;
fn status(&self) -> DownloadStatus;
fn metadata(&self) -> DownloadableMetadata;
fn on_queued(&self, app_handle: &AppHandle);
fn on_error(&self, app_handle: &AppHandle, error: &ApplicationDownloadError);
fn on_complete(&self, app_handle: &AppHandle);
fn on_cancelled(&self, app_handle: &AppHandle);
}

View File

@ -0,0 +1,80 @@
use humansize::{BINARY, format_size};
use std::{
fmt::{Display, Formatter},
io,
sync::{Arc, mpsc::SendError},
};
use remote::error::RemoteAccessError;
use serde_with::SerializeDisplay;
#[derive(SerializeDisplay)]
pub enum DownloadManagerError<T> {
IOError(io::Error),
SignalError(SendError<T>),
}
impl<T> Display for DownloadManagerError<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DownloadManagerError::IOError(error) => write!(f, "{error}"),
DownloadManagerError::SignalError(send_error) => write!(f, "{send_error}"),
}
}
}
impl<T> From<SendError<T>> for DownloadManagerError<T> {
fn from(value: SendError<T>) -> Self {
DownloadManagerError::SignalError(value)
}
}
impl<T> From<io::Error> for DownloadManagerError<T> {
fn from(value: io::Error) -> Self {
DownloadManagerError::IOError(value)
}
}
// TODO: Rename / separate from downloads
#[derive(Debug, SerializeDisplay)]
pub enum ApplicationDownloadError {
NotInitialized,
Communication(RemoteAccessError),
DiskFull(u64, u64),
#[allow(dead_code)]
Checksum,
Lock,
IoError(Arc<io::Error>),
DownloadError(RemoteAccessError),
}
impl Display for ApplicationDownloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ApplicationDownloadError::NotInitialized => {
write!(f, "Download not initalized, did something go wrong?")
}
ApplicationDownloadError::DiskFull(required, available) => write!(
f,
"Game requires {}, {} remaining left on disk.",
format_size(*required, BINARY),
format_size(*available, BINARY),
),
ApplicationDownloadError::Communication(error) => write!(f, "{error}"),
ApplicationDownloadError::Lock => write!(
f,
"failed to acquire lock. Something has gone very wrong internally. Please restart the application"
),
ApplicationDownloadError::Checksum => {
write!(f, "checksum failed to validate for download")
}
ApplicationDownloadError::IoError(error) => write!(f, "io error: {error}"),
ApplicationDownloadError::DownloadError(error) => {
write!(f, "Download failed with error {error:?}")
}
}
}
}
impl From<io::Error> for ApplicationDownloadError {
fn from(value: io::Error) -> Self {
ApplicationDownloadError::IoError(Arc::new(value))
}
}

View File

@ -0,0 +1,24 @@
use database::DownloadableMetadata;
use serde::Serialize;
use crate::download_manager_frontend::DownloadStatus;
#[derive(Serialize, Clone)]
pub struct QueueUpdateEventQueueData {
pub meta: DownloadableMetadata,
pub status: DownloadStatus,
pub progress: f64,
pub current: usize,
pub max: usize,
}
#[derive(Serialize, Clone)]
pub struct QueueUpdateEvent {
pub queue: Vec<QueueUpdateEventQueueData>,
}
#[derive(Serialize, Clone)]
pub struct StatsUpdateEvent {
pub speed: usize,
pub time: usize,
}

View File

@ -0,0 +1,44 @@
#![feature(duration_millis_float)]
#![feature(nonpoison_mutex)]
#![feature(sync_nonpoison)]
use std::{ops::Deref, sync::OnceLock};
use tauri::AppHandle;
use crate::{
download_manager_builder::DownloadManagerBuilder, download_manager_frontend::DownloadManager,
};
pub mod download_manager_builder;
pub mod download_manager_frontend;
pub mod downloadable;
pub mod error;
pub mod frontend_updates;
pub mod util;
pub static DOWNLOAD_MANAGER: DownloadManagerWrapper = DownloadManagerWrapper::new();
pub struct DownloadManagerWrapper(OnceLock<DownloadManager>);
impl DownloadManagerWrapper {
const fn new() -> Self {
DownloadManagerWrapper(OnceLock::new())
}
pub fn init(app_handle: AppHandle) {
DOWNLOAD_MANAGER
.0
.set(DownloadManagerBuilder::build(app_handle))
.expect("Failed to initialise download manager");
}
}
impl Deref for DownloadManagerWrapper {
type Target = DownloadManager;
fn deref(&self) -> &Self::Target {
match self.0.get() {
Some(download_manager) => download_manager,
None => unreachable!("Download manager should always be initialised"),
}
}
}

View File

@ -1,6 +1,6 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
atomic::{AtomicBool, Ordering},
};
#[derive(PartialEq, Eq, PartialOrd, Ord)]
@ -22,9 +22,10 @@ impl From<DownloadThreadControlFlag> for bool {
/// false => Stop
impl From<bool> for DownloadThreadControlFlag {
fn from(value: bool) -> Self {
match value {
true => DownloadThreadControlFlag::Go,
false => DownloadThreadControlFlag::Stop,
if value {
DownloadThreadControlFlag::Go
} else {
DownloadThreadControlFlag::Stop
}
}
}
@ -41,9 +42,9 @@ impl DownloadThreadControl {
}
}
pub fn get(&self) -> DownloadThreadControlFlag {
self.inner.load(Ordering::Relaxed).into()
self.inner.load(Ordering::Acquire).into()
}
pub fn set(&self, flag: DownloadThreadControlFlag) {
self.inner.store(flag.into(), Ordering::Relaxed);
self.inner.store(flag.into(), Ordering::Release);
}
}

View File

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

View File

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

View File

@ -0,0 +1,45 @@
use std::{
collections::VecDeque,
sync::{Arc, Mutex, MutexGuard},
};
use database::DownloadableMetadata;
use utils::lock;
#[derive(Clone, Debug)]
pub struct Queue {
inner: Arc<Mutex<VecDeque<DownloadableMetadata>>>,
}
#[allow(dead_code)]
impl Default for Queue {
fn default() -> Self {
Self::new()
}
}
impl Queue {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(VecDeque::new())),
}
}
pub fn read(&self) -> VecDeque<DownloadableMetadata> {
lock!(self.inner).clone()
}
pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> {
lock!(self.inner)
}
pub fn pop_front(&self) -> Option<DownloadableMetadata> {
self.edit().pop_front()
}
pub fn exists(&self, meta: DownloadableMetadata) -> bool {
self.read().contains(&meta)
}
pub fn append(&self, interface: DownloadableMetadata) {
self.edit().push_back(interface);
}
pub fn get_by_meta(&self, meta: &DownloadableMetadata) -> Option<usize> {
self.read().iter().position(|data| data == meta)
}
}

View File

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

7
drop-consts/Cargo.toml Normal file
View File

@ -0,0 +1,7 @@
[package]
name = "drop-consts"
version = "0.1.0"
edition = "2024"
[dependencies]
dirs = "6.0.0"

23
drop-consts/src/lib.rs Normal file
View File

@ -0,0 +1,23 @@
use std::{cell::LazyCell, path::PathBuf};
#[cfg(not(debug_assertions))]
pub const DATA_ROOT_PREFIX: &str = "drop";
#[cfg(debug_assertions)]
pub const DATA_ROOT_PREFIX: &str = "drop-debug";
pub const DATA_ROOT_DIR: LazyCell<PathBuf> = LazyCell::new(|| {
dirs::data_dir()
.expect("Failed to get data dir")
.join(DATA_ROOT_PREFIX)
});
pub const DROP_DATA_PATH: &str = ".dropdata";
// Downloads
pub const MAX_PACKET_LENGTH: usize = 4096 * 4;
pub const BUMP_SIZE: usize = 4096 * 16;
pub const RETRY_COUNT: usize = 3;
pub const TARGET_BUCKET_SIZE: usize = 63 * 1000 * 1000;
pub const MAX_FILES_PER_BUCKET: usize = (1024 / 4) - 1;

27
games/Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "games"
version = "0.1.0"
edition = "2024"
[dependencies]
atomic-instant-full = "0.1.0"
bitcode = "0.6.7"
boxcar = "0.2.14"
database = { version = "0.1.0", path = "../database" }
download_manager = { version = "0.1.0", path = "../download_manager" }
hex = "0.4.3"
log = "0.4.28"
md5 = "0.8.0"
rayon = "1.11.0"
remote = { version = "0.1.0", path = "../remote" }
reqwest = "0.12.23"
rustix = "1.1.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_with = "3.15.0"
sysinfo = "0.37.2"
tauri = "2.8.5"
throttle_my_fn = "0.2.6"
utils = { version = "0.1.0", path = "../utils" }
native_model = { version = "0.6.4", features = ["rmp_serde_1_3"], git = "https://github.com/Drop-OSS/native_model.git"}
serde_json = "1.0.145"
drop-consts = { version = "0.1.0", path = "../drop-consts" }

View File

@ -0,0 +1,24 @@
use bitcode::{Decode, Encode};
use serde::{Deserialize, Serialize};
use crate::library::Game;
pub type Collections = Vec<Collection>;
#[derive(Serialize, Deserialize, Debug, Clone, Default, Encode, Decode)]
#[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, Encode, Decode)]
#[serde(rename_all = "camelCase")]
pub struct CollectionObject {
collection_id: String,
game_id: String,
game: Game,
}

View File

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

View File

@ -0,0 +1,712 @@
use database::{
ApplicationTransientStatus, DownloadType, DownloadableMetadata, borrow_db_checked,
borrow_db_mut_checked,
};
use download_manager::download_manager_frontend::{DownloadManagerSignal, DownloadStatus};
use download_manager::downloadable::Downloadable;
use download_manager::error::ApplicationDownloadError;
use download_manager::util::download_thread_control_flag::{
DownloadThreadControl, DownloadThreadControlFlag,
};
use download_manager::util::progress_object::{ProgressHandle, ProgressObject};
use drop_consts::{MAX_FILES_PER_BUCKET, RETRY_COUNT, TARGET_BUCKET_SIZE};
use log::{debug, error, info, warn};
use rayon::ThreadPoolBuilder;
use remote::auth::generate_authorization_header;
use remote::error::RemoteAccessError;
use remote::requests::generate_url;
use remote::utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC};
use std::collections::{HashMap, HashSet};
use std::fs::{OpenOptions, create_dir_all};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tauri::AppHandle;
use utils::{app_emit, lock, send};
#[cfg(target_os = "linux")]
use rustix::fs::{FallocateFlags, fallocate};
use crate::downloads::manifest::{
DownloadBucket, DownloadContext, DownloadDrop, DropManifest, DropValidateContext, ManifestBody,
};
use crate::downloads::utils::get_disk_available;
use crate::downloads::validate::validate_game_chunk;
use crate::library::{on_game_complete, push_game_update, set_partially_installed};
use crate::state::GameStatusManager;
use super::download_logic::download_game_bucket;
use super::drop_data::DropData;
pub struct GameDownloadAgent {
pub id: String,
pub version: String,
pub control_flag: DownloadThreadControl,
buckets: Mutex<Vec<DownloadBucket>>,
context_map: Mutex<HashMap<String, bool>>,
pub manifest: Mutex<Option<DropManifest>>,
pub progress: Arc<ProgressObject>,
sender: Sender<DownloadManagerSignal>,
pub dropdata: DropData,
status: Mutex<DownloadStatus>,
}
impl GameDownloadAgent {
pub async fn new_from_index(
id: String,
version: String,
target_download_dir: usize,
sender: Sender<DownloadManagerSignal>,
) -> Result<Self, ApplicationDownloadError> {
let base_dir = {
let db_lock = borrow_db_checked();
db_lock.applications.install_dirs[target_download_dir].clone()
};
Self::new(id, version, base_dir, sender).await
}
pub async fn new(
id: String,
version: String,
base_dir: PathBuf,
sender: Sender<DownloadManagerSignal>,
) -> Result<Self, ApplicationDownloadError> {
// Don't run by default
let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop);
let base_dir_path = Path::new(&base_dir);
let data_base_dir_path = base_dir_path.join(id.clone());
let stored_manifest =
DropData::generate(id.clone(), version.clone(), data_base_dir_path.clone());
let context_lock = stored_manifest.contexts.lock().unwrap().clone();
let result = Self {
id,
version,
control_flag,
manifest: Mutex::new(None),
buckets: Mutex::new(Vec::new()),
context_map: Mutex::new(HashMap::new()),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
dropdata: stored_manifest,
status: Mutex::new(DownloadStatus::Queued),
};
result.ensure_manifest_exists().await?;
let required_space = lock!(result.manifest)
.as_ref()
.unwrap()
.values()
.map(|e| {
e.lengths
.iter()
.enumerate()
.filter(|(i, _)| *context_lock.get(&e.checksums[*i]).unwrap_or(&false))
.map(|(_, v)| v)
.sum::<usize>()
})
.sum::<usize>() as u64;
let available_space = get_disk_available(data_base_dir_path)? as u64;
if required_space > available_space {
return Err(ApplicationDownloadError::DiskFull(
required_space,
available_space,
));
}
Ok(result)
}
// Blocking
pub fn setup_download(&self, app_handle: &AppHandle) -> Result<(), ApplicationDownloadError> {
let mut db_lock = borrow_db_mut_checked();
let status = ApplicationTransientStatus::Downloading {
version_name: self.version.clone(),
};
db_lock
.applications
.transient_statuses
.insert(self.metadata(), status.clone());
// Don't use GameStatusManager because this game isn't installed
push_game_update(app_handle, &self.metadata().id, None, (None, Some(status)));
if !self.check_manifest_exists() {
return Err(ApplicationDownloadError::NotInitialized);
}
self.ensure_buckets()?;
self.control_flag.set(DownloadThreadControlFlag::Go);
Ok(())
}
// Blocking
pub fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
self.setup_download(app_handle)?;
let timer = Instant::now();
info!("beginning download for {}...", self.metadata().id);
let res = self.run().map_err(ApplicationDownloadError::Communication);
debug!(
"{} took {}ms to download",
self.id,
timer.elapsed().as_millis()
);
res
}
pub fn check_manifest_exists(&self) -> bool {
lock!(self.manifest).is_some()
}
pub async fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> {
if lock!(self.manifest).is_some() {
return Ok(());
}
self.download_manifest().await
}
async fn download_manifest(&self) -> Result<(), ApplicationDownloadError> {
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(
&["/api/v1/client/game/manifest"],
&[("id", &self.id), ("version", &self.version)],
)
.map_err(ApplicationDownloadError::Communication)?;
let response = client
.get(url)
.header("Authorization", generate_authorization_header())
.send()
.await
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if response.status() != 200 {
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::ManifestDownloadFailed(
response.status(),
response.text().await.unwrap(),
),
));
}
let manifest_download: DropManifest = response
.json()
.await
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if let Ok(mut manifest) = self.manifest.lock() {
*manifest = Some(manifest_download);
return Ok(());
}
Err(ApplicationDownloadError::Lock)
}
// Sets it up for both download and validate
fn setup_progress(&self) {
let buckets = lock!(self.buckets);
let chunk_count = buckets.iter().map(|e| e.drops.len()).sum();
let total_length = buckets
.iter()
.map(|bucket| bucket.drops.iter().map(|e| e.length).sum::<usize>())
.sum();
self.progress.set_max(total_length);
self.progress.set_size(chunk_count);
self.progress.reset();
}
pub fn ensure_buckets(&self) -> Result<(), ApplicationDownloadError> {
if lock!(self.buckets).is_empty() {
self.generate_buckets()?;
}
*lock!(self.context_map) = self.dropdata.get_contexts();
Ok(())
}
pub fn generate_buckets(&self) -> Result<(), ApplicationDownloadError> {
let manifest = lock!(self.manifest)
.clone()
.ok_or(ApplicationDownloadError::NotInitialized)?;
let game_id = self.id.clone();
let base_path = Path::new(&self.dropdata.base_path);
create_dir_all(base_path)?;
let mut buckets = Vec::new();
let mut current_buckets = HashMap::<String, DownloadBucket>::new();
let mut current_bucket_sizes = HashMap::<String, usize>::new();
for (raw_path, chunk) in manifest {
let path = base_path.join(Path::new(&raw_path));
let container = path
.parent()
.ok_or(ApplicationDownloadError::IoError(Arc::new(io::Error::new(
io::ErrorKind::NotFound,
"no parent directory",
))))?;
create_dir_all(container)?;
let already_exists = path.exists();
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
let mut file_running_offset = 0;
for (index, length) in chunk.lengths.iter().enumerate() {
let drop = DownloadDrop {
filename: raw_path.to_string(),
start: file_running_offset,
length: *length,
checksum: chunk.checksums[index].clone(),
permissions: chunk.permissions,
path: path.clone(),
index,
};
file_running_offset += *length;
if *length >= TARGET_BUCKET_SIZE {
// They get their own bucket
buckets.push(DownloadBucket {
game_id: game_id.clone(),
version: chunk.version_name.clone(),
drops: vec![drop],
});
continue;
}
let current_bucket_size = current_bucket_sizes
.entry(chunk.version_name.clone())
.or_insert_with(|| 0);
let c_version_name = chunk.version_name.clone();
let c_game_id = game_id.clone();
let current_bucket = current_buckets
.entry(chunk.version_name.clone())
.or_insert_with(|| DownloadBucket {
game_id: c_game_id,
version: c_version_name,
drops: vec![],
});
if (*current_bucket_size + length >= TARGET_BUCKET_SIZE
|| current_bucket.drops.len() >= MAX_FILES_PER_BUCKET)
&& !current_bucket.drops.is_empty()
{
// Move current bucket into list and make a new one
buckets.push(current_bucket.clone());
*current_bucket = DownloadBucket {
game_id: game_id.clone(),
version: chunk.version_name.clone(),
drops: vec![],
};
*current_bucket_size = 0;
}
current_bucket.drops.push(drop);
*current_bucket_size += *length;
}
#[cfg(target_os = "linux")]
if file_running_offset > 0 && !already_exists {
let _ = fallocate(file, FallocateFlags::empty(), 0, file_running_offset as u64);
}
}
for (_, bucket) in current_buckets.into_iter() {
if !bucket.drops.is_empty() {
buckets.push(bucket);
}
}
info!("buckets: {}", buckets.len());
let existing_contexts = self.dropdata.get_contexts();
self.dropdata.set_contexts(
&buckets
.iter()
.flat_map(|x| x.drops.iter().map(|v| v.checksum.clone()))
.map(|x| {
let contains = existing_contexts.get(&x).unwrap_or(&false);
(x, *contains)
})
.collect::<Vec<(String, bool)>>(),
);
*lock!(self.buckets) = buckets;
Ok(())
}
fn run(&self) -> Result<bool, RemoteAccessError> {
self.setup_progress();
let max_download_threads = borrow_db_checked().settings.max_download_threads;
debug!(
"downloading game: {} with {} threads",
self.id, max_download_threads
);
let pool = ThreadPoolBuilder::new()
.num_threads(max_download_threads)
.build()
.unwrap_or_else(|_| {
panic!("failed to build thread pool with {max_download_threads} threads")
});
let buckets = lock!(self.buckets);
let mut download_contexts = HashMap::<String, DownloadContext>::new();
let versions = buckets
.iter()
.map(|e| &e.version)
.collect::<HashSet<_>>()
.into_iter()
.cloned()
.collect::<Vec<String>>();
info!("downloading across these versions: {versions:?}");
let completed_contexts = Arc::new(boxcar::Vec::new());
let completed_indexes_loop_arc = completed_contexts.clone();
for version in versions {
let download_context = DROP_CLIENT_SYNC
.post(generate_url(&["/api/v2/client/context"], &[])?)
.json(&ManifestBody {
game: self.id.clone(),
version: version.clone(),
})
.header("Authorization", generate_authorization_header())
.send()?;
if download_context.status() != 200 {
return Err(RemoteAccessError::InvalidResponse(download_context.json()?));
}
let download_context = download_context.json::<DownloadContext>()?;
info!(
"download context: ({}) {}",
&version, download_context.context
);
download_contexts.insert(version, download_context);
}
let download_contexts = &download_contexts;
pool.scope(|scope| {
let context_map = lock!(self.context_map);
for (index, bucket) in buckets.iter().enumerate() {
let mut bucket = (*bucket).clone();
let completed_contexts = completed_indexes_loop_arc.clone();
let progress = self.progress.get(index);
let progress_handle = ProgressHandle::new(progress, self.progress.clone());
// If we've done this one already, skip it
// Note to future DecDuck, DropData gets loaded into context_map
let todo_drops = bucket
.drops
.into_iter()
.filter(|e| {
let todo = !*context_map.get(&e.checksum).unwrap_or(&false);
if !todo {
progress_handle.skip(e.length);
}
todo
})
.collect::<Vec<DownloadDrop>>();
if todo_drops.is_empty() {
continue;
};
bucket.drops = todo_drops;
let sender = self.sender.clone();
let download_context =
download_contexts.get(&bucket.version).unwrap_or_else(|| {
panic!(
"Could not get bucket version {}. Corrupted state.",
bucket.version
)
});
scope.spawn(move |_| {
// 3 attempts
for i in 0..RETRY_COUNT {
let loop_progress_handle = progress_handle.clone();
match download_game_bucket(
&bucket,
download_context,
&self.control_flag,
loop_progress_handle,
) {
Ok(true) => {
for drop in bucket.drops {
completed_contexts.push(drop.checksum);
}
return;
}
Ok(false) => return,
Err(e) => {
warn!("game download agent error: {e}");
let retry = matches!(
&e,
ApplicationDownloadError::Communication(_)
| ApplicationDownloadError::Checksum
| ApplicationDownloadError::Lock
| ApplicationDownloadError::IoError(_)
);
if i == RETRY_COUNT - 1 || !retry {
warn!("retry logic failed, not re-attempting.");
send!(sender, DownloadManagerSignal::Error(e));
return;
}
}
}
}
});
}
});
let newly_completed = completed_contexts.clone();
let completed_lock_len = {
let mut context_map_lock = lock!(self.context_map);
for (_, item) in newly_completed.iter() {
context_map_lock.insert(item.clone(), true);
}
context_map_lock.values().filter(|x| **x).count()
};
let context_map_lock = lock!(self.context_map);
let contexts = buckets
.iter()
.flat_map(|x| x.drops.iter().map(|e| e.checksum.clone()))
.map(|x| {
let completed = context_map_lock.get(&x).unwrap_or(&false);
(x, *completed)
})
.collect::<Vec<(String, bool)>>();
drop(context_map_lock);
self.dropdata.set_contexts(&contexts);
self.dropdata.write();
// If there are any contexts left which are false
if !contexts.iter().all(|x| x.1) {
info!(
"download agent for {} exited without completing ({}/{}) ({} buckets)",
self.id.clone(),
completed_lock_len,
contexts.len(),
buckets.len()
);
return Ok(false);
}
Ok(true)
}
fn setup_validate(&self, app_handle: &AppHandle) {
self.setup_progress();
self.control_flag.set(DownloadThreadControlFlag::Go);
let status = ApplicationTransientStatus::Validating {
version_name: self.version.clone(),
};
let mut db_lock = borrow_db_mut_checked();
db_lock
.applications
.transient_statuses
.insert(self.metadata(), status.clone());
push_game_update(app_handle, &self.metadata().id, None, (None, Some(status)));
}
pub fn validate(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
self.setup_validate(app_handle);
let buckets = lock!(self.buckets);
let contexts: Vec<DropValidateContext> = buckets
.clone()
.into_iter()
.flat_map(|e| -> Vec<DropValidateContext> { e.into() })
.collect();
let max_download_threads = borrow_db_checked().settings.max_download_threads;
info!("{} validation contexts", contexts.len());
let pool = ThreadPoolBuilder::new()
.num_threads(max_download_threads)
.build()
.unwrap_or_else(|_| {
panic!("failed to build thread pool with {max_download_threads} threads")
});
let invalid_chunks = Arc::new(boxcar::Vec::new());
pool.scope(|scope| {
for (index, context) in contexts.iter().enumerate() {
let current_progress = self.progress.get(index);
let progress_handle = ProgressHandle::new(current_progress, self.progress.clone());
let invalid_chunks_scoped = invalid_chunks.clone();
let sender = self.sender.clone();
scope.spawn(move |_| {
match validate_game_chunk(context, &self.control_flag, progress_handle) {
Ok(true) => {}
Ok(false) => {
invalid_chunks_scoped.push(context.checksum.clone());
}
Err(e) => {
error!("{e}");
send!(sender, DownloadManagerSignal::Error(e));
}
}
});
}
});
// If there are any contexts left which are false
if !invalid_chunks.is_empty() {
info!("validation of game id {} failed", self.id);
for context in invalid_chunks.iter() {
self.dropdata.set_context(context.1.clone(), false);
}
self.dropdata.write();
return Ok(false);
}
Ok(true)
}
pub fn cancel(&self, app_handle: &AppHandle) {
// See docs on usage
set_partially_installed(
&self.metadata(),
self.dropdata.base_path.display().to_string(),
Some(app_handle),
);
self.dropdata.write();
}
}
impl Downloadable for GameDownloadAgent {
fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
*lock!(self.status) = DownloadStatus::Downloading;
self.download(app_handle)
}
fn validate(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
*lock!(self.status) = DownloadStatus::Validating;
self.validate(app_handle)
}
fn progress(&self) -> Arc<ProgressObject> {
self.progress.clone()
}
fn control_flag(&self) -> DownloadThreadControl {
self.control_flag.clone()
}
fn metadata(&self) -> DownloadableMetadata {
DownloadableMetadata {
id: self.id.clone(),
version: Some(self.version.clone()),
download_type: DownloadType::Game,
}
}
fn on_queued(&self, app_handle: &tauri::AppHandle) {
*self.status.lock().unwrap() = DownloadStatus::Queued;
let mut db_lock = borrow_db_mut_checked();
let status = ApplicationTransientStatus::Queued {
version_name: self.version.clone(),
};
db_lock
.applications
.transient_statuses
.insert(self.metadata(), status.clone());
push_game_update(app_handle, &self.id, None, (None, Some(status)));
}
fn on_error(&self, app_handle: &tauri::AppHandle, error: &ApplicationDownloadError) {
*lock!(self.status) = DownloadStatus::Error;
app_emit!(app_handle, "download_error", error.to_string());
error!("error while managing download: {error:?}");
let mut handle = borrow_db_mut_checked();
handle
.applications
.transient_statuses
.remove(&self.metadata());
push_game_update(
app_handle,
&self.id,
None,
GameStatusManager::fetch_state(&self.id, &handle),
);
}
fn on_complete(&self, app_handle: &tauri::AppHandle) {
match on_game_complete(
&self.metadata(),
self.dropdata.base_path.to_string_lossy().to_string(),
app_handle,
) {
Ok(_) => {}
Err(e) => {
error!("could not mark game as complete: {e}");
send!(
self.sender,
DownloadManagerSignal::Error(ApplicationDownloadError::DownloadError(e))
);
}
}
}
fn on_cancelled(&self, app_handle: &tauri::AppHandle) {
info!("cancelled {}", self.id);
self.cancel(app_handle);
}
fn status(&self) -> DownloadStatus {
lock!(self.status).clone()
}
}

View File

@ -0,0 +1,293 @@
use std::fs::{Permissions, set_permissions};
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;
use std::time::Instant;
use std::{
fs::{File, OpenOptions},
io::{self, BufWriter, Seek, SeekFrom, Write},
path::PathBuf,
};
use download_manager::error::ApplicationDownloadError;
use download_manager::util::download_thread_control_flag::{
DownloadThreadControl, DownloadThreadControlFlag,
};
use download_manager::util::progress_object::ProgressHandle;
use drop_consts::{BUMP_SIZE, MAX_PACKET_LENGTH};
use log::{debug, info, warn};
use md5::{Context, Digest};
use remote::auth::generate_authorization_header;
use remote::error::{DropServerError, RemoteAccessError};
use remote::requests::generate_url;
use remote::utils::DROP_CLIENT_SYNC;
use reqwest::blocking::Response;
use crate::downloads::manifest::{ChunkBody, DownloadBucket, DownloadContext, DownloadDrop};
pub struct DropWriter<W: Write> {
hasher: Context,
destination: BufWriter<W>,
progress: ProgressHandle,
}
impl DropWriter<File> {
fn new(path: PathBuf, progress: ProgressHandle) -> Result<Self, io::Error> {
let destination = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
Ok(Self {
destination: BufWriter::with_capacity(1024 * 1024, destination),
hasher: Context::new(),
progress,
})
}
fn finish(mut self) -> io::Result<Digest> {
self.flush()?;
Ok(self.hasher.finalize())
}
}
// Write automatically pushes to file and hasher
impl Write for DropWriter<File> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.hasher
.write_all(buf)
.map_err(|e| io::Error::other(format!("Unable to write to hasher: {e}")))?;
let bytes_written = self.destination.write(buf)?;
self.progress.add(bytes_written);
Ok(bytes_written)
}
fn flush(&mut self) -> io::Result<()> {
self.hasher.flush()?;
self.destination.flush()
}
}
// Seek moves around destination output
impl Seek for DropWriter<File> {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
self.destination.seek(pos)
}
}
pub struct DropDownloadPipeline<'a, R: Read, W: Write> {
pub source: R,
pub drops: Vec<DownloadDrop>,
pub destination: Vec<DropWriter<W>>,
pub control_flag: &'a DownloadThreadControl,
#[allow(dead_code)]
progress: ProgressHandle,
}
impl<'a> DropDownloadPipeline<'a, Response, File> {
fn new(
source: Response,
drops: Vec<DownloadDrop>,
control_flag: &'a DownloadThreadControl,
progress: ProgressHandle,
) -> Result<Self, io::Error> {
Ok(Self {
source,
destination: drops
.iter()
.map(|drop| DropWriter::new(drop.path.clone(), progress.clone()))
.try_collect()?,
drops,
control_flag,
progress,
})
}
fn copy(&mut self) -> Result<bool, io::Error> {
let mut copy_buffer = [0u8; MAX_PACKET_LENGTH];
for (index, drop) in self.drops.iter().enumerate() {
let destination = self
.destination
.get_mut(index)
.ok_or(io::Error::other("no destination"))?;
let mut remaining = drop.length;
if drop.start != 0 {
destination.seek(SeekFrom::Start(drop.start as u64))?;
}
let mut last_bump = 0;
loop {
let size = MAX_PACKET_LENGTH.min(remaining);
let size = self
.source
.read(&mut copy_buffer[0..size])
.inspect_err(|_| {
info!("got error from {}", drop.filename);
})?;
remaining -= size;
last_bump += size;
destination.write_all(&copy_buffer[0..size])?;
if last_bump > BUMP_SIZE {
last_bump -= BUMP_SIZE;
if self.control_flag.get() == DownloadThreadControlFlag::Stop {
return Ok(false);
}
}
if remaining == 0 {
break;
};
}
if self.control_flag.get() == DownloadThreadControlFlag::Stop {
return Ok(false);
}
}
Ok(true)
}
#[allow(dead_code)]
fn debug_skip_checksum(self) {
self.destination
.into_iter()
.for_each(|mut e| e.flush().unwrap());
}
fn finish(self) -> Result<Vec<Digest>, io::Error> {
let checksums = self
.destination
.into_iter()
.map(|e| e.finish())
.try_collect()?;
Ok(checksums)
}
}
pub fn download_game_bucket(
bucket: &DownloadBucket,
ctx: &DownloadContext,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
) -> Result<bool, ApplicationDownloadError> {
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
return Ok(false);
}
let start = Instant::now();
let header = generate_authorization_header();
let url = generate_url(&["/api/v2/client/chunk"], &[])
.map_err(ApplicationDownloadError::Communication)?;
let body = ChunkBody::create(ctx, &bucket.drops);
let response = DROP_CLIENT_SYNC
.post(url)
.json(&body)
.header("Authorization", header)
.send()
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if response.status() != 200 {
info!("chunk request got status code: {}", response.status());
let raw_res = response.text().map_err(|e| {
ApplicationDownloadError::Communication(RemoteAccessError::FetchError(e.into()))
})?;
info!("{raw_res}");
if let Ok(err) = serde_json::from_str::<DropServerError>(&raw_res) {
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::InvalidResponse(err),
));
}
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::UnparseableResponse(raw_res),
));
}
let lengths = response
.headers()
.get("Content-Lengths")
.ok_or(ApplicationDownloadError::Communication(
RemoteAccessError::UnparseableResponse("missing Content-Lengths header".to_owned()),
))?
.to_str()
.map_err(|e| {
ApplicationDownloadError::Communication(RemoteAccessError::UnparseableResponse(
e.to_string(),
))
})?;
for (i, raw_length) in lengths.split(",").enumerate() {
let length = raw_length.parse::<usize>().unwrap_or(0);
let Some(drop) = bucket.drops.get(i) else {
warn!("invalid number of Content-Lengths recieved: {i}, {lengths}");
return Err(ApplicationDownloadError::DownloadError(
RemoteAccessError::InvalidResponse(DropServerError {
status_code: 400,
status_message: format!(
"invalid number of Content-Lengths recieved: {i}, {lengths}"
),
}),
));
};
if drop.length != length {
warn!(
"for {}, expected {}, got {} ({})",
drop.filename, drop.length, raw_length, length
);
return Err(ApplicationDownloadError::DownloadError(
RemoteAccessError::InvalidResponse(DropServerError {
status_code: 400,
status_message: format!(
"for {}, expected {}, got {} ({})",
drop.filename, drop.length, raw_length, length
),
}),
));
}
}
let timestep = start.elapsed().as_millis();
debug!("took {}ms to start downloading", timestep);
let mut pipeline =
DropDownloadPipeline::new(response, bucket.drops.clone(), control_flag, progress)
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
let completed = pipeline
.copy()
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
if !completed {
return Ok(false);
}
// If we complete the file, set the permissions (if on Linux)
#[cfg(unix)]
{
for drop in bucket.drops.iter() {
let permissions = Permissions::from_mode(drop.permissions);
set_permissions(drop.path.clone(), permissions)
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
}
}
let checksums = pipeline
.finish()
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
for (index, drop) in bucket.drops.iter().enumerate() {
let res = hex::encode(**checksums.get(index).unwrap());
if res != drop.checksum {
warn!("context didn't match... doing nothing because we will validate later.");
// return Ok(false);
// return Err(ApplicationDownloadError::Checksum);
}
}
Ok(true)
}

View File

@ -0,0 +1,93 @@
use std::{
collections::HashMap,
fs::File,
io::{self, Read, Write},
path::{Path, PathBuf},
};
use drop_consts::DROP_DATA_PATH;
use log::error;
use native_model::{Decode, Encode};
use utils::lock;
pub type DropData = v1::DropData;
pub mod v1 {
use std::{collections::HashMap, path::PathBuf, sync::Mutex};
use native_model::native_model;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
#[native_model(id = 9, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct DropData {
pub game_id: String,
pub game_version: String,
pub contexts: Mutex<HashMap<String, bool>>,
pub base_path: PathBuf,
}
impl DropData {
pub fn new(game_id: String, game_version: String, base_path: PathBuf) -> Self {
Self {
base_path,
game_id,
game_version,
contexts: Mutex::new(HashMap::new()),
}
}
}
}
impl DropData {
pub fn generate(game_id: String, game_version: String, base_path: PathBuf) -> Self {
match DropData::read(&base_path) {
Ok(v) => v,
Err(_) => DropData::new(game_id, game_version, base_path),
}
}
pub fn read(base_path: &Path) -> Result<Self, io::Error> {
let mut file = File::open(base_path.join(DROP_DATA_PATH))?;
let mut s = Vec::new();
file.read_to_end(&mut s)?;
native_model::rmp_serde_1_3::RmpSerde::decode(s).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to decode drop data: {e}"),
)
})
}
pub fn write(&self) {
let manifest_raw = match native_model::rmp_serde_1_3::RmpSerde::encode(&self) {
Ok(data) => data,
Err(_) => return,
};
let mut file = match File::create(self.base_path.join(DROP_DATA_PATH)) {
Ok(file) => file,
Err(e) => {
error!("{e}");
return;
}
};
match file.write_all(&manifest_raw) {
Ok(()) => {}
Err(e) => error!("{e}"),
}
}
pub fn set_contexts(&self, completed_contexts: &[(String, bool)]) {
*lock!(self.contexts) = completed_contexts
.iter()
.map(|s| (s.0.clone(), s.1))
.collect();
}
pub fn set_context(&self, context: String, state: bool) {
lock!(self.contexts).entry(context).insert_entry(state);
}
pub fn get_contexts(&self) -> HashMap<String, bool> {
lock!(self.contexts).clone()
}
}

View File

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

View File

@ -0,0 +1,98 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize)]
// Drops go in buckets
pub struct DownloadDrop {
pub index: usize,
pub filename: String,
pub path: PathBuf,
pub start: usize,
pub length: usize,
pub checksum: String,
pub permissions: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct DownloadBucket {
pub game_id: String,
pub version: String,
pub drops: Vec<DownloadDrop>,
}
#[derive(Deserialize)]
pub struct DownloadContext {
pub context: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChunkBodyFile {
filename: String,
chunk_index: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChunkBody {
pub context: String,
pub files: Vec<ChunkBodyFile>,
}
#[derive(Serialize)]
pub struct ManifestBody {
pub game: String,
pub version: String,
}
impl ChunkBody {
pub fn create(context: &DownloadContext, drops: &[DownloadDrop]) -> ChunkBody {
Self {
context: context.context.clone(),
files: drops
.iter()
.map(|e| ChunkBodyFile {
filename: e.filename.clone(),
chunk_index: e.index,
})
.collect(),
}
}
}
pub type DropManifest = HashMap<String, DropChunk>;
#[derive(Serialize, Deserialize, Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DropChunk {
pub permissions: u32,
pub ids: Vec<String>,
pub checksums: Vec<String>,
pub lengths: Vec<usize>,
pub version_name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DropValidateContext {
pub index: usize,
pub offset: usize,
pub path: PathBuf,
pub checksum: String,
pub length: usize,
}
impl From<DownloadBucket> for Vec<DropValidateContext> {
fn from(value: DownloadBucket) -> Self {
value
.drops
.into_iter()
.map(|e| DropValidateContext {
index: e.index,
offset: e.start,
path: e.path,
checksum: e.checksum,
length: e.length,
})
.collect()
}
}

View File

@ -0,0 +1,7 @@
pub mod download_agent;
mod download_logic;
pub mod drop_data;
pub mod error;
mod manifest;
pub mod utils;
pub mod validate;

View File

@ -0,0 +1,25 @@
use std::{io, path::PathBuf, sync::Arc};
use download_manager::error::ApplicationDownloadError;
use sysinfo::{Disk, DiskRefreshKind, Disks};
pub fn get_disk_available(mount_point: PathBuf) -> Result<u64, ApplicationDownloadError> {
let disks = Disks::new_with_refreshed_list_specifics(DiskRefreshKind::nothing().with_storage());
let mut disk_iter = disks.into_iter().collect::<Vec<&Disk>>();
disk_iter.sort_by(|a, b| {
b.mount_point()
.to_string_lossy()
.len()
.cmp(&a.mount_point().to_string_lossy().len())
});
for disk in disk_iter {
if mount_point.starts_with(disk.mount_point()) {
return Ok(disk.available_space());
}
}
Err(ApplicationDownloadError::IoError(Arc::new(
io::Error::other("could not find disk of path"),
)))
}

View File

@ -0,0 +1,104 @@
use std::{
fs::File,
io::{self, BufWriter, Read, Seek, SeekFrom, Write},
};
use download_manager::{
error::ApplicationDownloadError,
util::{
download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag},
progress_object::ProgressHandle,
},
};
use log::debug;
use md5::Context;
use crate::downloads::manifest::DropValidateContext;
pub fn validate_game_chunk(
ctx: &DropValidateContext,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
) -> Result<bool, ApplicationDownloadError> {
debug!(
"Starting chunk validation {}, {}, {} #{}",
ctx.path.display(),
ctx.index,
ctx.offset,
ctx.checksum
);
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
return Ok(false);
}
let Ok(mut source) = File::open(&ctx.path) else {
return Ok(false);
};
if ctx.offset != 0 {
source
.seek(SeekFrom::Start(ctx.offset as u64))
.expect("Failed to seek to file offset");
}
let mut hasher = md5::Context::new();
let completed = validate_copy(&mut source, &mut hasher, ctx.length, control_flag, progress)?;
if !completed {
return Ok(false);
}
let res = hex::encode(hasher.finalize().0);
if res != ctx.checksum {
return Ok(false);
}
debug!(
"Successfully finished verification #{}, copied {} bytes",
ctx.checksum, ctx.length
);
Ok(true)
}
fn validate_copy(
source: &mut File,
dest: &mut Context,
size: usize,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
) -> Result<bool, io::Error> {
let copy_buf_size = 512;
let mut copy_buf = vec![0; copy_buf_size];
let mut buf_writer = BufWriter::with_capacity(1024 * 1024, dest);
let mut total_bytes = 0;
loop {
if control_flag.get() == DownloadThreadControlFlag::Stop {
buf_writer.flush()?;
return Ok(false);
}
let mut bytes_read = source.read(&mut copy_buf)?;
total_bytes += bytes_read;
// If we read over (likely), truncate our read to
// the right size
if total_bytes > size {
let over = total_bytes - size;
bytes_read -= over;
total_bytes = size;
}
buf_writer.write_all(&copy_buf[0..bytes_read])?;
progress.add(bytes_read);
if total_bytes >= size {
break;
}
}
buf_writer.flush()?;
Ok(true)
}

7
games/src/lib.rs Normal file
View File

@ -0,0 +1,7 @@
#![feature(iterator_try_collect)]
pub mod collections;
pub mod downloads;
pub mod library;
pub mod scan;
pub mod state;

300
games/src/library.rs Normal file
View File

@ -0,0 +1,300 @@
use bitcode::{Decode, Encode};
use database::{
ApplicationTransientStatus, Database, DownloadableMetadata, GameDownloadStatus, GameVersion,
borrow_db_checked, borrow_db_mut_checked,
};
use log::{debug, error, warn};
use remote::{
auth::generate_authorization_header, error::RemoteAccessError, requests::generate_url,
utils::DROP_CLIENT_SYNC,
};
use serde::{Deserialize, Serialize};
use std::fs::remove_dir_all;
use std::thread::spawn;
use tauri::AppHandle;
use utils::app_emit;
use crate::state::{GameStatusManager, GameStatusWithTransient};
#[derive(Serialize, Deserialize, Debug)]
pub struct FetchGameStruct {
game: Game,
status: GameStatusWithTransient,
version: Option<GameVersion>,
}
impl FetchGameStruct {
pub fn new(game: Game, status: GameStatusWithTransient, version: Option<GameVersion>) -> Self {
Self {
game,
status,
version,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, Encode, Decode)]
#[serde(rename_all = "camelCase")]
pub struct Game {
id: String,
m_name: String,
m_short_description: String,
m_description: String,
// mDevelopers
// mPublishers
m_icon_object_id: String,
m_banner_object_id: String,
m_cover_object_id: String,
m_image_library_object_ids: Vec<String>,
m_image_carousel_object_ids: Vec<String>,
}
impl Game {
pub fn id(&self) -> &String {
&self.id
}
}
#[derive(serde::Serialize, Clone)]
pub struct GameUpdateEvent {
pub game_id: String,
pub status: (
Option<GameDownloadStatus>,
Option<ApplicationTransientStatus>,
),
pub version: Option<GameVersion>,
}
/**
* Called by:
* - on_cancel, when cancelled, for obvious reasons
* - when downloading, so if drop unexpectedly quits, we can resume the download. hidden by the "Downloading..." transient state, though
* - when scanning, to import the game
*/
pub fn set_partially_installed(
meta: &DownloadableMetadata,
install_dir: String,
app_handle: Option<&AppHandle>,
) {
set_partially_installed_db(&mut borrow_db_mut_checked(), meta, install_dir, app_handle);
}
pub fn set_partially_installed_db(
db_lock: &mut Database,
meta: &DownloadableMetadata,
install_dir: String,
app_handle: Option<&AppHandle>,
) {
db_lock.applications.transient_statuses.remove(meta);
db_lock.applications.game_statuses.insert(
meta.id.clone(),
GameDownloadStatus::PartiallyInstalled {
version_name: meta.version.as_ref().unwrap().clone(),
install_dir,
},
);
db_lock
.applications
.installed_game_version
.insert(meta.id.clone(), meta.clone());
if let Some(app_handle) = app_handle {
push_game_update(
app_handle,
&meta.id,
None,
GameStatusManager::fetch_state(&meta.id, db_lock),
);
}
}
pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) {
debug!("triggered uninstall for agent");
let mut db_handle = borrow_db_mut_checked();
db_handle
.applications
.transient_statuses
.insert(meta.clone(), ApplicationTransientStatus::Uninstalling {});
push_game_update(
app_handle,
&meta.id,
None,
GameStatusManager::fetch_state(&meta.id, &db_handle),
);
let previous_state = db_handle.applications.game_statuses.get(&meta.id).cloned();
let previous_state = if let Some(state) = previous_state {
state
} else {
warn!("uninstall job doesn't have previous state, failing silently");
return;
};
if let Some((_, install_dir)) = match previous_state {
GameDownloadStatus::Installed {
version_name,
install_dir,
} => Some((version_name, install_dir)),
GameDownloadStatus::SetupRequired {
version_name,
install_dir,
} => Some((version_name, install_dir)),
GameDownloadStatus::PartiallyInstalled {
version_name,
install_dir,
} => Some((version_name, install_dir)),
_ => None,
} {
db_handle
.applications
.transient_statuses
.insert(meta.clone(), ApplicationTransientStatus::Uninstalling {});
drop(db_handle);
let app_handle = app_handle.clone();
spawn(move || {
if let Err(e) = remove_dir_all(install_dir) {
error!("{e}");
} else {
let mut db_handle = borrow_db_mut_checked();
db_handle.applications.transient_statuses.remove(&meta);
db_handle
.applications
.installed_game_version
.remove(&meta.id);
db_handle
.applications
.game_statuses
.insert(meta.id.clone(), GameDownloadStatus::Remote {});
let _ = db_handle.applications.transient_statuses.remove(&meta);
push_game_update(
&app_handle,
&meta.id,
None,
GameStatusManager::fetch_state(&meta.id, &db_handle),
);
debug!("uninstalled game id {}", &meta.id);
app_emit!(&app_handle, "update_library", ());
}
});
} else {
warn!("invalid previous state for uninstall, failing silently.");
}
}
pub fn get_current_meta(game_id: &String) -> Option<DownloadableMetadata> {
borrow_db_checked()
.applications
.installed_game_version
.get(game_id)
.cloned()
}
pub fn on_game_complete(
meta: &DownloadableMetadata,
install_dir: String,
app_handle: &AppHandle,
) -> Result<(), RemoteAccessError> {
// Fetch game version information from remote
if meta.version.is_none() {
return Err(RemoteAccessError::GameNotFound(meta.id.clone()));
}
let client = DROP_CLIENT_SYNC.clone();
let response = generate_url(
&["/api/v1/client/game/version"],
&[
("id", &meta.id),
("version", meta.version.as_ref().unwrap()),
],
)?;
let response = client
.get(response)
.header("Authorization", generate_authorization_header())
.send()?;
let game_version: GameVersion = response.json()?;
let mut handle = borrow_db_mut_checked();
handle
.applications
.game_versions
.entry(meta.id.clone())
.or_default()
.insert(meta.version.clone().unwrap(), game_version.clone());
handle
.applications
.installed_game_version
.insert(meta.id.clone(), meta.clone());
drop(handle);
let status = if game_version.setup_command.is_empty() {
GameDownloadStatus::Installed {
version_name: meta.version.clone().unwrap(),
install_dir,
}
} else {
GameDownloadStatus::SetupRequired {
version_name: meta.version.clone().unwrap(),
install_dir,
}
};
let mut db_handle = borrow_db_mut_checked();
db_handle
.applications
.game_statuses
.insert(meta.id.clone(), status.clone());
drop(db_handle);
app_emit!(
app_handle,
&format!("update_game/{}", meta.id),
GameUpdateEvent {
game_id: meta.id.clone(),
status: (Some(status), None),
version: Some(game_version),
}
);
Ok(())
}
pub fn push_game_update(
app_handle: &AppHandle,
game_id: &String,
version: Option<GameVersion>,
status: GameStatusWithTransient,
) {
if let Some(GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. }) =
&status.0
&& version.is_none()
{
panic!("pushed game for installed game that doesn't have version information");
}
app_emit!(
app_handle,
&format!("update_game/{game_id}"),
GameUpdateEvent {
game_id: game_id.clone(),
status,
version,
}
);
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontendGameOptions {
launch_string: String,
}
impl FrontendGameOptions {
pub fn launch_string(&self) -> &String {
&self.launch_string
}
}

48
games/src/scan.rs Normal file
View File

@ -0,0 +1,48 @@
use std::fs;
use database::{DownloadType, DownloadableMetadata, borrow_db_mut_checked};
use drop_consts::DROP_DATA_PATH;
use log::warn;
use crate::{
downloads::drop_data::DropData,
library::set_partially_installed_db,
};
pub fn scan_install_dirs() {
let mut db_lock = borrow_db_mut_checked();
for install_dir in db_lock.applications.install_dirs.clone() {
let Ok(files) = fs::read_dir(install_dir) else {
continue;
};
for game in files.into_iter().flatten() {
let drop_data_file = game.path().join(DROP_DATA_PATH);
if !drop_data_file.exists() {
continue;
}
let game_id = game.file_name().display().to_string();
let Ok(drop_data) = DropData::read(&game.path()) else {
warn!(
".dropdata exists for {}, but couldn't read it. is it corrupted?",
game.file_name().display()
);
continue;
};
if db_lock.applications.game_statuses.contains_key(&game_id) {
continue;
}
let metadata = DownloadableMetadata::new(
drop_data.game_id,
Some(drop_data.game_version),
DownloadType::Game,
);
set_partially_installed_db(
&mut db_lock,
&metadata,
drop_data.base_path.to_str().unwrap().to_string(),
None,
);
}
}
}

35
games/src/state.rs Normal file
View File

@ -0,0 +1,35 @@
use database::models::data::{
ApplicationTransientStatus, Database, DownloadType, DownloadableMetadata, GameDownloadStatus,
};
pub type GameStatusWithTransient = (
Option<GameDownloadStatus>,
Option<ApplicationTransientStatus>,
);
pub struct GameStatusManager {}
impl GameStatusManager {
pub fn fetch_state(game_id: &String, database: &Database) -> GameStatusWithTransient {
let online_state = database
.applications
.transient_statuses
.get(&DownloadableMetadata {
id: game_id.to_string(),
download_type: DownloadType::Game,
version: None,
})
.cloned();
let offline_state = database.applications.game_statuses.get(game_id).cloned();
if online_state.is_some() {
return (None, online_state);
}
if offline_state.is_some() {
return (offline_state, None);
}
(None, None)
}
}

View File

@ -1,12 +0,0 @@
<template>
<div class="flex flex-col bg-zinc-900 overflow-hidden">
<Header class="select-none" />
<div class="grow overflow-y-auto">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
const queueState = useQueueState();
</script>

View File

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

1
libs/drop-base Submodule

Submodule libs/drop-base added at 04125e89be

50
main/app.vue Normal file
View File

@ -0,0 +1,50 @@
<template>
<NuxtLoadingIndicator color="#2563eb" />
<NuxtLayout class="select-none w-screen h-screen">
<NuxtPage />
<ModalStack />
</NuxtLayout>
</template>
<script setup lang="ts">
import "~/composables/downloads.js";
import { invoke } from "@tauri-apps/api/core";
import { useAppState } from "./composables/app-state.js";
import {
initialNavigation,
setupHooks,
} from "./composables/state-navigation.js";
const router = useRouter();
const state = useAppState();
async function fetchState() {
try {
state.value = JSON.parse(await invoke("fetch_state"));
if (!state.value)
throw createError({
statusCode: 500,
statusMessage: `App state is: ${state.value}`,
fatal: true,
});
} catch (e) {
console.error("failed to parse state", e);
throw e;
}
}
await fetchState();
// This is inefficient but apparently we do it lol
router.beforeEach(async () => {
await fetchState();
});
setupHooks();
initialNavigation(state);
useHead({
title: "Drop",
});
</script>

View File

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

View File

Before

Width:  |  Height:  |  Size: 6.5 MiB

After

Width:  |  Height:  |  Size: 6.5 MiB

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

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<template>
<Menu v-if="state.user" as="div" class="relative inline-block">
<Menu v-if="state?.user" as="div" class="relative inline-block">
<MenuButton>
<HeaderWidget>
<div class="inline-flex items-center text-zinc-300 hover:text-white">
@ -23,7 +23,7 @@
<MenuItems
class="absolute bg-zinc-900 right-0 top-10 z-50 w-56 origin-top-right focus:outline-none shadow-md"
>
<PanelWidget class="flex-col gap-y-2">
<div class="flex-col gap-y-2">
<NuxtLink
to="/id/me"
class="transition inline-flex items-center w-full py-3 px-4 hover:bg-zinc-800"
@ -37,7 +37,7 @@
</NuxtLink>
<div class="h-0.5 rounded-full w-full bg-zinc-800" />
<div class="flex flex-col mb-1">
<MenuItem v-slot="{ active }">
<MenuItem v-if="state.user.admin" v-slot="{ active }">
<a
:href="adminUrl"
target="_blank"
@ -49,20 +49,23 @@
Admin Dashboard
</a>
</MenuItem>
<MenuItem v-for="(nav, navIdx) in navigation" v-slot="{ active, close }">
<MenuItem
v-for="(nav, navIdx) in navigation"
v-slot="{ active, close }"
>
<button
@click="() => navigate(close, nav)"
@click="() => navigate(close, nav)"
:href="nav.route"
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'transition text-left block px-4 py-2 text-sm',
]"
>
{{ nav.label }}</button
>
{{ nav.label }}
</button>
</MenuItem>
</div>
</PanelWidget>
</div>
</MenuItems>
</transition>
</Menu>
@ -80,27 +83,22 @@ const open = ref(false);
const router = useRouter();
router.afterEach(() => {
open.value = false;
})
});
const state = useAppState();
const profilePictureUrl: string = await invoke("gen_drop_url", {
path: `/api/v1/object/${state.value.user?.profilePicture}`,
});
const profilePictureUrl: string = await useObject(
state.value?.user?.profilePictureObjectId ?? ""
);
const adminUrl: string = await invoke("gen_drop_url", {
path: "/admin",
});
function navigate(close: () => any, to: NavigationItem){
function navigate(close: () => any, to: NavigationItem) {
close();
router.push(to.route);
}
const navigation: NavigationItem[] = [
{
label: "Account settings",
route: "/account",
prefix: "",
},
{
label: "App settings",
route: "/settings",
@ -110,6 +108,6 @@ const navigation: NavigationItem[] = [
label: "Quit Drop",
route: "/quit",
prefix: "",
}
]
},
];
</script>

View File

@ -13,11 +13,7 @@
<div class="max-w-lg">
<slot />
<div class="mt-10">
<button
@click="() => authWrapper_wrapper()"
:disabled="loading"
class="text-sm text-left font-semibold leading-7 text-blue-600"
>
<div>
<div v-if="loading" role="status">
<svg
aria-hidden="true"
@ -37,10 +33,44 @@
</svg>
<span class="sr-only">Loading...</span>
</div>
<span v-else>
Sign in with your browser <span aria-hidden="true">&rarr;</span>
<span class="inline-flex gap-x-8 items-center" v-else>
<button
@click="() => authWrapper_wrapper()"
:disabled="loading"
class="px-3 py-1 inline-flex items-center gap-x-2 bg-zinc-700 rounded text-sm text-left font-semibold leading-7 text-white"
>
Sign in with your browser <ArrowTopRightOnSquareIcon class="size-4" />
</button>
<NuxtLink href="/auth/code" class="text-zinc-100 text-sm hover:text-zinc-300">
Use a code &rarr;
</NuxtLink>
</span>
</button>
</div>
<div class="mt-5" v-if="offerManual">
<h1 class="text-zinc-100 font-semibold">Having trouble?</h1>
<p class="mt-1 text-zinc-400 text-sm">
You can manually enter the token from your web browser.
</p>
<div class="inline-flex gap-x-1 mt-2 w-full">
<input
id="token"
name="token"
type="text"
autocomplete="token"
required
class="grow block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
v-model="manualToken"
/>
<LoadingButton
:loading="manualLoading"
@click="() => continueManual_wrapper()"
class="w-fit"
>
Submit
</LoadingButton>
</div>
</div>
<div v-if="error" class="mt-5 rounded-md bg-red-600/10 p-4">
<div class="flex">
@ -96,20 +126,46 @@
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
const loading = ref(false);
const error = ref<string | undefined>();
let offerManualTimeout: NodeJS.Timeout | undefined;
const offerManual = ref(false);
const manualToken = ref("");
const manualLoading = ref(false);
async function auth() {
await invoke("auth_initiate");
}
function authWrapper_wrapper() {
error.value = undefined;
loading.value = true;
auth().catch((e) => {
loading.value = false;
error.value = e;
if (offerManualTimeout) clearTimeout(offerManualTimeout);
});
offerManualTimeout = setTimeout(() => {
offerManual.value = true;
}, 2000);
}
async function continueManual() {
await invoke("manual_recieve_handshake", { token: manualToken.value });
}
function continueManual_wrapper() {
loading.value = true;
continueManual()
.catch((e) => {
error.value = e;
})
.finally(() => {
loading.value = false;
});
}
</script>

View File

@ -0,0 +1,303 @@
<template>
<div class="flex flex-col h-full">
<div class="mb-3 inline-flex gap-x-2">
<div
class="relative 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>
<button
@click="() => calculateGames(true, true)"
class="p-1 flex items-center justify-center transition-transform duration-300 size-10 hover:scale-110 active:scale-90 rounded-lg bg-zinc-800/50 text-zinc-100"
>
<ArrowPathIcon class="size-4" />
</button>
</div>
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5">
<Disclosure
as="div"
v-for="(nav, navIndex) in filteredNavigation"
:key="nav.id"
class="first:pt-0 last:pb-0"
v-slot="{ open }"
:default-open="nav.deft"
>
<dt>
<DisclosureButton
class="flex w-full items-center justify-between text-left text-gray-900 dark:text-white"
>
<span class="text-sm font-semibold font-display">{{
nav.name
}}</span>
<span class="ml-6 flex h-7 items-center">
<PlusSmallIcon v-if="!open" class="size-6" aria-hidden="true" />
<MinusSmallIcon v-else class="size-6" aria-hidden="true" />
</span>
</DisclosureButton>
</dt>
<DisclosurePanel as="dd" class="mt-2 flex flex-col gap-y-1.5">
<NuxtLink
v-for="item in nav.items"
:key="nav.id"
:class="[
'transition-all duration-300 rounded-lg flex items-center px-1 py-1.5 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
currentNavigation == item.id
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
: item.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="item.route"
>
<div class="flex items-center w-full gap-x-2">
<div
class="flex-none transition-transform duration-300 hover:-rotate-2"
>
<img
class="size-6 object-cover bg-zinc-900 rounded transition-all duration-300 shadow-sm"
:src="icons[item.id]"
alt=""
/>
</div>
<div class="inline-flex items-center gap-x-2">
<p
class="text-sm whitespace-nowrap font-display font-semibold"
>
{{ item.label }}
</p>
<p
class="truncate text-[10px] font-bold uppercase font-display"
:class="[
gameStatusTextStyle[games[item.id].status.value.type],
]"
>
{{ gameStatusText[games[item.id].status.value.type] }}
</p>
</div>
</div>
</NuxtLink>
</DisclosurePanel>
</Disclosure>
</TransitionGroup>
<div
v-if="loading"
class="h-full grow flex p-8 justify-center text-zinc-100"
>
<div role="status">
<svg
aria-hidden="true"
class="w-6 h-6 text-transparent animate-spin fill-zinc-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
import {
ArrowPathIcon,
MagnifyingGlassIcon,
MinusSmallIcon,
PlusSmallIcon,
} from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import {
GameStatusEnum,
type Collection as Collection,
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-zinc-400",
[GameStatusEnum.Validating]: "text-blue-300",
[GameStatusEnum.Running]: "text-green-500",
[GameStatusEnum.Remote]: "text-zinc-700",
[GameStatusEnum.Queued]: "text-zinc-400",
[GameStatusEnum.Updating]: "text-zinc-400",
[GameStatusEnum.Uninstalling]: "text-zinc-100",
[GameStatusEnum.SetupRequired]: "text-yellow-500",
[GameStatusEnum.PartiallyInstalled]: "text-gray-400",
};
const gameStatusText: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]: "Not installed",
[GameStatusEnum.Queued]: "Queued",
[GameStatusEnum.Downloading]: "Downloading...",
[GameStatusEnum.Validating]: "Validating...",
[GameStatusEnum.Installed]: "Installed",
[GameStatusEnum.Updating]: "Updating...",
[GameStatusEnum.Uninstalling]: "Uninstalling...",
[GameStatusEnum.SetupRequired]: "Setup required",
[GameStatusEnum.Running]: "Running",
[GameStatusEnum.PartiallyInstalled]: "Partially installed",
};
const router = useRouter();
const searchQuery = ref("");
const loading = ref(false);
const games: {
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
} = {};
const icons: { [key: string]: string } = {};
const collections: Ref<Collection[]> = ref([]);
async function calculateGames(clearAll = false, forceRefresh = false) {
if (clearAll) {
collections.value = [];
loading.value = true;
}
// If we update immediately, the navigation gets re-rendered before we
// add all the necessary state, and it freaks tf out
const newGames = await invoke<Game[]>("fetch_library", {
hardRefresh: forceRefresh,
});
const otherCollections = await invoke<Collection[]>("fetch_collections", {
hardRefresh: forceRefresh,
});
const allGames = [
...newGames,
...otherCollections
.map((e) => e.entries)
.flat()
.map((e) => e.game),
].filter((v, i, a) => a.indexOf(v) === i);
for (const game of allGames) {
if (games[game.id]) continue;
games[game.id] = await useGame(game.id);
}
for (const game of allGames) {
if (icons[game.id]) continue;
icons[game.id] = await useObject(game.mIconObjectId);
}
const libraryCollection = {
id: "library",
name: "Library",
isDefault: true,
entries: newGames.map((e) => ({ gameId: e.id, game: e })),
} satisfies Collection;
loading.value = false;
collections.value = [libraryCollection, ...otherCollections];
}
// Wait up to 300 ms for the library to load, otherwise
// show the loading state while we while
await new Promise<void>((r) => {
let hasResolved = false;
const resolveFunc = () => {
if (!hasResolved) r();
hasResolved = true;
};
calculateGames(true).then(resolveFunc);
setTimeout(resolveFunc, 300);
});
const navigation = computed(() =>
collections.value.map((collection) => {
const items = collection.entries.map(({ game }) => {
const status = games[game.id].status;
const isInstalled = computed(
() => status.value.type != GameStatusEnum.Remote
);
const item = {
label: game.mName,
route: `/library/${game.id}`,
prefix: `/library/${game.id}`,
isInstalled,
id: game.id,
};
return item;
});
return {
id: collection.id,
name: collection.name,
deft: collection.isDefault,
items,
};
})
);
const route = useRoute();
const currentNavigation = computed(() => {
return route.path.slice("/library/".length);
});
const filteredNavigation = computed(() => {
if (!searchQuery.value)
return navigation.value.map((e, i) => ({ ...e, index: i }));
const query = searchQuery.value.toLowerCase();
return navigation.value
.map((c) => ({
...c,
items: c.items.filter((nav) => nav.label.toLowerCase().includes(query)),
}))
.filter((e) => e.items.length > 0);
});
listen("update_library", async (event) => {
console.log("Updating library");
let oldNavigation = currentNavigation.value;
await calculateGames();
if (oldNavigation !== currentNavigation.value) {
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

@ -1,6 +1,6 @@
<template>
<div
class="h-10 cursor-pointer flex flex-row items-center justify-between bg-zinc-950"
class="h-16 cursor-pointer flex flex-row items-center justify-between bg-zinc-950"
>
<div class="px-5 py-3 grow" @mousedown="() => window.startDragging()">
<Wordmark class="mt-1" />

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
import type { AppState } from "~/types";
export const useAppState = () => useState<AppState | undefined>("state");

View File

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

View File

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

74
main/composables/game.ts Normal file
View File

@ -0,0 +1,74 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Game, GameStatus, GameStatusEnum, GameVersion } from "~/types";
const gameRegistry: { [key: string]: { game: Game; version?: GameVersion } } =
{};
const gameStatusRegistry: { [key: string]: Ref<GameStatus> } = {};
type OptionGameStatus = { [key in GameStatusEnum]: { version_name?: string } };
export type SerializedGameStatus = [
{ type: GameStatusEnum },
OptionGameStatus | null
];
export const parseStatus = (status: SerializedGameStatus): GameStatus => {
if (status[0]) {
return {
type: status[0].type,
};
} else if (status[1]) {
const [[gameStatus, options]] = Object.entries(status[1]);
return {
type: gameStatus as GameStatusEnum,
...options,
};
} else {
throw new Error("No game status");
}
};
export const useGame = async (gameId: string) => {
if (!gameRegistry[gameId]) {
const data: {
game: Game;
status: SerializedGameStatus;
version?: GameVersion;
} = await invoke("fetch_game", {
gameId,
});
gameRegistry[gameId] = { game: data.game, version: data.version };
if (!gameStatusRegistry[gameId]) {
gameStatusRegistry[gameId] = ref(parseStatus(data.status));
listen(`update_game/${gameId}`, (event) => {
console.log(event);
const payload: {
status: SerializedGameStatus;
version?: GameVersion;
} = event.payload as any;
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 status = gameStatusRegistry[gameId];
return { ...game, status };
};
export type FrontendGameConfiguration = {
launchString: string;
};

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