Compare commits

...

24 Commits

Author SHA1 Message Date
989e99dc7e Update .github/workflows/release.yml
Some checks failed
Clippy check / clippy_check (push) Failing after 38s
2026-01-30 03:47:56 +01:00
DecDuck
5ad005161f Depot API & executor launch (#173)
Some checks failed
publish / publish-tauri (, ubuntu-22.04) (release) Failing after 14m38s
publish / publish-tauri (, windows-latest) (release) Has been cancelled
publish / publish-tauri (--target aarch64-apple-darwin, macos-14) (release) Has been cancelled
publish / publish-tauri (--target aarch64-unknown-linux-gnu, ubuntu-22.04-arm) (release) Has been cancelled
publish / publish-tauri (--target x86_64-apple-darwin, macos-14) (release) Has been cancelled
* feat: depot api downloads

* feat: frontend fixes and experimental webview store

* feat: sync downloader

* feat: cleanup and fixes

* feat: encrypted database and fixed resuming

* feat: launch option selector

* fix: autostart when no options

* fix: clippy

* fix: clippy x2

* feat: executor launch

* feat: executor launch

* feat: not installed error handling

* feat: better offline handling

* feat: dependency popup

* fix: cancelation and resuming issues

* feat: dedup by platform

* feat: new ui for additional components and fix dl manager clog

* feat: auto-queue dependencies

* feat: depot scanning and ranking

* feat: new library fetching stack

* In-app store page (Windows + macOS) (#176)

* feat: async store loading

* feat: fix overscroll behaviour

* fix: query params in server protocol

* fix: clippy
2026-01-20 11:40:48 +11:00
DecDuck
cc086a9f16 Fix incompatiblities with server/develop 2026-01-11 13:03:35 +11:00
DecDuck
f41df6531c Fix server error messages 2025-11-30 23:13:01 +11:00
DecDuck
28d7a741c1 Move to pnpm to fix builds 2025-11-19 23:02:20 +11:00
DecDuck
490a689497 Fix Apple signing 2025-11-19 22:14:57 +11:00
DecDuck
84f4210479 Bump version 2025-11-15 09:09:58 +11:00
quexeky
a2d1a989e0 Fix folders not opening (#162) 2025-10-17 15:27:32 +11:00
DecDuck
83d2301056 Fix macOS build 2025-10-16 15:32:28 +11:00
quexeky
87bbe1da49 156 refactor into workspaces (#157)
* 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>

* fix: Remote tauri dependency from process

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

* refactor: Improvements to src-tauri

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

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

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

* refactor: Finish refactor

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

* chore: Run cargo clippy && cargo fmt

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

* refactor: Move everything into src-tauri

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

---------

Signed-off-by: quexeky <git@quexeky.dev>
2025-10-14 17:12:51 +11:00
quexeky
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
DecDuck
70cecdad19 Update README.md 2025-09-11 08:16:33 +10:00
DecDuck
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
quexeky
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
DecDuck
7e70a17a43 Bump version to v0.3.3 2025-08-28 18:23:12 +10:00
DecDuck
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
DecDuck
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
quexeky
4f5fccf0c1 Add umu-run discovery (#122)
Signed-off-by: quexeky <git@quexeky.dev>
2025-08-28 18:05:05 +10:00
DecDuck
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
DecDuck
ec6294b8e7 Fix #117 (#118)
* fix: version data not being attached to process manager push

* fix: clippy
2025-08-15 23:02:01 +10:00
DecDuck
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
DecDuck
cb55ac2bf5 Fix platform builds 2025-08-12 15:08:50 +10:00
DecDuck
e11db851a5 fix: #92 (#115) 2025-08-11 14:37:46 +10:00
DecDuck
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
163 changed files with 27505 additions and 14463 deletions

View File

@@ -1,4 +1,4 @@
name: 'publish' name: "publish"
on: on:
workflow_dispatch: {} workflow_dispatch: {}
@@ -18,16 +18,16 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- platform: 'macos-latest' # for Arm based macs (M1 and above). - platform: "macos-14" # for Arm based macs (M1 and above).
args: '--target aarch64-apple-darwin' args: "--target aarch64-apple-darwin"
- platform: 'macos-latest' # for Intel based macs. - platform: "macos-14" # for Intel based macs.
args: '--target x86_64-apple-darwin' args: "--target x86_64-apple-darwin"
- platform: 'ubuntu-22.04' # for Tauri v1 you could replace this with ubuntu-20.04. - platform: "ubuntu-22.04" # for Tauri v1 you could replace this with ubuntu-20.04.
args: '' args: ""
- platform: 'ubuntu-22.04-arm' - platform: "ubuntu-22.04-arm"
args: '--target aarch64-unknown-linux-gnu' args: "--target aarch64-unknown-linux-gnu"
- platform: 'windows-latest' - platform: "windows-latest"
args: '' args: ""
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
steps: steps:
@@ -36,27 +36,39 @@ jobs:
submodules: true submodules: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: setup node - name: setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: lts/* node-version: lts/*
cache: pnpm
- name: install Rust nightly - name: install Rust nightly
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@nightly
with: with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds. # 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' || '' }} targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
- name: install dependencies (ubuntu only) - 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. if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm' # This must match the platform value defined above.
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 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. # webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
- name: Import Apple Developer Certificate - name: Import Apple Developer Certificate
if: matrix.platform == 'macos-latest' if: matrix.platform == 'macos-14'
env: env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -67,18 +79,30 @@ jobs:
security default-keychain -s build.keychain security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain security set-keychain-settings -t 3600 -u build.keychain
curl https://droposs.org/drop.crt --output drop.pem
echo "Created keychain"
curl https://droposs.org/drop.der --output drop.der
# swiftc libs/appletrust/add-certificate.swift
# ./add-certificate drop.der
# rm add-certificate
# echo "Added certificate to keychain using swift util"
## Script is equivalent to:
sudo security authorizationdb write com.apple.trust-settings.admin allow sudo security authorizationdb write com.apple.trust-settings.admin allow
sudo security add-trusted-cert -d -r trustRoot -k build.keychain -p codeSign -u -1 drop.pem sudo security add-trusted-cert -d -r trustRoot -k build.keychain -p codeSign -u -1 drop.der
sudo security authorizationdb remove com.apple.trust-settings.admin sudo security authorizationdb remove com.apple.trust-settings.admin
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
echo "Imported certificate"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security find-identity -v -p codesigning build.keychain security find-identity -v -p codesigning build.keychain
- name: Verify Certificate - name: Verify Certificate
if: matrix.platform == 'macos-latest' if: matrix.platform == 'macos-14'
run: | run: |
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Drop OSS") CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Drop OSS")
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
@@ -86,19 +110,19 @@ jobs:
echo "Certificate imported. Using identity: $CERT_ID" echo "Certificate imported. Using identity: $CERT_ID"
- name: install frontend dependencies - name: install frontend dependencies
run: yarn install # change this to npm, pnpm or bun depending on which one you use. run: pnpm install # change this to npm, pnpm or bun depending on which one you use.
- uses: tauri-apps/tauri-action@v0 - uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
NO_STRIP: true NO_STRIP: true
with: with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version. tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
releaseName: 'Auto-release v__VERSION__' releaseName: "Auto-release v__VERSION__"
releaseBody: 'See the assets to download this version and install. This release was created automatically.' releaseBody: "See the assets to download this version and install. This release was created automatically."
releaseDraft: false releaseDraft: false
prerelease: true prerelease: true
args: ${{ matrix.args }} args: ${{ matrix.args }}

4
.gitignore vendored
View File

@@ -29,4 +29,6 @@ src-tauri/flamegraph.svg
src-tauri/perf* src-tauri/perf*
/*.AppImage /*.AppImage
/squashfs-root /squashfs-root
/target/

View File

@@ -1,2 +0,0 @@
*
!.gitignore

View File

@@ -1,29 +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.
## Running ## Internals
Before setting up the drop app, be sure that you have a server set up.
The instructions for this can be found on the [Drop Docs](https://docs.droposs.org/docs/guides/quickstart)
## Current features It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI.
Currently supported are the following features:
- Signin (with custom server)
- Database registering & recovery
- Dynamic library fetching from server
- Installing & uninstalling games
- Download progress monitoring
- Launching / playing games
## Development ## 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`: 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 tauri dev` e.g. `RUST_LOG=debug yarn tauri dev`
## Contributing ## 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).

View File

@@ -21,6 +21,13 @@ async function spawn(exec, opts) {
}); });
} }
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 views = fs.readdirSync(".").filter((view) => {
const expectedPath = `./${view}/package.json`; const expectedPath = `./${view}/package.json`;
return fs.existsSync(expectedPath); return fs.existsSync(expectedPath);
@@ -33,10 +40,10 @@ for (const view of views) {
process.chdir(`./${view}`); process.chdir(`./${view}`);
loggerChild.info(`Install deps for "${view}"`); loggerChild.info(`Install deps for "${view}"`);
await spawn("yarn"); await spawn("pnpm install");
loggerChild.info(`Building "${view}"`); loggerChild.info(`Building "${view}"`);
await spawn("yarn build", { await spawn("pnpm run build", {
env: { ...process.env, NUXT_APP_BASE_URL: `/${view}/` }, env: { ...process.env, NUXT_APP_BASE_URL: `/${view}/` },
}); });

View File

@@ -0,0 +1,72 @@
import Foundation
import Security
enum SecurityError: Error {
case generalError
}
func deleteCertificateFromKeyChain(_ certificateLabel: String) -> Bool {
let delQuery: [NSString: Any] = [
kSecClass: kSecClassCertificate,
kSecAttrLabel: certificateLabel,
]
let delStatus: OSStatus = SecItemDelete(delQuery as CFDictionary)
return delStatus == errSecSuccess
}
func saveCertificateToKeyChain(_ certificate: SecCertificate, certificateLabel: String) throws {
SecKeychainSetPreferenceDomain(SecPreferencesDomain.system)
deleteCertificateFromKeyChain(certificateLabel)
let setQuery: [NSString: AnyObject] = [
kSecClass: kSecClassCertificate,
kSecValueRef: certificate,
kSecAttrLabel: certificateLabel as AnyObject,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked,
kSecAttrCanSign: true as AnyObject,
]
let addStatus: OSStatus = SecItemAdd(setQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw SecurityError.generalError
}
var status = SecTrustSettingsSetTrustSettings(certificate, SecTrustSettingsDomain.admin, nil)
}
func getCertificateFromString(stringData: String) throws -> SecCertificate {
if let data = NSData(base64Encoded: stringData, options: NSData.Base64DecodingOptions.ignoreUnknownCharacters) {
if let certificate = SecCertificateCreateWithData(kCFAllocatorDefault, data) {
return certificate
}
}
throw SecurityError.generalError
}
if CommandLine.arguments.count != 2 {
print("Usage: \(CommandLine.arguments[0]) [cert.file]")
print("Usage: \(CommandLine.arguments[0]) --version")
exit(1)
}
if (CommandLine.arguments[1] == "--version") {
let version = "dev"
print(version)
exit(0)
} else {
let fileURL = URL(fileURLWithPath: CommandLine.arguments[1])
do {
let certData = try Data(contentsOf: fileURL)
let certificate = SecCertificateCreateWithData(nil, certData as CFData)
if certificate != nil {
try? saveCertificateToKeyChain(certificate!, certificateLabel: "DropOSS")
exit(0)
} else {
print("ERROR: Unknown error while reading the \(CommandLine.arguments[1]) file.")
}
} catch {
print("ERROR: Unexpected error while reading the \(CommandLine.arguments[1]) file. \(error)")
}
}
exit(1)

View File

@@ -1,5 +1,5 @@
<template> <template>
<LoadingIndicator /> <NuxtLoadingIndicator color="#2563eb" />
<NuxtLayout class="select-none w-screen h-screen"> <NuxtLayout class="select-none w-screen h-screen">
<NuxtPage /> <NuxtPage />
<ModalStack /> <ModalStack />
@@ -15,6 +15,8 @@ import {
initialNavigation, initialNavigation,
setupHooks, setupHooks,
} from "./composables/state-navigation.js"; } from "./composables/state-navigation.js";
import { listen } from "@tauri-apps/api/event";
import type { AppState } from "./types.js";
const router = useRouter(); const router = useRouter();
@@ -36,9 +38,8 @@ async function fetchState() {
} }
await fetchState(); await fetchState();
// This is inefficient but apparently we do it lol listen("update_state", (event) => {
router.beforeEach(async () => { state.value = event.payload as AppState;
await fetchState();
}); });
setupHooks(); setupHooks();

View File

@@ -6,6 +6,7 @@ html,
body { body {
-ms-overflow-style: none; /* IE and Edge / -ms-overflow-style: none; /* IE and Edge /
scrollbar-width: none; / Firefox */ scrollbar-width: none; / Firefox */
overscroll-behavior: none;
} }
/* Hide scrollbar for Chrome, Safari and Opera */ /* Hide scrollbar for Chrome, Safari and Opera */

View File

@@ -0,0 +1,106 @@
<template>
<ModalTemplate :model-value="true">
<template #default
><div class="flex items-start gap-x-3">
<img :src="useObject(game.mIconObjectId)" class="size-12" />
<div class="mt-3 text-center sm:mt-0 sm:text-left">
<h3 class="text-base font-semibold text-zinc-100">
Missing required dependency "{{ game.mName }}"
</h3>
<div class="mt-2">
<p class="text-sm text-zinc-400">
To launch this game, you need to have "{{ game.mName }}" ({{
version.displayName ?? version.versionPath
}}) installed.
</p>
</div>
</div>
</div>
<InstallDirectorySelector
:install-dirs="installDirs"
v-model="installDir"
/>
<div v-if="installError" class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ installError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton
@click="() => install()"
:loading="installLoading"
:disabled="installLoading"
type="submit"
class="ml-2 w-full sm:w-fit"
>
Install
</LoadingButton>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="cancel"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { XCircleIcon } from "@heroicons/vue/24/solid";
const model = defineModel<{ gameId: string; versionId: string }>({
required: true,
});
const { game, status } = await useGame(model.value.gameId);
const versionOptions = await invoke<Array<VersionOption>>(
"fetch_game_version_options",
{
gameId: game.id,
}
);
const version = versionOptions.find(
(v) => v.versionId === model.value.versionId
)!;
const installDirs = await invoke<string[]>("fetch_download_dir_stats");
const installDir = ref(0);
function cancel() {
// @ts-expect-error
model.value = undefined;
}
const installError = ref<string | undefined>();
const installLoading = ref(false);
async function install() {
try {
installLoading.value = true;
await invoke("download_game", {
gameId: game.id,
versionId: model.value.versionId,
installDir: installDir.value,
targetPlatform: version.platform,
});
cancel();
} catch (error) {
installError.value = (error as string).toString();
}
installLoading.value = false;
}
</script>

View File

@@ -37,7 +37,7 @@
</NuxtLink> </NuxtLink>
<div class="h-0.5 rounded-full w-full bg-zinc-800" /> <div class="h-0.5 rounded-full w-full bg-zinc-800" />
<div class="flex flex-col mb-1"> <div class="flex flex-col mb-1">
<MenuItem v-slot="{ active }"> <MenuItem v-if="state.user.admin" v-slot="{ active }">
<a <a
:href="adminUrl" :href="adminUrl"
target="_blank" target="_blank"

View File

@@ -0,0 +1,87 @@
<template>
<Listbox as="div" v-model="installDir">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
>Install to</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate">{{ installDirs[installDir] }}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(dir, dirIdx) in installDirs"
:key="dir"
:value="dirIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected ? 'font-semibold text-zinc-100' : 'font-normal',
'block truncate',
]"
>{{ dir }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
<div class="text-zinc-400 text-sm mt-2">
Add more install directories in
<PageWidget to="/settings/downloads">
<WrenchIcon class="size-3" />
Settings
</PageWidget>
</div>
</Listbox>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import {
CheckIcon,
ChevronUpDownIcon,
WrenchIcon,
} from "@heroicons/vue/20/solid";
const installDir = defineModel<number>({ required: true });
const { installDirs } = defineProps<{ installDirs: string[] }>();
</script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div> <div class="flex flex-col h-full">
<div class="mb-3 inline-flex gap-x-2"> <div class="mb-3 inline-flex gap-x-2">
<div <div
class="relative transition-transform duration-300 hover:scale-105 active:scale-95" class="relative transition-transform duration-300 hover:scale-105 active:scale-95"
@@ -20,72 +20,134 @@
/> />
</div> </div>
<button <button
@click="() => calculateGames(true)" @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" 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" /> <ArrowPathIcon class="size-4" />
</button> </button>
</div> </div>
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5"> <TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5 h-full">
<NuxtLink <Disclosure
v-for="nav in filteredNavigation" as="div"
v-for="(nav, navIndex) in filteredNavigation"
:key="nav.id" :key="nav.id"
:class="[ :class="['first:pt-0 last:pb-0', nav.tools ? 'mt-auto' : '']"
'transition-all duration-300 rounded-lg flex items-center py-2 px-3 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50', v-slot="{ open }"
nav.index === currentNavigation :default-open="nav.deft"
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
: nav.isInstalled.value
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
]"
:href="nav.route"
> >
<div class="flex items-center w-full gap-x-3"> <dt>
<div <DisclosureButton
class="flex-none transition-transform duration-300 hover:-rotate-2" class="flex w-full items-center justify-between text-left text-gray-900 dark:text-white"
> >
<img <span class="text-sm font-semibold font-display">{{
class="size-8 object-cover bg-zinc-900 rounded-lg transition-all duration-300 shadow-sm" nav.name
:src="icons[nav.id]" }}</span>
alt="" <span class="ml-6 relative flex size-4">
/> <MinusIcon class="absolute inset-0 size-4" aria-hidden="true" />
</div> <MinusIcon
<div class="flex flex-col flex-1"> :class="[ !open ? 'rotate-90' : 'rotate-0', 'transition-all absolute inset-0 size-4']"
<p aria-hidden="true"
class="truncate text-xs font-display leading-5 flex-1 font-semibold" />
> </span>
{{ nav.label }} </DisclosureButton>
</p> </dt>
<p <DisclosurePanel as="dd" class="mt-2 flex flex-col gap-y-1.5">
class="text-xs font-medium" <NuxtLink
:class="[gameStatusTextStyle[games[nav.id].status.value.type]]" v-for="item in nav.items"
> :key="nav.id"
{{ gameStatusText[games[nav.id].status.value.type] }} :class="[
</p> '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',
</div> currentNavigation == item.id
</div> ? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
</NuxtLink> : 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="useObject(item.icon)"
alt=""
/>
</div>
<div class="truncate 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> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ArrowPathIcon, MagnifyingGlassIcon } from "@heroicons/vue/20/solid"; import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
import {
ArrowPathIcon,
MagnifyingGlassIcon,
MinusIcon,
PlusIcon,
} from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { GameStatusEnum, type Game, type GameStatus } from "~/types"; import {
GameStatusEnum,
type Collection as Collection,
type Game,
type GameStatus,
} from "~/types";
import { TransitionGroup } from "vue"; import { TransitionGroup } from "vue";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
// Style information // Style information
const gameStatusTextStyle: { [key in GameStatusEnum]: string } = { const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "text-green-500", [GameStatusEnum.Installed]: "text-green-500",
[GameStatusEnum.Downloading]: "text-blue-500", [GameStatusEnum.Downloading]: "text-zinc-400",
[GameStatusEnum.Validating]: "text-blue-300", [GameStatusEnum.Validating]: "text-blue-300",
[GameStatusEnum.Running]: "text-green-500", [GameStatusEnum.Running]: "text-blue-500",
[GameStatusEnum.Remote]: "text-zinc-500", [GameStatusEnum.Remote]: "text-zinc-700",
[GameStatusEnum.Queued]: "text-blue-500", [GameStatusEnum.Queued]: "text-zinc-400",
[GameStatusEnum.Updating]: "text-blue-500", [GameStatusEnum.Updating]: "text-zinc-400",
[GameStatusEnum.Uninstalling]: "text-zinc-100", [GameStatusEnum.Uninstalling]: "text-zinc-100",
[GameStatusEnum.SetupRequired]: "text-yellow-500", [GameStatusEnum.SetupRequired]: "text-yellow-500",
[GameStatusEnum.PartiallyInstalled]: "text-gray-400", [GameStatusEnum.PartiallyInstalled]: "text-gray-400",
@@ -107,71 +169,147 @@ const router = useRouter();
const searchQuery = ref(""); const searchQuery = ref("");
const loading = ref(false);
const games: { const games: {
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> }; [key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
} = {}; } = {};
const icons: { [key: string]: string } = {};
const rawGames: Ref<Game[], Game[]> = ref([]); const collections: Ref<Collection[]> = ref([]);
async function calculateGames(clearAll = false) { async function calculateGames(clearAll = false, forceRefresh = false) {
if (clearAll) rawGames.value = []; try {
await calculateGamesLogic(clearAll, forceRefresh);
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to fetch library",
description: `Drop encountered an error while fetching your library: ${e}`,
},
(_, c) => c(),
);
}
loading.value = false;
}
type FetchLibraryResponse = {
library: Game[];
collections: Collection[];
other: Game[];
};
async function calculateGamesLogic(clearAll = false, forceRefresh = false) {
if (clearAll) {
collections.value = [];
loading.value = true;
}
// If we update immediately, the navigation gets re-rendered before we // If we update immediately, the navigation gets re-rendered before we
// add all the necessary state, and it freaks tf out // add all the necessary state, and it freaks tf out
const newGames = await invoke<typeof rawGames.value>("fetch_library"); const library = await invoke<FetchLibraryResponse>("fetch_library", {
for (const game of newGames) { hardRefresh: forceRefresh,
});
const allGames = [
...library.library,
...library.collections
.map((e) => e.entries)
.flat()
.map((e) => e.game),
...library.other,
].filter((v, i, a) => a.indexOf(v) === i);
for (const game of allGames) {
if (games[game.id]) continue; if (games[game.id]) continue;
games[game.id] = await useGame(game.id); games[game.id] = await useGame(game.id);
} }
for (const game of newGames) {
if (icons[game.id]) continue; const libraryCollection = {
icons[game.id] = await useObject(game.mIconObjectId); id: "library",
} name: "Library",
rawGames.value = newGames; isDefault: true,
entries: library.library.map((e) => ({ gameId: e.id, game: e })),
} satisfies Collection;
const otherCollection = {
id: "other",
name: "Tools & Launchers",
isDefault: false,
isTools: true,
entries: library.other.map((v) => ({ gameId: v.id, game: v })),
} satisfies Collection;
loading.value = false;
collections.value = [
libraryCollection,
...library.collections,
...(library.other.length > 0 ? [otherCollection] : []),
];
} }
await calculateGames(); // 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(() => const navigation = computed(() =>
rawGames.value.map((game) => { collections.value.map((collection) => {
const status = games[game.id].status; const items = collection.entries.map(({ game }) => {
const status = games[game.id].status;
const isInstalled = computed( const isInstalled = computed(
() => () => status.value.type != GameStatusEnum.Remote,
status.value.type == GameStatusEnum.Installed || );
status.value.type == GameStatusEnum.SetupRequired
);
const item = { const item = {
label: game.mName, label: game.mName,
route: `/library/${game.id}`, route: `/library/${game.id}`,
prefix: `/library/${game.id}`, prefix: `/library/${game.id}`,
isInstalled, icon: game.mIconObjectId,
id: game.id, isInstalled,
id: game.id,
type: game.type,
};
return item;
});
return {
id: collection.id,
name: collection.name,
deft: collection.isDefault,
tools: collection.isTools ?? false,
items,
}; };
return item; }),
})
);
const { currentNavigation, recalculateNavigation } = useCurrentNavigationIndex(
navigation.value
); );
const route = useRoute();
const currentNavigation = computed(() => {
return route.path.slice("/library/".length);
});
const filteredNavigation = computed(() => { const filteredNavigation = computed(() => {
if (!searchQuery.value) if (!searchQuery.value)
return navigation.value.map((e, i) => ({ ...e, index: i })); return navigation.value.map((e, i) => ({ ...e, index: i }));
const query = searchQuery.value.toLowerCase(); const query = searchQuery.value.toLowerCase();
return navigation.value return navigation.value
.filter((nav) => nav.label.toLowerCase().includes(query)) .map((c) => ({
.map((e, i) => ({ ...e, index: i })); ...c,
items: c.items.filter((nav) => nav.label.toLowerCase().includes(query)),
}))
.filter((e) => e.items.length > 0);
}); });
listen("update_library", async (event) => { listen("update_library", async (event) => {
console.log("Updating library"); console.log("Updating library");
let oldNavigation = navigation.value[currentNavigation.value]; let oldNavigation = currentNavigation.value;
await calculateGames(); await calculateGames(false, true);
recalculateNavigation(); if (oldNavigation !== currentNavigation.value) {
if (oldNavigation !== navigation.value[currentNavigation.value]) {
console.log("Triggered");
router.push("/library"); router.push("/library");
} }
}); });

View File

@@ -1,7 +0,0 @@
<template></template>
<script setup lang="ts">
const loading = useLoadingIndicator();
watch(loading.isLoading, console.log);
</script>

View File

@@ -1,9 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ArrowDownTrayIcon, CloudIcon } from "@heroicons/vue/20/solid"; import { ArrowDownTrayIcon, CloudIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
async function checkOffline() {
const isOffline = await invoke("check_online");
}
</script> </script>
<template> <template>
<div <button
@click="checkOffline"
class="transition inline-flex items-center rounded-sm px-4 py-1.5 bg-zinc-900 text-sm text-zinc-400 gap-x-2" 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"> <div class="relative">
@@ -13,5 +19,5 @@ import { ArrowDownTrayIcon, CloudIcon } from "@heroicons/vue/20/solid";
/> />
</div> </div>
Offline Offline
</div> </button>
</template> </template>

View File

@@ -32,3 +32,19 @@ listen("update_stats", (event) => {
const stats = useStatsState(); const stats = useStatsState();
stats.value = event.payload as StatsState; stats.value = event.payload as StatsState;
}); });
export const useDownloadHistory = () => useState<Array<number>>('history', () => []);
export function formatKilobytes(bytes: number): string {
const units = ["K", "M", "G", "T", "P"];
let value = bytes;
let unitIndex = 0;
const scalar = 1000;
while (value >= scalar && unitIndex < units.length - 1) {
value /= scalar;
unitIndex++;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}

View File

@@ -48,13 +48,13 @@ export const useGame = async (gameId: string) => {
version?: GameVersion; version?: GameVersion;
} = event.payload as any; } = event.payload as any;
gameStatusRegistry[gameId].value = parseStatus(payload.status); gameStatusRegistry[gameId].value = parseStatus(payload.status);
/** /**
* I am not super happy about this. * 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. * 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 * It is necessary because a flag to check if we should overwrite seems excessive, and this function gets called
* on transient state updates. * on transient state updates.
*/ */
if (payload.version) { if (payload.version) {
gameRegistry[gameId].version = payload.version; gameRegistry[gameId].version = payload.version;
@@ -71,3 +71,23 @@ export const useGame = async (gameId: string) => {
export type FrontendGameConfiguration = { export type FrontendGameConfiguration = {
launchString: string; launchString: string;
}; };
export type LaunchResult =
| { result: "Success" }
| { result: "InstallRequired"; data: [string, string] };
export type VersionOption = {
versionId: string;
displayName?: string;
versionPath: string;
platform: string;
size: number;
requiredContent: Array<{
gameId: string;
versionId: string;
name: string;
iconObjectId: string;
shortDescription: string;
size: number;
}>;
};

View File

@@ -1,5 +1,5 @@
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
export const useObject = async (id: string) => { export const useObject = (id: string) => {
return convertFileSrc(id, "object"); return convertFileSrc(id, "object");
}; };

View File

@@ -12,6 +12,7 @@ export default defineNuxtConfig({
css: ["~/assets/main.scss"], css: ["~/assets/main.scss"],
ssr: false, ssr: false,
devtools: false,
extends: [["../libs/drop-base"]], extends: [["../libs/drop-base"]],

View File

@@ -1,7 +1,7 @@
{ {
"name": "view", "name": "view",
"private": true, "private": true,
"version": "0.3.1", "version": "0.3.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "nuxt generate", "build": "nuxt generate",
@@ -13,7 +13,9 @@
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@nuxtjs/tailwindcss": "^6.12.2", "@nuxtjs/tailwindcss": "^6.12.2",
"@tauri-apps/api": "^2.7.0", "@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.3",
"koa": "^2.16.1", "koa": "^2.16.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"micromark": "^4.0.1", "micromark": "^4.0.1",
@@ -32,6 +34,5 @@
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vue-tsc": "^2.2.10" "vue-tsc": "^2.2.10"
}, }
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

25
main/pages/community.vue Normal file
View File

@@ -0,0 +1,25 @@
<template>
<div class="grow w-full h-full flex items-center justify-center">
<div class="flex flex-col items-center">
<WrenchScrewdriverIcon
class="h-12 w-12 text-blue-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Under construction
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-lg">
This page hasn't been implemented yet.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
WrenchScrewdriverIcon,
} from "@heroicons/vue/20/solid";
</script>

View File

@@ -170,8 +170,8 @@
</div> </div>
</div> </div>
<form class="space-y-6"> <div class="space-y-6">
<div v-if="versionOptions && versionOptions.length > 0"> <div v-if="versionOptions && versionOptions.length > 0 && currentVersionOption">
<Listbox as="div" v-model="installVersionIndex"> <Listbox as="div" v-model="installVersionIndex">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100" <ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
>Version</ListboxLabel >Version</ListboxLabel
@@ -181,9 +181,16 @@
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6" class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
> >
<span class="block truncate" <span class="block truncate"
>{{ versionOptions[installVersionIndex].versionName }} >{{
currentVersionOption.displayName ||
currentVersionOption.versionPath
}}
on on
{{ versionOptions[installVersionIndex].platform }}</span {{ currentVersionOption.platform }} ({{
formatKilobytes(
currentVersionOption.size / 1024
)
}}B)</span
> >
<span <span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2" class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
@@ -206,7 +213,7 @@
<ListboxOption <ListboxOption
as="template" as="template"
v-for="(version, versionIdx) in versionOptions" v-for="(version, versionIdx) in versionOptions"
:key="version.versionName" :key="version.versionId"
:value="versionIdx" :value="versionIdx"
v-slot="{ active, selected }" v-slot="{ active, selected }"
> >
@@ -223,8 +230,12 @@
: 'font-normal', : 'font-normal',
'block truncate', 'block truncate',
]" ]"
>{{ version.versionName }} on >{{ version.displayName || version.versionPath }} on
{{ version.platform }}</span {{ version.platform }} ({{
formatKilobytes(
versionOptions[installVersionIndex].size / 1024
)
}}B)</span
> >
<span <span
@@ -243,7 +254,10 @@
</div> </div>
</Listbox> </Listbox>
</div> </div>
<div v-else class="mt-1 rounded-md bg-red-600/10 p-4"> <div
v-else-if="versionOptions === null || versionOptions?.length == 0"
class="mt-1 rounded-md bg-red-600/10 p-4"
>
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" /> <XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
@@ -256,83 +270,108 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="installDirs"> <div v-else class="w-full flex items-center justify-center p-4">
<Listbox as="div" v-model="installDir"> <div role="status">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100" <svg
>Install to</ListboxLabel aria-hidden="true"
class="w-7 h-7 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
> >
<div class="relative mt-2"> <path
<ListboxButton 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"
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6" fill="currentColor"
> />
<span class="block truncate">{{ <path
installDirs[installDir] 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"
}}</span> fill="currentFill"
<span />
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2" </svg>
> <span class="sr-only">Loading...</span>
<ChevronUpDownIcon </div>
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(dir, dirIdx) in installDirs"
:key="dir"
:value="dirIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ dir }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
<div class="text-zinc-400 text-sm mt-2">
Add more install directories in
<PageWidget to="/settings/downloads">
<WrenchIcon class="size-3" />
Settings
</PageWidget>
</div>
</Listbox>
</div> </div>
</form> <div v-if="installDirs">
<InstallDirectorySelector
:install-dirs="installDirs"
v-model="installDir"
/>
</div>
<div
v-if="
currentVersionOption?.requiredContent &&
currentVersionOption.requiredContent.length > 0
"
>
<div class="border-b border-white/10 py-2">
<h3 class="text-sm font-semibold text-white">
Install additional dependencies?
</h3>
<p class="mt-1 text-xs text-gray-400">
This game requires additional content to run. Click the components
to automatically queue for download.
</p>
</div>
<ul role="list" class="mt-2 divide-y divide-white/5">
<li
v-for="content in currentVersionOption
.requiredContent"
:key="content.versionId"
:class="[
!installDepsDisabled[content.versionId]
? 'bg-zinc-950 ring-2 ring-zinc-800'
: '',
'rounded-lg relative flex justify-between px-2 py-3',
]"
>
<div class="flex min-w-0 gap-x-2">
<img
class="size-12 flex-none"
:src="useObject(content.iconObjectId)"
alt=""
/>
<div class="min-w-0 flex-auto">
<p class="text-sm/6 font-semibold text-white">
<button
@click="
() =>
(installDepsDisabled[content.versionId] =
!installDepsDisabled[content.versionId])
"
>
<span class="absolute inset-x-0 -top-px bottom-0"></span>
{{ content.name }}
</button>
</p>
<p class="mt-1 flex text-xs/5 text-gray-400">
{{ content.shortDescription }}
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-x-2">
<div class="hidden sm:flex sm:flex-col sm:items-end">
<p
class="inline-flex items-center gap-x-1 text-xs/5 text-gray-400"
>
{{ formatKilobytes(content.size / 1024) }}B
<ServerIcon class="size-3" />
</p>
</div>
<CheckIcon
v-if="!installDepsDisabled[content.versionId]"
class="size-5 flex-none text-green-500"
aria-hidden="true"
/>
<MinusIcon
v-else
class="size-5 flex-none text-gray-500"
aria-hidden="true"
/>
</div>
</li>
</ul>
</div>
</div>
<div v-if="installError" class="mt-1 rounded-md bg-red-600/10 p-4"> <div v-if="installError" class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex"> <div class="flex">
@@ -368,6 +407,48 @@
</template> </template>
</ModalTemplate> </ModalTemplate>
<ModalTemplate :model-value="launchOptionsOpen">
<template #default>
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left">
<h3 class="text-base font-semibold text-zinc-100">
Launch {{ game.mName }}
</h3>
<div class="mt-2">
<p class="text-sm text-zinc-400">
The instance admin has configured multiple ways to start this
game. Select an option to start.
</p>
</div>
</div>
</div>
<ol class="space-y-2">
<li v-for="(launchData, launchIdx) in launchOptions!">
<button
class="transition w-full rounded-sm bg-zinc-800 inline-flex items-center text-sm py-2 px-3 gap-x-2 text-zinc-100 hover:text-zinc-300 hover:bg-zinc-700"
@click="() => launchIndex(launchIdx)"
>
<PlayIcon class="size-4" />
<span>
{{ launchData.name }}
</span>
</button>
</li>
</ol>
</template>
<template #buttons>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="launchOptions = undefined"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
<!-- <!--
Dear future DecDuck, Dear future DecDuck,
This v-if is necessary for Vue rendering reasons This v-if is necessary for Vue rendering reasons
@@ -446,6 +527,11 @@
</div> </div>
</div> </div>
</Transition> </Transition>
<DependencyRequiredModal
v-if="dependencyRequiredModal"
v-model="dependencyRequiredModal"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -465,9 +551,10 @@ import {
XMarkIcon, XMarkIcon,
ArrowsPointingOutIcon, ArrowsPointingOutIcon,
PhotoIcon, PhotoIcon,
PlayIcon,
} from "@heroicons/vue/20/solid"; } from "@heroicons/vue/20/solid";
import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline"; import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline";
import { XCircleIcon } from "@heroicons/vue/24/solid"; import { MinusIcon, ServerIcon, XCircleIcon } from "@heroicons/vue/24/solid";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { micromark } from "micromark"; import { micromark } from "micromark";
import { GameStatusEnum } from "~/types"; import { GameStatusEnum } from "~/types";
@@ -496,9 +583,7 @@ const mediaUrls = await Promise.all(
const htmlDescription = micromark(game.value.mDescription); const htmlDescription = micromark(game.value.mDescription);
const installFlowOpen = ref(false); const installFlowOpen = ref(false);
const versionOptions = ref< const versionOptions = ref<undefined | Array<VersionOption>>();
undefined | Array<{ versionName: string; platform: string }>
>();
const installDirs = ref<undefined | Array<string>>(); const installDirs = ref<undefined | Array<string>>();
const currentImageIndex = ref(0); const currentImageIndex = ref(0);
@@ -510,13 +595,13 @@ async function installFlow() {
installDirs.value = undefined; installDirs.value = undefined;
try { try {
versionOptions.value = await invoke("fetch_game_verion_options", { versionOptions.value = await invoke("fetch_game_version_options", {
gameId: game.value.id, gameId: game.value.id,
}); });
console.log(versionOptions.value);
installDirs.value = await invoke("fetch_download_dir_stats"); installDirs.value = await invoke("fetch_download_dir_stats");
} catch (error) { } catch (error) {
installError.value = (error as string).toString(); installError.value = (error as string).toString();
versionOptions.value = undefined;
} }
} }
@@ -524,15 +609,31 @@ const installLoading = ref(false);
const installError = ref<string | undefined>(); const installError = ref<string | undefined>();
const installVersionIndex = ref(0); const installVersionIndex = ref(0);
const installDir = ref(0); const installDir = ref(0);
const installDepsDisabled = ref<{ [key: string]: boolean }>({});
const currentVersionOption = computed(() => versionOptions.value?.[installVersionIndex.value]);
async function install() { async function install() {
try { try {
if (!versionOptions.value) throw new Error("Versions have not been loaded"); if (!versionOptions.value) throw new Error("Versions have not been loaded");
installLoading.value = true; installLoading.value = true;
await invoke("download_game", { const versionOption = versionOptions.value[installVersionIndex.value];
gameId: game.value.id,
gameVersion: versionOptions.value[installVersionIndex.value].versionName, const games = [
installDir: installDir.value, { gameId: game.value.id, versionId: versionOption.versionId },
}); ...versionOption.requiredContent
.filter((v) => !installDepsDisabled.value[v.versionId])
.map((v) => ({ gameId: v.gameId, versionId: v.versionId })),
];
for (const game of games) {
await invoke("download_game", {
gameId: game.gameId,
versionId: game.versionId,
installDir: installDir.value,
targetPlatform: versionOption.platform,
});
}
installFlowOpen.value = false; installFlowOpen.value = false;
} catch (error) { } catch (error) {
installError.value = (error as string).toString(); installError.value = (error as string).toString();
@@ -549,9 +650,24 @@ async function resumeDownload() {
} }
} }
const launchOptions = ref<Array<{ name: string }> | undefined>(undefined);
const launchOptionsOpen = computed(() => launchOptions.value !== undefined);
async function launch() { async function launch() {
if (status.value.type == GameStatusEnum.SetupRequired) {
await launchIndex(0);
return;
}
try { try {
await invoke("launch_game", { id: game.value.id }); const fetchedLaunchOptions = await invoke<Array<{ name: string }>>(
"get_launch_options",
{ id: game.value.id }
);
if (fetchedLaunchOptions.length == 1) {
await launchIndex(0);
return;
}
launchOptions.value = fetchedLaunchOptions;
} catch (e) { } catch (e) {
createModal( createModal(
ModalType.Notification, ModalType.Notification,
@@ -566,6 +682,36 @@ async function launch() {
} }
} }
const dependencyRequiredModal = ref<
{ gameId: string; versionId: string } | undefined
>(undefined);
async function launchIndex(index: number) {
launchOptions.value = undefined;
try {
const result = await invoke<LaunchResult>("launch_game", {
id: game.value.id,
index,
});
if (result.result == "InstallRequired") {
dependencyRequiredModal.value = {
gameId: result.data[0],
versionId: result.data[1],
};
}
} catch (e) {
createModal(
ModalType.Notification,
{
title: `Couldn't run "${game.value.mName}"`,
description: `Drop failed to launch "${game.value.mName}": ${e}`,
buttonText: "Close",
},
(e, c) => c()
);
}
}
async function queue() { async function queue() {
router.push("/queue"); router.push("/queue");
} }

25
main/pages/news.vue Normal file
View File

@@ -0,0 +1,25 @@
<template>
<div class="grow w-full h-full flex items-center justify-center">
<div class="flex flex-col items-center">
<WrenchScrewdriverIcon
class="h-12 w-12 text-blue-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Under construction
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-lg">
This page hasn't been implemented yet.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
WrenchScrewdriverIcon,
} from "@heroicons/vue/20/solid";
</script>

View File

@@ -4,18 +4,18 @@
class="h-16 overflow-hidden relative rounded-xl flex flex-row border border-zinc-900" class="h-16 overflow-hidden relative rounded-xl flex flex-row border border-zinc-900"
> >
<div <div
class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 text-blue-400 font-display items-left justify-center pl-2" class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 font-display items-left justify-center pl-2"
> >
<span class="font-semibold">{{ formatKilobytes(stats.speed) }}/s</span> <span class="font-bold text-zinc-100">{{ formatKilobytes(stats.speed) }}B/s</span>
<span v-if="stats.time > 0" class="text-sm" <span class="text-xs text-zinc-400"
>{{ formatTime(stats.time) }} left</span >{{ formatTime(stats.time) }} left</span
> >
</div> </div>
<div class="absolute inset-0 h-full flex flex-row items-end justify-end"> <div class="absolute inset-0 h-full flex flex-row items-end justify-end space-x-[1px]">
<div <div
v-for="bar in speedHistory" v-for="bar in speedHistory"
:style="{ height: `${(bar / speedMax) * 100}%` }" :style="{ height: `${(bar / speedMax) * 100}%` }"
class="w-[8px] bg-blue-600/40" class="w-[3px] bg-blue-600 rounded-t-full"
/> />
</div> </div>
</div> </div>
@@ -62,9 +62,9 @@
class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display" class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display"
><span class="text-zinc-300">{{ ><span class="text-zinc-300">{{
formatKilobytes(element.current / 1000) formatKilobytes(element.current / 1000)
}}</span> }}B</span>
/ /
<span class="">{{ formatKilobytes(element.max / 1000) }}</span <span class="">{{ formatKilobytes(element.max / 1000) }}B</span
><ServerIcon class="size-5" ><ServerIcon class="size-5"
/></span> /></span>
</div> </div>
@@ -91,7 +91,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ServerIcon, XMarkIcon } from "@heroicons/vue/20/solid"; import { ServerIcon, XMarkIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { GameStatusEnum, type DownloadableMetadata, type Game, type GameStatus } from "~/types"; import { type DownloadableMetadata, type Game, type GameStatus } from "~/types";
// const actionNames = { // const actionNames = {
// [GameStatusEnum.Downloading]: "downloading", // [GameStatusEnum.Downloading]: "downloading",
@@ -105,12 +105,12 @@ window.addEventListener("resize", (event) => {
const queue = useQueueState(); const queue = useQueueState();
const stats = useStatsState(); const stats = useStatsState();
const speedHistory = useState<Array<number>>(() => []); const speedHistory = useDownloadHistory();
const speedHistoryMax = computed(() => windowWidth.value / 8); const speedHistoryMax = computed(() => windowWidth.value / 4);
const speedMax = computed( const speedMax = computed(
() => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.3 () => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.1
); );
const previousGameId = ref<string | undefined>(); const previousGameId = useState<string | undefined>('previous_game');
const games: Ref<{ const games: Ref<{
[key: string]: { game: Game; status: Ref<GameStatus>; cover: string }; [key: string]: { game: Game; status: Ref<GameStatus>; cover: string };
@@ -122,14 +122,15 @@ function resetHistoryGraph() {
} }
function checkReset(v: QueueState) { function checkReset(v: QueueState) {
const currentGame = v.queue.at(0)?.meta.id; const currentGame = v.queue.at(0)?.meta.id;
// If we don't have a game
if (!currentGame) return;
// If we're finished // If we're finished
if (!currentGame && previousGameId.value) { if (!currentGame && previousGameId.value) {
previousGameId.value = undefined; previousGameId.value = undefined;
resetHistoryGraph(); resetHistoryGraph();
return; return;
} }
// If we don't have a game
if (!currentGame) return;
// If we started a new download // If we started a new download
if (currentGame && !previousGameId.value) { if (currentGame && !previousGameId.value) {
previousGameId.value = currentGame; previousGameId.value = currentGame;
@@ -149,9 +150,10 @@ watch(queue, (v) => {
}); });
watch(stats, (v) => { watch(stats, (v) => {
if(v.speed == 0) return;
const newLength = speedHistory.value.push(v.speed); const newLength = speedHistory.value.push(v.speed);
if (newLength > speedHistoryMax.value) { if (newLength > speedHistoryMax.value) {
speedHistory.value.splice(0, 1); speedHistory.value.splice(0, newLength - speedHistoryMax.value);
} }
checkReset(queue.value); checkReset(queue.value);
}); });
@@ -182,21 +184,10 @@ async function cancelGame(meta: DownloadableMetadata) {
await invoke("cancel_game", { meta }); await invoke("cancel_game", { meta });
} }
function formatKilobytes(bytes: number): string {
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = bytes;
let unitIndex = 0;
const scalar = 1000;
while (value >= scalar && unitIndex < units.length - 1) {
value /= scalar;
unitIndex++;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (seconds == 0) {
return `0s`;
}
if (seconds < 60) { if (seconds < 60) {
return `${Math.round(seconds)}s`; return `${Math.round(seconds)}s`;
} }

View File

@@ -116,7 +116,7 @@ platformInfo.value = currentPlatform;
async function openDataDir() { async function openDataDir() {
if (!dataDir.value) return; if (!dataDir.value) return;
try { try {
await open(dataDir.value); await invoke("open_fs", { path: dataDir.value });
} catch (error) { } catch (error) {
console.error("Failed to open data dir:", error); console.error("Failed to open data dir:", error);
} }
@@ -126,7 +126,7 @@ async function openLogFile() {
if (!dataDir.value) return; if (!dataDir.value) return;
try { try {
const logPath = `${dataDir.value}/drop.log`; const logPath = `${dataDir.value}/drop.log`;
await open(logPath); await invoke("open_fs", { path: logPath });
} catch (error) { } catch (error) {
console.error("Failed to open log file:", error); console.error("Failed to open log file:", error);
} }

View File

@@ -1,7 +1,23 @@
<template> <template>
<div class="grow w-full h-full flex items-center justify-center">
<div class="flex flex-col items-center">
<WrenchScrewdriverIcon
class="h-12 w-12 text-blue-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Under construction
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-lg">
This page hasn't been implemented yet.
</p>
</div>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { WrenchScrewdriverIcon } from "@heroicons/vue/20/solid";
</script> </script>

View File

@@ -1,37 +1,12 @@
<template> <template>
<div class="grow w-full h-full flex items-center justify-center"> <iframe :src="convertedStoreUrl" class="grow w-full h-full" />
<div class="flex flex-col items-center">
<BuildingStorefrontIcon
class="h-12 w-12 text-blue-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Store not supported in client
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-lg">
Currently, Drop requires you to view the store in your browser.
Please click the button below to open it in your default browser.
</p>
<NuxtLink
:href="storeUrl"
target="_blank"
class="mt-6 transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
>
Open Store <ArrowTopRightOnSquareIcon class="size-4" />
</NuxtLink>
</div>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import {
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
BuildingStorefrontIcon, BuildingStorefrontIcon,
} from "@heroicons/vue/20/solid"; } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core"; import { convertFileSrc, invoke } from "@tauri-apps/api/core";
const storeUrl = await invoke<string>("gen_drop_url", { path: "/store" }); const convertedStoreUrl = convertFileSrc("store", "server");
</script> </script>

8217
main/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
main/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- esbuild

View File

@@ -27,6 +27,7 @@ export type AppState = {
export type Game = { export type Game = {
id: string; id: string;
type: "Game" | "Executor" | "Redist";
mName: string; mName: string;
mShortDescription: string; mShortDescription: string;
mDescription: string; mDescription: string;
@@ -37,6 +38,14 @@ export type Game = {
mImageCarouselObjectIds: string[]; mImageCarouselObjectIds: string[];
}; };
export type Collection = {
id: string;
name: string;
isDefault: boolean;
isTools?: boolean;
entries: Array<{ gameId: string; game: Game }>;
};
export type GameVersion = { export type GameVersion = {
launchCommandTemplate: string; launchCommandTemplate: string;
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
WEBKIT_DISABLE_DMABUF_RENDERER=1 yarn tauri dev GSK_RENDERER=ngl pnpm tauri dev

View File

@@ -3,7 +3,7 @@
ARCH=$(uname -m) ARCH=$(uname -m)
# build tauri apps # build tauri apps
# NO_STRIP=true yarn tauri build -- --verbose # NO_STRIP=true pnpm tauri build -- --verbose
# unpack appimage # unpack appimage
APPIMAGE=$(ls ./src-tauri/target/release/bundle/appimage/*.AppImage) APPIMAGE=$(ls ./src-tauri/target/release/bundle/appimage/*.AppImage)

View File

@@ -7,16 +7,11 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.7.0",
"@tauri-apps/plugin-deep-link": "^2.4.1",
"@tauri-apps/plugin-dialog": "^2.3.2",
"@tauri-apps/plugin-opener": "^2.4.0",
"@tauri-apps/plugin-os": "^2.3.0",
"@tauri-apps/plugin-shell": "^2.3.0",
"pino": "^9.7.0", "pino": "^9.7.0",
"pino-pretty": "^13.1.1" "pino-pretty": "^13.1.1",
"tauri": "^0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.7.1" "@tauri-apps/cli": "^2.9.4"
} }
} }

5583
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

21
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,21 @@
onlyBuiltDependencies:
- sharp
overrides:
cross-spawn@<6.0.6: '>=6.0.6'
cross-spawn@>=7.0.0 <7.0.5: '>=7.0.5'
form-data@<2.5.4: '>=2.5.4'
got@<11.8.5: '>=11.8.5'
http-cache-semantics@<4.1.1: '>=4.1.1'
lodash@<4.17.21: '>=4.17.21'
lodash@>=4.0.0 <4.17.21: '>=4.17.21'
minimist@>=1.0.0 <1.2.6: '>=1.2.6'
nth-check@<2.0.1: '>=2.0.1'
semver-regex@<3.1.3: '>=3.1.3'
semver-regex@<3.1.4: '>=3.1.4'
semver@>=7.0.0 <7.5.2: '>=7.5.2'
sharp@<0.30.5: '>=0.30.5'
sharp@<0.32.6: '>=0.32.6'
tmp@<=0.2.3: '>=0.2.4'
tough-cookie@<4.1.3: '>=4.1.3'
trim-newlines@<3.0.1: '>=3.0.1'

3229
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,118 +1,150 @@
[package] [package]
name = "drop-app" name = "drop-app"
version = "0.3.1" version = "0.4.0"
description = "The client application for the open-source, self-hosted game distribution platform Drop" description = "The client application for the open-source, self-hosted game distribution platform Drop"
authors = ["Drop OSS"] authors = ["Drop OSS"]
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace]
resolver = "3"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] members = [
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] } "client",
"cloud_saves",
"database",
"download_manager",
"games",
"process",
"remote",
"utils",
]
[lib] [lib]
crate-type = ["cdylib", "rlib", "staticlib"]
# The `_lib` suffix may seem redundant but it is necessary # The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name. # to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "drop_app_lib" name = "drop_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
rustflags = ["-C", "target-feature=+aes,+sse2"] rustflags = ["-C", "target-feature=+aes,+sse2"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.0.0", features = [] }
[dependencies] [dependencies]
tauri-plugin-shell = "2.2.1"
serde_json = "1"
rayon = "1.10.0"
webbrowser = "1.0.2"
url = "2.5.2"
tauri-plugin-deep-link = "2"
log = "0.4.22"
hex = "0.4.3"
tauri-plugin-dialog = "2"
http = "1.1.0"
urlencoding = "2.1.3"
md5 = "0.7.0"
chrono = "0.4.38"
tauri-plugin-os = "2"
boxcar = "0.2.7"
umu-wrapper-lib = "0.1.0"
tauri-plugin-autostart = "2.0.0"
shared_child = "1.0.1"
serde_with = "3.12.0"
slice-deque = "0.3.0"
throttle_my_fn = "0.2.6"
parking_lot = "0.12.3"
atomic-instant-full = "0.1.0" atomic-instant-full = "0.1.0"
bitcode = "0.6.6"
boxcar = "0.2.7"
bytes = "1.10.1"
cacache = "13.1.0" cacache = "13.1.0"
chrono = "0.4.38"
client = { path = "./client", version = "0.1.0" } # client
database = { path = "./database" } # database
deranged = "=0.4.0"
dirs = "6.0.0"
download_manager = { path = "./download_manager", version = "0.1.0" } # download manager
droplet-rs = "0.7.3"
filetime = "0.2.25"
futures-core = "0.3.31"
futures-lite = "2.6.0"
games = { path = "./games", version = "0.1.0" } # games
gethostname = "1.0.1"
hex = "0.4.3"
http = "1.1.0"
http-serde = "2.1.1" http-serde = "2.1.1"
humansize = "2.1.3"
known-folders = "1.2.0"
log = "0.4.22"
md5 = "0.7.0"
native_model = { git = "https://github.com/Drop-OSS/native_model.git", version = "0.6.4", features = [
"rmp_serde_1_3",
] }
page_size = "0.6.0"
parking_lot = "0.12.3"
process = { path = "./process" } # process
rand = "0.9.1"
rayon = "1.10.0"
regex = "1.11.1"
remote = { path = "./remote", version = "0.1.0" } # remote
reqwest = { version = "0.12.28", default-features = false, features = [
"blocking",
"http2",
"json",
"native-tls-alpn",
"rustls-tls",
"rustls-tls-native-roots",
"stream",
] }
reqwest-middleware = "0.4.0" reqwest-middleware = "0.4.0"
reqwest-middleware-cache = "0.1.1" reqwest-middleware-cache = "0.1.1"
deranged = "=0.4.0"
droplet-rs = "0.7.3"
gethostname = "1.0.1"
zstd = "0.13.3"
tar = "0.4.44"
rand = "0.9.1"
regex = "1.11.1"
tempfile = "3.19.1"
schemars = "0.8.22"
sha1 = "0.10.6"
dirs = "6.0.0"
whoami = "1.6.0"
filetime = "0.2.25"
walkdir = "2.5.0"
known-folders = "1.2.0"
native_model = { version = "0.6.1", features = ["rmp_serde_1_3"] }
tauri-plugin-opener = "2.4.0"
bitcode = "0.6.6"
reqwest-websocket = "0.5.0" reqwest-websocket = "0.5.0"
futures-lite = "2.6.0" schemars = "0.8.22"
page_size = "0.6.0" serde_json = "1"
serde_with = "3.12.0"
sha1 = "0.10.6"
shared_child = "1.0.1"
slice-deque = "0.3.0"
sysinfo = "0.36.1" sysinfo = "0.36.1"
humansize = "2.1.3" tar = "0.4.44"
# tailscale = { path = "./tailscale" } tauri-plugin-autostart = "*"
tauri-plugin-deep-link = "*"
[dependencies.dynfmt] tauri-plugin-dialog = "*"
version = "0.1.5" tauri-plugin-opener = "*"
features = ["curly"] tauri-plugin-os = "*"
tauri-plugin-shell = "*"
[dependencies.tauri] tempfile = "3.19.1"
version = "2.7.0" throttle_my_fn = "0.2.6"
features = ["protocol-asset", "tray-icon"] tokio-util = { version = "0.7.16", features = ["io"] }
umu-wrapper-lib = "0.1.0"
[dependencies.tokio] url = "2.5.2"
version = "1.40.0" urlencoding = "2.1.3"
features = ["rt", "tokio-macros", "signal"] utils = { path = "./utils" } # utils
walkdir = "2.5.0"
webbrowser = "1.0.2"
whoami = "1.6.0"
wry = { version = "*", features = [] }
zstd = "0.13.3"
[dependencies.log4rs] [dependencies.log4rs]
version = "1.3.0" version = "1.3.0"
features = ["console_appender", "file_appender"] features = ["console_appender", "file_appender"]
[dependencies.rustbreak]
version = "2"
features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
[dependencies.rustix] [dependencies.rustix]
version = "0.38.37" version = "0.38.37"
features = ["fs"] features = ["fs"]
[dependencies.uuid]
version = "1.10.0"
features = ["v4", "fast-rng", "macro-diagnostics"]
[dependencies.rustbreak]
version = "2"
features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
[dependencies.reqwest]
version = "0.12"
default-features = false
features = ["json", "http2", "blocking", "rustls-tls", "native-tls-alpn", "rustls-tls-webpki-roots"]
[dependencies.serde] [dependencies.serde]
version = "1" version = "1"
features = ["derive", "rc"] features = ["derive", "rc"]
[dependencies.tauri]
version = "2.9.5"
features = ["protocol-asset", "tray-icon", "unstable"]
[dependencies.tokio]
version = "1.40.0"
features = ["rt", "signal", "tokio-macros"]
[dependencies.uuid]
version = "1.10.0"
features = ["fast-rng", "macro-diagnostics", "v4"]
[build-dependencies]
tauri-build = { version = "*", features = [] }
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
[profile.release] [profile.release]
lto = true lto = true
panic = "abort"
codegen-units = 1 codegen-units = 1
panic = 'abort'
[profile.dev.package."*"]
# Set the default for dependencies in Development mode.
opt-level = 3
[profile.dev]
# Turn on a small amount of optimisation in Development mode.
opt-level = 1

4862
src-tauri/client/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,10 @@
use serde::Serialize;
use crate::{app_status::AppStatus, user::User};
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppState {
pub status: AppStatus,
pub user: Option<User>
}

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,
}

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(())
}

View File

@@ -0,0 +1,52 @@
use std::{
ffi::OsStr,
path::PathBuf,
process::{Command, Stdio},
sync::LazyLock,
};
use log::info;
pub static COMPAT_INFO: LazyLock<Option<CompatInfo>> = LazyLock::new(create_new_compat_info);
pub static UMU_LAUNCHER_EXECUTABLE: LazyLock<Option<PathBuf>> = LazyLock::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()
}

View File

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

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,
}

View File

@@ -0,0 +1,19 @@
[package]
name = "cloud_saves"
version = "0.1.0"
edition = "2024"
[dependencies]
database = { version = "0.1.0", path = "../database" }
dirs = "6.0.0"
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,234 @@
use std::{collections::HashMap, path::PathBuf, str::FromStr};
#[cfg(target_os = "linux")]
use database::platform::Platform;
use database::{GameVersion, db::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

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

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)
}
}

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

@@ -1,7 +1,6 @@
use crate::database::db::GameVersion; use database::GameVersion;
use super::conditions::{Condition};
use super::conditions::Condition;
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CloudSaveMetadata { pub struct CloudSaveMetadata {
@@ -16,15 +15,17 @@ pub struct GameFile {
pub id: Option<String>, pub id: Option<String>,
pub data_type: DataType, pub data_type: DataType,
pub tags: Vec<Tag>, pub tags: Vec<Tag>,
pub conditions: Vec<Condition> pub conditions: Vec<Condition>,
} }
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub enum DataType { pub enum DataType {
Registry, Registry,
File, File,
Other Other,
} }
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] #[derive(
Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum Tag { pub enum Tag {
Config, Config,
@@ -32,4 +33,4 @@ pub enum Tag {
#[default] #[default]
#[serde(other)] #[serde(other)]
Other, Other,
} }

View File

@@ -1,11 +1,10 @@
use std::sync::LazyLock; use std::sync::LazyLock;
use database::platform::Platform;
use regex::Regex; use regex::Regex;
use crate::process::process_manager::Platform;
use super::placeholder::*; use super::placeholder::*;
pub fn normalize(path: &str, os: Platform) -> String { pub fn normalize(path: &str, os: Platform) -> String {
let mut path = path.trim().trim_end_matches(['/', '\\']).replace('\\', "/"); let mut path = path.trim().trim_end_matches(['/', '\\']).replace('\\', "/");
@@ -14,18 +13,25 @@ pub fn normalize(path: &str, os: Platform) -> String {
} }
static CONSECUTIVE_SLASHES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"/{2,}").unwrap()); 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_1: LazyLock<Regex> =
static UNNECESSARY_DOUBLE_STAR_2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*{2,}([^/*])").unwrap()); 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_WILDCARD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\*)+$").unwrap());
static ENDING_DOT: 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 INTERMEDIATE_DOT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\./)").unwrap());
static BLANK_SEGMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\s+/)").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: 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_ROAMING: LazyLock<Regex> =
static APP_DATA_LOCAL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%localappdata%").unwrap()); LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Roaming").unwrap());
static APP_DATA_LOCAL_2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Local/").unwrap()); static APP_DATA_LOCAL: LazyLock<Regex> =
static USER_PROFILE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%").unwrap()); LazyLock::new(|| Regex::new(r"(?i)%localappdata%").unwrap());
static DOCUMENTS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/Documents").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 [ for (pattern, replacement) in [
(&CONSECUTIVE_SLASHES, "/"), (&CONSECUTIVE_SLASHES, "/"),
@@ -66,7 +72,9 @@ pub fn normalize(path: &str, os: Platform) -> String {
fn too_broad(path: &str) -> bool { fn too_broad(path: &str) -> bool {
println!("Path: {}", path); println!("Path: {}", path);
use {BASE, HOME, ROOT, STORE_USER_ID, WIN_APP_DATA, WIN_DIR, WIN_DOCUMENTS, XDG_CONFIG, XDG_DATA}; use {
BASE, HOME, ROOT, STORE_USER_ID, WIN_APP_DATA, WIN_DIR, WIN_DOCUMENTS, XDG_CONFIG, XDG_DATA,
};
let path_lower = path.to_lowercase(); let path_lower = path.to_lowercase();
@@ -77,7 +85,9 @@ fn too_broad(path: &str) -> bool {
} }
for item in AVOID_WILDCARDS { for item in AVOID_WILDCARDS {
if path.starts_with(&format!("{}/*", item)) || path.starts_with(&format!("{}/{}", item, STORE_USER_ID)) { if path.starts_with(&format!("{}/*", item))
|| path.starts_with(&format!("{}/{}", item, STORE_USER_ID))
{
return true; return true;
} }
} }
@@ -124,7 +134,6 @@ fn too_broad(path: &str) -> bool {
return true; return true;
} }
} }
// Drive letters: // Drive letters:
let drives: Regex = Regex::new(r"^[a-zA-Z]:$").unwrap(); let drives: Regex = Regex::new(r"^[a-zA-Z]:$").unwrap();
@@ -159,4 +168,4 @@ pub fn usable(path: &str) -> bool {
&& !path.starts_with("../") && !path.starts_with("../")
&& !too_broad(path) && !too_broad(path)
&& !unprintable.is_match(path) && !unprintable.is_match(path)
} }

View File

@@ -13,12 +13,12 @@ pub enum CommonPath {
impl CommonPath { impl CommonPath {
pub fn get(&self) -> Option<PathBuf> { pub fn get(&self) -> Option<PathBuf> {
static CONFIG: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::config_dir()); static CONFIG: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::config_dir);
static DATA: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::data_dir()); static DATA: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::data_dir);
static DATA_LOCAL: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::data_local_dir()); static DATA_LOCAL: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::data_local_dir);
static DOCUMENT: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::document_dir()); static DOCUMENT: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::document_dir);
static HOME: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::home_dir()); static HOME: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::home_dir);
static PUBLIC: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::public_dir()); static PUBLIC: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::public_dir);
#[cfg(windows)] #[cfg(windows)]
static DATA_LOCAL_LOW: LazyLock<Option<PathBuf>> = LazyLock::new(|| { static DATA_LOCAL_LOW: LazyLock<Option<PathBuf>> = LazyLock::new(|| {

View File

@@ -48,4 +48,4 @@ pub const XDG_DATA: &str = "<xdgData>"; // %WINDIR% on Windows
pub const XDG_CONFIG: &str = "<xdgConfig>"; // $XDG_DATA_HOME on Linux pub const XDG_CONFIG: &str = "<xdgConfig>"; // $XDG_DATA_HOME on Linux
pub const SKIP: &str = "<skip>"; // $XDG_CONFIG_HOME on Linux pub const SKIP: &str = "<skip>"; // $XDG_CONFIG_HOME on Linux
pub static OS_USERNAME: LazyLock<String> = LazyLock::new(|| whoami::username()); pub static OS_USERNAME: LazyLock<String> = LazyLock::new(whoami::username);

View File

@@ -1,22 +1,17 @@
use std::{ use std::{
fs::{self, create_dir_all, File}, fs::{self, File, create_dir_all},
io::{self, ErrorKind, Read, Write}, io::{self, Read, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
thread::sleep,
time::Duration,
}; };
use super::{ use crate::error::BackupError;
backup_manager::BackupHandler, conditions::Condition, metadata::GameFile, placeholder::*,
}; use super::{backup_manager::BackupHandler, placeholder::*};
use database::GameVersion;
use log::{debug, warn}; use log::{debug, warn};
use rustix::path::Arg; use rustix::path::Arg;
use tempfile::tempfile; use tempfile::tempfile;
use crate::{
database::db::GameVersion, error::backup_error::BackupError, process::process_manager::Platform,
};
use super::{backup_manager::BackupManager, metadata::CloudSaveMetadata, normalise::normalize}; use super::{backup_manager::BackupManager, metadata::CloudSaveMetadata, normalise::normalize};
pub fn resolve(meta: &mut CloudSaveMetadata) -> File { pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
@@ -31,7 +26,7 @@ pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
.iter() .iter()
.find_map(|p| match p { .find_map(|p| match p {
super::conditions::Condition::Os(os) => Some(os), super::conditions::Condition::Os(os) => Some(os),
_ => None, _ => None
}) })
.cloned() .cloned()
{ {
@@ -64,7 +59,7 @@ pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
let binding = serde_json::to_string(meta).unwrap(); let binding = serde_json::to_string(meta).unwrap();
let serialized = binding.as_bytes(); let serialized = binding.as_bytes();
let mut file = tempfile().unwrap(); let mut file = tempfile().unwrap();
file.write(serialized).unwrap(); file.write_all(serialized).unwrap();
tarball.append_file("metadata", &mut file).unwrap(); tarball.append_file("metadata", &mut file).unwrap();
tarball.into_inner().unwrap().finish().unwrap() tarball.into_inner().unwrap().finish().unwrap()
} }
@@ -97,7 +92,7 @@ pub fn extract(file: PathBuf) -> Result<(), BackupError> {
.iter() .iter()
.find_map(|p| match p { .find_map(|p| match p {
super::conditions::Condition::Os(os) => Some(os), super::conditions::Condition::Os(os) => Some(os),
_ => None, _ => None
}) })
.cloned() .cloned()
{ {
@@ -116,7 +111,7 @@ pub fn extract(file: PathBuf) -> Result<(), BackupError> {
}; };
let new_path = parse_path(file.path.into(), handler, &manifest.game_version)?; let new_path = parse_path(file.path.into(), handler, &manifest.game_version)?;
create_dir_all(&new_path.parent().unwrap()).unwrap(); create_dir_all(new_path.parent().unwrap()).unwrap();
println!( println!(
"Current path {:?} copying to {:?}", "Current path {:?} copying to {:?}",
@@ -133,23 +128,22 @@ pub fn copy_item<P: AsRef<Path>>(src: P, dest: P) -> io::Result<()> {
let src_path = src.as_ref(); let src_path = src.as_ref();
let dest_path = dest.as_ref(); let dest_path = dest.as_ref();
let metadata = fs::metadata(&src_path)?; let metadata = fs::metadata(src_path)?;
if metadata.is_file() { if metadata.is_file() {
// Ensure the parent directory of the destination exists for a file copy // Ensure the parent directory of the destination exists for a file copy
if let Some(parent) = dest_path.parent() { if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
fs::copy(&src_path, &dest_path)?; fs::copy(src_path, dest_path)?;
} else if metadata.is_dir() { } else if metadata.is_dir() {
// For directories, we call the recursive helper function. // For directories, we call the recursive helper function.
// The destination for the recursive copy is the `dest_path` itself. // The destination for the recursive copy is the `dest_path` itself.
copy_dir_recursive(&src_path, &dest_path)?; copy_dir_recursive(src_path, dest_path)?;
} else { } else {
// Handle other file types like symlinks if necessary, // Handle other file types like symlinks if necessary,
// for now, return an error or skip. // for now, return an error or skip.
return Err(io::Error::new( return Err(io::Error::other(
io::ErrorKind::Other,
format!("Source {:?} is neither a file nor a directory", src_path), format!("Source {:?} is neither a file nor a directory", src_path),
)); ));
} }
@@ -158,7 +152,7 @@ pub fn copy_item<P: AsRef<Path>>(src: P, dest: P) -> io::Result<()> {
} }
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> { fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
fs::create_dir_all(&dest)?; fs::create_dir_all(dest)?;
for entry in fs::read_dir(src)? { for entry in fs::read_dir(src)? {
let entry = entry?; let entry = entry?;
@@ -220,43 +214,3 @@ pub fn parse_path(
println!("Final line: {:?}", &s); println!("Final line: {:?}", &s);
Ok(s) Ok(s)
} }
pub fn test() {
let mut meta = CloudSaveMetadata {
files: vec![
GameFile {
path: String::from("<home>/favicon.png"),
id: None,
data_type: super::metadata::DataType::File,
tags: Vec::new(),
conditions: vec![Condition::Os(Platform::Linux)],
},
GameFile {
path: String::from("<home>/Documents/Pixel Art"),
id: None,
data_type: super::metadata::DataType::File,
tags: Vec::new(),
conditions: vec![Condition::Os(Platform::Linux)],
},
],
game_version: GameVersion {
game_id: String::new(),
version_name: String::new(),
platform: Platform::Linux,
launch_command: String::new(),
launch_args: Vec::new(),
launch_command_template: String::new(),
setup_command: String::new(),
setup_args: Vec::new(),
setup_command_template: String::new(),
only_setup: true,
version_index: 0,
delta: false,
umu_id_override: None,
},
save_id: String::from("aaaaaaa"),
};
//resolve(&mut meta);
extract("save".into()).unwrap();
}

View File

@@ -0,0 +1,19 @@
[package]
name = "database"
version = "0.1.0"
edition = "2024"
[dependencies]
aes = "0.8.4"
anyhow = "*"
chrono = "0.4.42"
ctr = "0.9.2"
dirs = "6.0.0"
keyring = { version = "3.6.3", features = ["apple-native", "crypto-rust", "linux-native-sync-persistent", "windows-native"] }
log = "0.4.28"
rand = "0.9.2"
ron = "0.12.0"
serde = "1.0.228"
serde_with = "3.15.0"
url = "2.5.7"
whoami = "1.6.1"

View File

@@ -0,0 +1,37 @@
use std::{
path::PathBuf,
sync::{Arc, LazyLock},
};
use keyring::Entry;
use log::info;
use crate::interface::{DatabaseInterface};
pub static DB: LazyLock<DatabaseInterface> = LazyLock::new(DatabaseInterface::set_up_database);
#[cfg(not(debug_assertions))]
static DATA_ROOT_PREFIX: &str = "drop";
#[cfg(debug_assertions)]
static DATA_ROOT_PREFIX: &str = "drop-debug";
pub static DATA_ROOT_DIR: LazyLock<Arc<PathBuf>> = LazyLock::new(|| {
Arc::new(
dirs::data_dir()
.expect("Failed to get data dir")
.join(DATA_ROOT_PREFIX),
)
});
pub(crate) static KEY_IV: LazyLock<([u8; 16], [u8; 16])> = LazyLock::new(|| {
let entry = Entry::new("drop", "database_key").expect("failed to open keyring");
let mut key = entry.get_secret().unwrap_or_else(|_| {
let mut buffer = [0u8; 32];
rand::fill(&mut buffer);
entry.set_secret(&buffer).expect("failed to save key");
info!("created new database key");
buffer.to_vec()
});
let new = key.split_off(16);
(new.try_into().expect("failed to extract key"), key.try_into().expect("failed to extract iv"))
});

View File

@@ -0,0 +1,249 @@
use std::{
fs::{self, create_dir_all},
mem::ManuallyDrop,
ops::{Deref, DerefMut},
path::{Path, PathBuf},
sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard},
};
use aes::cipher::{KeyIvInit as _, StreamCipher as _};
use anyhow::Error;
use chrono::Utc;
use log::{debug, error, info, warn};
use url::Url;
use crate::{
db::{DATA_ROOT_DIR, DB, KEY_IV},
models::{
self,
data::{Database, DatabaseVersionSerializable},
},
};
type Aes128Ctr64LE = ctr::Ctr64LE<aes::Aes128>;
pub struct DatabaseInterface {
data: RwLock<models::data::Database>,
path: PathBuf,
}
impl DatabaseInterface {
pub fn set_up_database() -> Self {
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 DatabaseInterface::open_at_path(&db_path) {
Ok(db) => db.unwrap(),
Err(e) => handle_invalid_database(e, db_path, games_base_dir, cache_dir)
.expect("failed to recover from failed database"),
}
} else {
let default = Database::new(games_base_dir, None, cache_dir);
debug!("Creating database at path {}", db_path.display());
DatabaseInterface::create_at_path(&db_path, default)
.expect("Database could not be created")
}
}
pub fn open_at_path(db_path: &Path) -> Result<Option<DatabaseInterface>, Error> {
if !db_path.exists() {
return Ok(None);
};
let mut database_data = std::fs::read(db_path)?;
let (key, iv) = *KEY_IV;
let mut cipher = Aes128Ctr64LE::new(&key.into(), &iv.into());
cipher.apply_keystream(&mut database_data);
let database_data = String::from_utf8(database_data)?;
let database_data: DatabaseVersionSerializable = ron::from_str(&database_data)?;
Ok(Some(DatabaseInterface {
data: RwLock::new(database_data.0),
path: db_path.to_path_buf(),
}))
}
pub fn create_at_path(db_path: &Path, database: Database) -> Result<DatabaseInterface, Error> {
let database = DatabaseVersionSerializable(database);
let mut database_data = ron::to_string(&database)?.into_bytes();
let (key, iv) = *KEY_IV;
let mut cipher = Aes128Ctr64LE::new(&key.into(), &iv.into());
cipher.apply_keystream(&mut database_data);
std::fs::write(db_path, database_data)?;
Ok(DatabaseInterface {
data: RwLock::new(database.0),
path: db_path.to_path_buf(),
})
}
pub fn database_is_set_up(&self) -> bool {
!borrow_db_checked().base_url.is_empty()
}
pub 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))
}
fn save(&self) -> Result<(), Error> {
let lock = self.data.read().expect("failed to lock database to save");
DatabaseInterface::create_at_path(&self.path, lock.clone())?;
Ok(())
}
fn borrow_data(
&self,
) -> Result<
std::sync::RwLockReadGuard<'_, Database>,
PoisonError<std::sync::RwLockReadGuard<'_, Database>>,
> {
self.data.read()
}
fn borrow_data_mut(
&self,
) -> Result<
std::sync::RwLockWriteGuard<'_, Database>,
PoisonError<std::sync::RwLockWriteGuard<'_, Database>>,
> {
self.data.write()
}
}
// TODO: Make the error relelvant rather than just assume that it's a Deserialize error
fn handle_invalid_database(
error: Error,
db_path: PathBuf,
games_base_dir: PathBuf,
cache_dir: PathBuf,
) -> Result<DatabaseInterface, Error> {
warn!("{error:?}");
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);
Ok(DatabaseInterface::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}");
}
}
}

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,
};

View File

@@ -0,0 +1,284 @@
pub mod data {
use std::{hash::Hash, path::PathBuf};
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 Database = v1::Database;
pub type GameVersion = v1::GameVersion;
pub type Settings = v1::Settings;
pub type DatabaseAuth = v1::DatabaseAuth;
pub type GameDownloadStatus = v1::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 = v1::DatabaseApplications;
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);
}
}
#[derive(Serialize, Deserialize)]
enum DatabaseVersionEnum {
V1 { database: v1::Database },
}
pub struct DatabaseVersionSerializable(pub(crate) Database);
impl Serialize for DatabaseVersionSerializable {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
// Always serialize to latest version
DatabaseVersionEnum::V1 {
database: self.0.clone(),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for DatabaseVersionSerializable {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match DatabaseVersionEnum::deserialize(deserializer)? {
DatabaseVersionEnum::V1 { database } => DatabaseVersionSerializable(database),
})
}
}
mod v1 {
use serde_with::serde_as;
use std::{collections::HashMap, path::PathBuf};
use crate::platform::Platform;
use super::{Deserialize, Serialize};
fn default_template() -> String {
"{}".to_owned()
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct GameVersion {
pub game_id: String,
pub version_id: String,
pub display_name: Option<String>,
pub version_path: String,
pub only_setup: bool,
pub version_index: usize,
pub delta: bool,
#[serde(default = "default_template")]
pub launch_template: String,
pub launches: Vec<LaunchConfiguration>,
pub setups: Vec<SetupConfiguration>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LaunchConfiguration {
pub launch_id: String,
pub name: String,
pub command: String,
pub platform: Platform,
pub umu_id_override: Option<String>,
pub executor: Option<LaunchConfigurationExecutor>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
/**
* This is intended to be used to look up the actual launch configuration that we store elsewhere
*/
pub struct LaunchConfigurationExecutor {
pub launch_id: String,
pub game_id: String,
pub version_id: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SetupConfiguration {
pub command: String,
pub platform: Platform,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
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,
}
}
}
#[derive(Serialize, Clone, Deserialize, Debug)]
#[serde(tag = "type")]
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,
},
}
// Stuff that shouldn't be synced to disk
#[derive(Clone, Serialize, Deserialize, Debug)]
pub enum ApplicationTransientStatus {
Queued { version_id: String },
Downloading { version_id: String },
Uninstalling {},
Updating { version_id: String },
Validating { version_id: String },
Running {},
}
#[derive(serde::Serialize, Clone, Deserialize)]
pub struct DatabaseAuth {
pub private: String,
pub cert: String,
pub client_id: String,
pub web_token: Option<String>,
}
#[derive(
Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone, Copy,
)]
pub enum DownloadType {
Game,
Tool,
Dlc,
Mod,
}
#[derive(Debug, Eq, PartialOrd, Ord, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DownloadableMetadata {
pub id: String,
pub version: String,
pub target_platform: Platform,
pub download_type: DownloadType,
}
impl DownloadableMetadata {
pub fn new(
id: String,
version: String,
target_platform: Platform,
download_type: DownloadType,
) -> Self {
Self {
id,
version,
target_platform,
download_type,
}
}
}
#[serde_as]
#[derive(Serialize, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
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, GameVersion>,
pub installed_game_version: HashMap<String, DownloadableMetadata>,
#[serde(skip)]
pub transient_statuses: HashMap<DownloadableMetadata, ApplicationTransientStatus>,
}
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database {
#[serde(default)]
pub settings: Settings,
pub auth: Option<DatabaseAuth>,
pub base_url: String,
pub applications: DatabaseApplications,
#[serde(skip)]
pub prev_database: Option<PathBuf>,
pub cache_dir: PathBuf,
}
}
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,
}
}
}
impl DatabaseAuth {
pub fn new(
private: String,
cert: String,
client_id: String,
web_token: Option<String>,
) -> Self {
Self {
private,
cert,
client_id,
web_token,
}
}
}
}

View File

@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, Ord)]
pub enum Platform {
Windows,
Linux,
#[allow(non_camel_case_types)]
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,20 @@
[package]
name = "download_manager"
version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = "0.1.89"
atomic-instant-full = "0.1"
database = { version = "0.1.0", path = "../database" }
futures-util = "0.3.31"
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 = "*"
throttle_my_fn = "0.2.6"
tokio = { version = "1.48.0", features = ["sync"] }
utils = { version = "0.1.0", path = "../utils" }

View File

@@ -0,0 +1,144 @@
use std::{
collections::HashMap,
sync::RwLock,
time::{Duration, Instant},
};
use futures_util::StreamExt;
use remote::{
error::RemoteAccessError,
requests::{generate_url, make_authenticated_get},
utils::DROP_CLIENT_ASYNC,
};
use serde::Deserialize;
use tauri::Url;
use crate::util::semaphore::{SyncSemaphore, SyncSemaphorePermit};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DepotManifestContent {
version_id: String,
//compression: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DepotManifest {
content: HashMap<String, Vec<DepotManifestContent>>,
}
struct Depot {
endpoint: String,
manifest: Option<DepotManifest>,
latest_speed: Option<usize>, // bytes per second
current_downloads: SyncSemaphore,
}
pub struct DepotManager {
depots: RwLock<Vec<Depot>>,
}
#[derive(Deserialize)]
struct ServersideDepot {
endpoint: String,
}
const SPEEDTEST_TIMEOUT: Duration = Duration::from_secs(4);
impl DepotManager {
pub fn new() -> Self {
Self {
depots: RwLock::new(Vec::new()),
}
}
pub async fn sync_depots(&self) -> Result<(), RemoteAccessError> {
let depots = make_authenticated_get(generate_url(&["/api/v1/client/depots"], &[])?).await?;
let depots: Vec<ServersideDepot> = depots.json().await?;
let mut new_depots = depots
.into_iter()
.map(|depot| Depot {
endpoint: if depot.endpoint.ends_with("/") {
depot.endpoint
} else {
format!("{}/", depot.endpoint)
},
manifest: None,
latest_speed: None,
current_downloads: SyncSemaphore::new(),
})
.collect::<Vec<Depot>>();
for depot in &mut new_depots {
let manifest_url = Url::parse(&depot.endpoint)?.join("manifest.json")?;
let manifest = DROP_CLIENT_ASYNC.get(manifest_url).send().await?;
let manifest: DepotManifest = manifest.json().await?;
depot.manifest.replace(manifest);
let speedtest_url = Url::parse(&depot.endpoint)?.join("speedtest")?;
let speedtest = DROP_CLIENT_ASYNC.get(speedtest_url).send().await?;
let mut stream = speedtest.bytes_stream();
let start = Instant::now();
let mut total_length = 0;
while let Some(chunk) = stream.next().await {
let length = chunk?.len();
total_length += length;
if SPEEDTEST_TIMEOUT <= start.elapsed() {
break;
}
}
let elapsed = start.elapsed().as_millis() as usize;
let speed = if elapsed == 0 { usize::MAX } else { (total_length / elapsed) * 1000 };
depot.latest_speed.replace(speed);
}
let mut depot_lock = self.depots.write().unwrap();
*depot_lock = new_depots;
Ok(())
}
pub fn next_depot(
&self,
game_id: &str,
version_id: &str,
) -> Result<(String, SyncSemaphorePermit), RemoteAccessError> {
let lock = self.depots.read().unwrap();
let best_depot = lock
.iter()
.filter(|v| {
let manifest = match &v.manifest {
Some(v) => v,
None => return false,
};
let versions = match manifest.content.get(game_id) {
Some(v) => v,
None => return false,
};
let _version = match versions.iter().find(|v| v.version_id == version_id) {
Some(v) => v,
None => return false,
};
true
})
.max_by(|x, y| {
let x_speed = x.latest_speed.unwrap_or(0) / x.current_downloads.permits();
let y_speed = y.latest_speed.unwrap_or(0) / y.current_downloads.permits();
x_speed.cmp(&y_speed)
})
.ok_or(RemoteAccessError::NoDepots)?;
Ok((
best_depot.endpoint.clone(),
best_depot.current_downloads.acquire(),
))
}
}

View File

@@ -1,19 +1,22 @@
use core::panic;
use std::{ use std::{
collections::HashMap, collections::HashMap,
sync::{ sync::{Arc, Mutex},
Arc, Mutex, time::Duration,
mpsc::{Receiver, Sender, channel},
},
thread::{JoinHandle, spawn},
}; };
use database::DownloadableMetadata;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, async_runtime::JoinHandle};
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::{sync::mpsc, time::timeout};
use utils::{app_emit, lock, send};
use crate::{ use crate::{
database::models::data::DownloadableMetadata, depot_manager::DepotManager,
error::application_download_error::ApplicationDownloadError, download_manager_frontend::DownloadStatus,
games::library::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent}, error::ApplicationDownloadError,
frontend_updates::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent},
}; };
use super::{ use super::{
@@ -75,17 +78,17 @@ pub struct DownloadManagerBuilder {
status: Arc<Mutex<DownloadManagerStatus>>, status: Arc<Mutex<DownloadManagerStatus>>,
app_handle: AppHandle, app_handle: AppHandle,
current_download_agent: Option<DownloadAgent>, // Should be the only download agent in the map with the "Go" flag
current_download_thread: Mutex<Option<JoinHandle<()>>>, current_download_thread: Mutex<Option<JoinHandle<()>>>,
active_control_flag: Option<DownloadThreadControl>, active_control_flag: Option<DownloadThreadControl>,
} }
impl DownloadManagerBuilder { impl DownloadManagerBuilder {
pub fn build(app_handle: AppHandle) -> DownloadManager { pub fn build(app_handle: AppHandle) -> DownloadManager {
let queue = Queue::new(); let queue = Queue::new();
let (command_sender, command_receiver) = channel(); let (command_sender, command_receiver) = mpsc::channel(1500);
let active_progress = Arc::new(Mutex::new(None)); let active_progress = Arc::new(Mutex::new(None));
let status = Arc::new(Mutex::new(DownloadManagerStatus::Empty)); let status = Arc::new(Mutex::new(DownloadManagerStatus::Empty));
let depot_manager = Arc::new(DepotManager::new());
let manager = Self { let manager = Self {
download_agent_registry: HashMap::new(), download_agent_registry: HashMap::new(),
download_queue: queue.clone(), download_queue: queue.clone(),
@@ -95,73 +98,91 @@ impl DownloadManagerBuilder {
progress: active_progress.clone(), progress: active_progress.clone(),
app_handle, app_handle,
current_download_agent: None,
current_download_thread: Mutex::new(None), current_download_thread: Mutex::new(None),
active_control_flag: None, active_control_flag: None,
}; };
let terminator = spawn(|| manager.manage_queue()); let terminator = tauri::async_runtime::spawn(async move {
let result = manager.manage_queue().await;
info!("download manager exited with result: {:?}", result);
});
DownloadManager::new(terminator, queue, active_progress, command_sender) DownloadManager::new(terminator, queue, active_progress, command_sender, depot_manager)
} }
fn set_status(&self, status: DownloadManagerStatus) { fn set_status(&self, status: DownloadManagerStatus) {
*self.status.lock().unwrap() = status; *lock!(self.status) = status;
} }
fn remove_and_cleanup_front_download(&mut self, meta: &DownloadableMetadata) -> DownloadAgent { async fn remove_and_cleanup_front_download(
&mut self,
meta: &DownloadableMetadata,
) -> DownloadAgent {
self.download_queue.pop_front(); self.download_queue.pop_front();
let download_agent = self.download_agent_registry.remove(meta).unwrap(); let download_agent = self.download_agent_registry.remove(meta).unwrap();
self.cleanup_current_download(); self.cleanup_current_download().await;
download_agent download_agent
} }
// CAREFUL WITH THIS FUNCTION // CAREFUL WITH THIS FUNCTION
// Make sure the download thread is terminated // Make sure the download thread is terminated
fn cleanup_current_download(&mut self) { async fn cleanup_current_download(&mut self) {
self.active_control_flag = None; self.active_control_flag = None;
*self.progress.lock().unwrap() = None; *lock!(self.progress) = None;
self.current_download_agent = None;
let mut download_thread_lock = self.current_download_thread.lock().unwrap(); if let Some(unfinished_thread) = {
*download_thread_lock = None; let mut download_thread_lock = lock!(self.current_download_thread);
drop(download_thread_lock); download_thread_lock.take()
} {
let _ = unfinished_thread.await;
}
} }
fn stop_and_wait_current_download(&self) { async fn stop_and_wait_current_download(&self) -> bool {
self.set_status(DownloadManagerStatus::Paused); self.set_status(DownloadManagerStatus::Paused);
if let Some(current_flag) = &self.active_control_flag { if let Some(current_flag) = &self.active_control_flag {
current_flag.set(DownloadThreadControlFlag::Stop); current_flag.set(DownloadThreadControlFlag::Stop);
if let Some(current_download_thread) = {
let mut download_thread_lock = lock!(self.current_download_thread);
download_thread_lock.take()
} {
let result = timeout(Duration::from_secs(4), async {
current_download_thread.await.is_ok()
})
.await;
if let Ok(result) = result {
return result;
};
panic!("failed to cleanup download: timeout after 4 seconds");
};
} }
let mut download_thread_lock = self.current_download_thread.lock().unwrap(); true
if let Some(current_download_thread) = download_thread_lock.take() {
current_download_thread.join().unwrap();
}
} }
fn manage_queue(mut self) -> Result<(), ()> { async fn manage_queue(mut self) -> Result<(), ()> {
loop { loop {
let signal = match self.command_receiver.recv() { let signal = match self.command_receiver.recv().await {
Ok(signal) => signal, Some(signal) => signal,
Err(_) => return Err(()), None => return Err(()),
}; };
match signal { match signal {
DownloadManagerSignal::Go => { DownloadManagerSignal::Go => {
self.manage_go_signal(); self.manage_go_signal().await;
} }
DownloadManagerSignal::Stop => { DownloadManagerSignal::Stop => {
self.manage_stop_signal(); self.manage_stop_signal();
} }
DownloadManagerSignal::Completed(meta) => { DownloadManagerSignal::Completed(meta) => {
self.manage_completed_signal(meta); self.manage_completed_signal(meta).await;
} }
DownloadManagerSignal::Queue(download_agent) => { DownloadManagerSignal::Queue(download_agent) => {
self.manage_queue_signal(download_agent); self.manage_queue_signal(download_agent).await;
} }
DownloadManagerSignal::Error(e) => { DownloadManagerSignal::Error(e) => {
self.manage_error_signal(e); self.manage_error_signal(e).await;
} }
DownloadManagerSignal::UpdateUIQueue => { DownloadManagerSignal::UpdateUIQueue => {
self.push_ui_queue_update(); self.push_ui_queue_update();
@@ -170,16 +191,16 @@ impl DownloadManagerBuilder {
self.push_ui_stats_update(kbs, time); self.push_ui_stats_update(kbs, time);
} }
DownloadManagerSignal::Finish => { DownloadManagerSignal::Finish => {
self.stop_and_wait_current_download(); self.stop_and_wait_current_download().await;
return Ok(()); return Ok(());
} }
DownloadManagerSignal::Cancel(meta) => { DownloadManagerSignal::Cancel(meta) => {
self.manage_cancel_signal(&meta); self.manage_cancel_signal(&meta).await;
} }
} }
} }
} }
fn manage_queue_signal(&mut self, download_agent: DownloadAgent) { async fn manage_queue_signal(&mut self, download_agent: DownloadAgent) {
debug!("got signal Queue"); debug!("got signal Queue");
let meta = download_agent.metadata(); let meta = download_agent.metadata();
@@ -190,16 +211,14 @@ impl DownloadManagerBuilder {
return; return;
} }
download_agent.on_initialised(&self.app_handle); download_agent.on_queued(&self.app_handle);
self.download_queue.append(meta.clone()); self.download_queue.append(meta.clone());
self.download_agent_registry.insert(meta, download_agent); self.download_agent_registry.insert(meta, download_agent);
self.sender send!(self.sender, DownloadManagerSignal::UpdateUIQueue);
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
} }
fn manage_go_signal(&mut self) { async fn manage_go_signal(&mut self) {
debug!("got signal Go"); debug!("got signal Go");
if self.download_agent_registry.is_empty() { if self.download_agent_registry.is_empty() {
debug!( debug!(
@@ -209,23 +228,13 @@ impl DownloadManagerBuilder {
return; return;
} }
if self.current_download_agent.is_some()
&& self.download_queue.read().front().unwrap()
== &self.current_download_agent.as_ref().unwrap().metadata()
{
debug!(
"Current download agent: {:?}",
self.current_download_agent.as_ref().unwrap().metadata()
);
return;
}
debug!("current download queue: {:?}", self.download_queue.read()); debug!("current download queue: {:?}", self.download_queue.read());
// Should always be Some if the above two statements keep going let agent_data = if let Some(agent_data) = self.download_queue.read().front() {
let agent_data = self.download_queue.read().front().unwrap().clone(); agent_data.clone()
} else {
info!("starting download for {agent_data:?}"); return;
};
let download_agent = self let download_agent = self
.download_agent_registry .download_agent_registry
@@ -233,33 +242,52 @@ impl DownloadManagerBuilder {
.unwrap() .unwrap()
.clone(); .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()); self.active_control_flag = Some(download_agent.control_flag());
self.current_download_agent = Some(download_agent.clone());
let sender = self.sender.clone(); let sender = self.sender.clone();
let mut download_thread_lock = self.current_download_thread.lock().unwrap(); let mut download_thread_lock = lock!(self.current_download_thread);
let app_handle = self.app_handle.clone(); let app_handle = self.app_handle.clone();
*download_thread_lock = Some(spawn(move || { *download_thread_lock = Some(tauri::async_runtime::spawn(async move {
loop { loop {
let download_result = match download_agent.download(&app_handle) { let download_result =
// Ok(true) is for completed and exited properly match download_agent.download(&app_handle).await {
Ok(v) => v, // Ok(true) is for completed and exited properly
Err(e) => { Ok(v) => v,
error!("download {:?} has error {}", download_agent.metadata(), &e); Err(e) => {
download_agent.on_error(&app_handle, &e); error!("download {:?} has error {}", download_agent.metadata(), &e);
sender.send(DownloadManagerSignal::Error(e)).unwrap(); download_agent.on_error(&app_handle, &e);
return; send!(sender, DownloadManagerSignal::Error(e));
} return;
}; }
};
// If the download gets cancelled // If the download gets canceled
// immediately return, on_cancelled gets called for us earlier // immediately return, on_cancelled gets called for us earlier
if !download_result { if !download_result {
return; return;
} }
if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop {
return;
}
let validate_result = match download_agent.validate(&app_handle) { let validate_result = match download_agent.validate(&app_handle) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
@@ -269,17 +297,22 @@ impl DownloadManagerBuilder {
&e &e
); );
download_agent.on_error(&app_handle, &e); download_agent.on_error(&app_handle, &e);
sender.send(DownloadManagerSignal::Error(e)).unwrap(); send!(sender, DownloadManagerSignal::Error(e));
return; return;
} }
}; };
if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop {
return;
}
if validate_result { if validate_result {
download_agent.on_complete(&app_handle); download_agent.on_complete(&app_handle).await;
sender send!(
.send(DownloadManagerSignal::Completed(download_agent.metadata())) sender,
.unwrap(); DownloadManagerSignal::Completed(download_agent.metadata())
sender.send(DownloadManagerSignal::UpdateUIQueue).unwrap(); );
send!(sender, DownloadManagerSignal::UpdateUIQueue);
return; return;
} }
} }
@@ -290,67 +323,56 @@ impl DownloadManagerBuilder {
active_control_flag.set(DownloadThreadControlFlag::Go); active_control_flag.set(DownloadThreadControlFlag::Go);
} }
fn manage_stop_signal(&mut self) { fn manage_stop_signal(&mut self) {
debug!("got signal Stop");
if let Some(active_control_flag) = self.active_control_flag.clone() { if let Some(active_control_flag) = self.active_control_flag.clone() {
self.set_status(DownloadManagerStatus::Paused); self.set_status(DownloadManagerStatus::Paused);
active_control_flag.set(DownloadThreadControlFlag::Stop); active_control_flag.set(DownloadThreadControlFlag::Stop);
} }
} }
fn manage_completed_signal(&mut self, meta: DownloadableMetadata) { async fn manage_completed_signal(&mut self, meta: DownloadableMetadata) {
debug!("got signal Completed"); if let Some(interface) = self.download_queue.read().front()
if let Some(interface) = &self.current_download_agent && interface == &meta
&& interface.metadata() == meta
{ {
self.remove_and_cleanup_front_download(&meta); self.remove_and_cleanup_front_download(&meta).await;
} }
self.push_ui_queue_update(); self.push_ui_queue_update();
self.sender.send(DownloadManagerSignal::Go).unwrap(); send!(self.sender, DownloadManagerSignal::Go);
} }
fn manage_error_signal(&mut self, error: ApplicationDownloadError) { async fn manage_error_signal(&mut self, error: ApplicationDownloadError) {
debug!("got signal Error"); info!("got signal Error");
if let Some(current_agent) = self.current_download_agent.clone() { 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); current_agent.on_error(&self.app_handle, &error);
self.stop_and_wait_current_download(); self.stop_and_wait_current_download().await;
self.remove_and_cleanup_front_download(&current_agent.metadata()); self.remove_and_cleanup_front_download(metadata).await;
} }
self.push_ui_queue_update();
self.set_status(DownloadManagerStatus::Error); self.set_status(DownloadManagerStatus::Error);
} }
fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) { async 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().await;
self.set_status(DownloadManagerStatus::Empty);
if let Some(current_download) = &self.current_download_agent { self.download_queue.pop_front();
if &current_download.metadata() == meta {
self.set_status(DownloadManagerStatus::Paused);
current_download.on_cancelled(&self.app_handle);
self.stop_and_wait_current_download();
self.download_queue.pop_front(); self.cleanup_current_download().await;
self.download_agent_registry.remove(meta);
self.cleanup_current_download(); }
debug!("current download queue: {:?}", self.download_queue.read()); // else just cancel it
} else if let Some(download_agent) = self.download_agent_registry.get(meta) {
// TODO: Collapse these two into a single if statement somehow
else if let Some(download_agent) = self.download_agent_registry.get(meta) {
let index = self.download_queue.get_by_meta(meta);
if let Some(index) = index {
download_agent.on_cancelled(&self.app_handle);
let _ = self.download_queue.edit().remove(index).unwrap();
let removed = self.download_agent_registry.remove(meta);
debug!(
"removed {:?} from queue {:?}",
removed.map(|x| x.metadata()),
self.download_queue.read()
);
}
}
} else if let Some(download_agent) = self.download_agent_registry.get(meta) {
let index = self.download_queue.get_by_meta(meta); let index = self.download_queue.get_by_meta(meta);
if let Some(index) = index { if let Some(index) = index {
download_agent.on_cancelled(&self.app_handle); download_agent.on_cancelled(&self.app_handle);
let _ = self.download_queue.edit().remove(index).unwrap(); let _ = self.download_queue.edit().remove(index);
let removed = self.download_agent_registry.remove(meta); let removed = self.download_agent_registry.remove(meta);
debug!( debug!(
"removed {:?} from queue {:?}", "removed {:?} from queue {:?}",
@@ -360,11 +382,12 @@ impl DownloadManagerBuilder {
} }
} }
self.push_ui_queue_update(); self.push_ui_queue_update();
send!(self.sender, DownloadManagerSignal::Go);
} }
fn push_ui_stats_update(&self, kbs: usize, time: usize) { fn push_ui_stats_update(&self, kbs: usize, time: usize) {
let event_data = StatsUpdateEvent { speed: kbs, time }; let event_data = StatsUpdateEvent { speed: kbs, time };
self.app_handle.emit("update_stats", event_data).unwrap(); app_emit!(&self.app_handle, "update_stats", event_data);
} }
fn push_ui_queue_update(&self) { fn push_ui_queue_update(&self) {
let queue = &self.download_queue.read(); let queue = &self.download_queue.read();
@@ -383,6 +406,6 @@ impl DownloadManagerBuilder {
.collect(); .collect();
let event_data = QueueUpdateEvent { queue: queue_objs }; let event_data = QueueUpdateEvent { queue: queue_objs };
self.app_handle.emit("update_queue", event_data).unwrap(); app_emit!(&self.app_handle, "update_queue", event_data);
} }
} }

View File

@@ -1,27 +1,25 @@
use std::{ use std::{
any::Any,
collections::VecDeque, collections::VecDeque,
fmt::Debug, fmt::Debug,
sync::{ sync::{Arc, Mutex, MutexGuard},
mpsc::{SendError, Sender},
Mutex, MutexGuard,
},
thread::JoinHandle,
}; };
use database::DownloadableMetadata;
use log::{debug, info}; use log::{debug, info};
use serde::Serialize; use serde::Serialize;
use tauri::async_runtime::JoinHandle;
use tokio::sync::mpsc::Sender;
use tokio::sync::mpsc::error::SendError;
use utils::{lock, send};
use crate::{ use crate::{depot_manager::DepotManager, error::ApplicationDownloadError};
database::models::data::DownloadableMetadata,
error::application_download_error::ApplicationDownloadError,
};
use super::{ use super::{
download_manager_builder::{CurrentProgressObject, DownloadAgent}, download_manager_builder::{CurrentProgressObject, DownloadAgent},
util::queue::Queue, util::queue::Queue,
}; };
#[derive(Debug)]
pub enum DownloadManagerSignal { pub enum DownloadManagerSignal {
/// Resumes (or starts) the `DownloadManager` /// Resumes (or starts) the `DownloadManager`
Go, Go,
@@ -62,7 +60,7 @@ impl Serialize for DownloadManagerStatus {
} }
} }
#[derive(Serialize, Clone, Debug)] #[derive(Serialize, Clone, Debug, PartialEq)]
pub enum DownloadStatus { pub enum DownloadStatus {
Queued, Queued,
Downloading, Downloading,
@@ -81,36 +79,46 @@ pub enum DownloadStatus {
/// which provides raw access to the underlying queue. /// which provides raw access to the underlying queue.
/// THIS EDITING IS BLOCKING!!! /// THIS EDITING IS BLOCKING!!!
pub struct DownloadManager { pub struct DownloadManager {
terminator: Mutex<Option<JoinHandle<Result<(), ()>>>>, terminator: Mutex<Option<JoinHandle<()>>>,
download_queue: Queue, download_queue: Queue,
progress: CurrentProgressObject, progress: CurrentProgressObject,
command_sender: Sender<DownloadManagerSignal>, command_sender: Sender<DownloadManagerSignal>,
depot_manager: Arc<DepotManager>,
}
impl Debug for DownloadManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DownloadManager").finish()
}
} }
#[allow(dead_code)] #[allow(dead_code)]
impl DownloadManager { impl DownloadManager {
pub fn new( pub fn new(
terminator: JoinHandle<Result<(), ()>>, terminator: JoinHandle<()>,
download_queue: Queue, download_queue: Queue,
progress: CurrentProgressObject, progress: CurrentProgressObject,
command_sender: Sender<DownloadManagerSignal>, command_sender: Sender<DownloadManagerSignal>,
depot_manager: Arc<DepotManager>,
) -> Self { ) -> Self {
Self { Self {
terminator: Mutex::new(Some(terminator)), terminator: Mutex::new(Some(terminator)),
download_queue, download_queue,
progress, progress,
command_sender, command_sender,
depot_manager
} }
} }
pub fn queue_download( pub async fn queue_download(
&self, &self,
download: DownloadAgent, download: DownloadAgent,
) -> Result<(), SendError<DownloadManagerSignal>> { ) -> Result<(), SendError<DownloadManagerSignal>> {
info!("creating download with meta {:?}", download.metadata()); info!("creating download with meta {:?}", download.metadata());
self.command_sender self.command_sender
.send(DownloadManagerSignal::Queue(download))?; .send(DownloadManagerSignal::Queue(download))
self.command_sender.send(DownloadManagerSignal::Go) .await?;
self.command_sender.send(DownloadManagerSignal::Go).await
} }
pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> { pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> {
self.download_queue.edit() self.download_queue.edit()
@@ -119,68 +127,63 @@ impl DownloadManager {
self.download_queue.read() self.download_queue.read()
} }
pub fn get_current_download_progress(&self) -> Option<f64> { pub fn get_current_download_progress(&self) -> Option<f64> {
let progress_object = (*self.progress.lock().unwrap()).clone()?; let progress_object = (*lock!(self.progress)).clone()?;
Some(progress_object.get_progress()) Some(progress_object.get_progress())
} }
pub fn rearrange_string(&self, meta: &DownloadableMetadata, new_index: usize) { pub async fn rearrange_string(&self, meta: &DownloadableMetadata, new_index: usize) {
let mut queue = self.edit(); let mut queue = self.edit();
let current_index = get_index_from_id(&mut queue, meta).unwrap(); let current_index =
let to_move = queue.remove(current_index).unwrap(); 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); queue.insert(new_index, to_move);
self.command_sender send!(self.command_sender, DownloadManagerSignal::UpdateUIQueue);
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
} }
pub fn cancel(&self, meta: DownloadableMetadata) { pub async fn cancel(&self, meta: DownloadableMetadata) {
self.command_sender send!(self.command_sender, DownloadManagerSignal::Cancel(meta));
.send(DownloadManagerSignal::Cancel(meta))
.unwrap();
} }
pub fn rearrange(&self, current_index: usize, new_index: usize) { pub async fn rearrange(&self, current_index: usize, new_index: usize) {
if current_index == new_index { if current_index == new_index {
return; return;
} }
let needs_pause = current_index == 0 || new_index == 0; let needs_pause = current_index == 0 || new_index == 0;
if needs_pause { if needs_pause {
self.command_sender send!(self.command_sender, DownloadManagerSignal::Stop);
.send(DownloadManagerSignal::Stop)
.unwrap();
} }
debug!("moving download at index {current_index} to index {new_index}"); debug!("moving download at index {current_index} to index {new_index}");
let mut queue = self.edit(); {
let to_move = queue.remove(current_index).unwrap(); let mut queue = self.edit();
queue.insert(new_index, to_move); let to_move = queue.remove(current_index).expect("Failed to get");
drop(queue); queue.insert(new_index, to_move);
}
if needs_pause { if needs_pause {
self.command_sender.send(DownloadManagerSignal::Go).unwrap(); send!(self.command_sender, DownloadManagerSignal::Go);
} }
self.command_sender send!(self.command_sender, DownloadManagerSignal::UpdateUIQueue);
.send(DownloadManagerSignal::UpdateUIQueue) send!(self.command_sender, DownloadManagerSignal::Go);
.unwrap();
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
} }
pub fn pause_downloads(&self) { pub async fn pause_downloads(&self) {
self.command_sender send!(self.command_sender, DownloadManagerSignal::Stop);
.send(DownloadManagerSignal::Stop)
.unwrap();
} }
pub fn resume_downloads(&self) { pub async fn resume_downloads(&self) {
self.command_sender.send(DownloadManagerSignal::Go).unwrap(); send!(self.command_sender, DownloadManagerSignal::Go);
} }
pub fn ensure_terminated(&self) -> Result<Result<(), ()>, Box<dyn Any + Send>> { pub async fn ensure_terminated(&self) -> Result<(), tauri::Error> {
self.command_sender send!(self.command_sender, DownloadManagerSignal::Finish);
.send(DownloadManagerSignal::Finish) let terminator = lock!(self.terminator).take();
.unwrap(); terminator.unwrap().await
let terminator = self.terminator.lock().unwrap().take();
terminator.unwrap().join()
} }
pub fn get_sender(&self) -> Sender<DownloadManagerSignal> { pub fn get_sender(&self) -> Sender<DownloadManagerSignal> {
self.command_sender.clone() self.command_sender.clone()
} }
pub fn clone_depot_manager(&self) -> Arc<DepotManager> {
self.depot_manager.clone()
}
} }
/// Takes in the locked value from .`edit()` and attempts to /// Takes in the locked value from .`edit()` and attempts to

View File

@@ -0,0 +1,33 @@
use std::{fmt::Debug, sync::Arc};
use async_trait::async_trait;
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
*/
#[async_trait]
pub trait Downloadable: Send + Sync + Debug {
async 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);
async fn on_complete(&self, app_handle: &AppHandle);
fn on_cancelled(&self, app_handle: &AppHandle);
}

View File

@@ -0,0 +1,82 @@
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),
InvalidCommand,
}
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:?}")
}
ApplicationDownloadError::InvalidCommand => write!(f, "Invalid command state"),
}
}
}
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,45 @@
#![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 mod depot_manager;
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::{ use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Arc,
atomic::{AtomicBool, Ordering},
}; };
#[derive(PartialEq, Eq, PartialOrd, Ord)] #[derive(PartialEq, Eq, PartialOrd, Ord)]
@@ -22,7 +22,11 @@ impl From<DownloadThreadControlFlag> for bool {
/// false => Stop /// false => Stop
impl From<bool> for DownloadThreadControlFlag { impl From<bool> for DownloadThreadControlFlag {
fn from(value: bool) -> Self { fn from(value: bool) -> Self {
if value { DownloadThreadControlFlag::Go } else { DownloadThreadControlFlag::Stop } if value {
DownloadThreadControlFlag::Go
} else {
DownloadThreadControlFlag::Stop
}
} }
} }

View File

@@ -2,3 +2,4 @@ pub mod download_thread_control_flag;
pub mod progress_object; pub mod progress_object;
pub mod queue; pub mod queue;
pub mod rolling_progress_updates; pub mod rolling_progress_updates;
pub mod semaphore;

View File

@@ -1,20 +1,20 @@
use std::{ use std::{
sync::{ sync::{
atomic::{AtomicUsize, Ordering},
mpsc::Sender,
Arc, Mutex, Arc, Mutex,
atomic::{AtomicUsize, Ordering},
}, },
time::{Duration, Instant}, time::Instant,
}; };
use atomic_instant_full::AtomicInstant; use atomic_instant_full::AtomicInstant;
use throttle_my_fn::throttle; use utils::{lock, send};
use tokio::sync::mpsc::Sender;
use crate::download_manager::download_manager_frontend::DownloadManagerSignal; use crate::download_manager_frontend::DownloadManagerSignal;
use super::rolling_progress_updates::RollingProgressWindow; use super::rolling_progress_updates::RollingProgressWindow;
#[derive(Clone)] #[derive(Clone, Debug)]
pub struct ProgressObject { pub struct ProgressObject {
max: Arc<Mutex<usize>>, max: Arc<Mutex<usize>>,
progress_instances: Arc<Mutex<Vec<Arc<AtomicUsize>>>>, progress_instances: Arc<Mutex<Vec<Arc<AtomicUsize>>>>,
@@ -23,7 +23,7 @@ pub struct ProgressObject {
//last_update: Arc<RwLock<Instant>>, //last_update: Arc<RwLock<Instant>>,
last_update_time: Arc<AtomicInstant>, last_update_time: Arc<AtomicInstant>,
bytes_last_update: Arc<AtomicUsize>, bytes_last_update: Arc<AtomicUsize>,
rolling: RollingProgressWindow<250>, rolling: RollingProgressWindow<1000>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -45,7 +45,7 @@ impl ProgressHandle {
pub fn add(&self, amount: usize) { pub fn add(&self, amount: usize) {
self.progress self.progress
.fetch_add(amount, std::sync::atomic::Ordering::AcqRel); .fetch_add(amount, std::sync::atomic::Ordering::AcqRel);
calculate_update(&self.progress_object); tauri::async_runtime::spawn(calculate_update(self.progress_object.clone()));
} }
pub fn skip(&self, amount: usize) { pub fn skip(&self, amount: usize) {
self.progress self.progress
@@ -74,12 +74,10 @@ impl ProgressObject {
} }
pub fn set_time_now(&self) { pub fn set_time_now(&self) {
*self.start.lock().unwrap() = Instant::now(); *lock!(self.start) = Instant::now();
} }
pub fn sum(&self) -> usize { pub fn sum(&self) -> usize {
self.progress_instances lock!(self.progress_instances)
.lock()
.unwrap()
.iter() .iter()
.map(|instance| instance.load(Ordering::Acquire)) .map(|instance| instance.load(Ordering::Acquire))
.sum() .sum()
@@ -88,39 +86,42 @@ impl ProgressObject {
self.set_time_now(); self.set_time_now();
self.bytes_last_update.store(0, Ordering::Release); self.bytes_last_update.store(0, Ordering::Release);
self.rolling.reset(); self.rolling.reset();
self.progress_instances lock!(self.progress_instances)
.lock()
.unwrap()
.iter() .iter()
.for_each(|x| x.store(0, Ordering::SeqCst)); .for_each(|x| x.store(0, Ordering::SeqCst));
} }
pub fn get_max(&self) -> usize { pub fn get_max(&self) -> usize {
*self.max.lock().unwrap() *lock!(self.max)
} }
pub fn set_max(&self, new_max: usize) { pub fn set_max(&self, new_max: usize) {
*self.max.lock().unwrap() = new_max; *lock!(self.max) = new_max;
} }
pub fn set_size(&self, length: usize) { pub fn set_size(&self, length: usize) {
*self.progress_instances.lock().unwrap() = *lock!(self.progress_instances) =
(0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect(); (0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect();
} }
pub fn get_progress(&self) -> f64 { pub fn get_progress(&self) -> f64 {
self.sum() as f64 / self.get_max() as f64 self.sum() as f64 / self.get_max() as f64
} }
pub fn get(&self, index: usize) -> Arc<AtomicUsize> { pub fn get(&self, index: usize) -> Arc<AtomicUsize> {
self.progress_instances.lock().unwrap()[index].clone() lock!(self.progress_instances)[index].clone()
} }
fn update_window(&self, kilobytes_per_second: usize) { fn update_window(&self, kilobytes_per_second: usize) {
self.rolling.update(kilobytes_per_second); self.rolling.update(kilobytes_per_second);
} }
} }
#[throttle(1, Duration::from_millis(20))] pub async fn calculate_update(progress: Arc<ProgressObject>) {
pub fn calculate_update(progress: &ProgressObject) {
let last_update_time = progress let last_update_time = progress
.last_update_time .last_update_time
.swap(Instant::now(), Ordering::SeqCst); .load(Ordering::SeqCst);
let time_since_last_update = Instant::now().duration_since(last_update_time).as_millis(); let time_since_last_update = Instant::now()
.duration_since(last_update_time)
.as_millis_f64();
if time_since_last_update < 250.0 {
return;
}
progress.last_update_time.swap(Instant::now(), Ordering::SeqCst);
let current_bytes_downloaded = progress.sum(); let current_bytes_downloaded = progress.sum();
let max = progress.get_max(); let max = progress.get_max();
@@ -128,38 +129,32 @@ pub fn calculate_update(progress: &ProgressObject) {
.bytes_last_update .bytes_last_update
.swap(current_bytes_downloaded, Ordering::Acquire); .swap(current_bytes_downloaded, Ordering::Acquire);
let bytes_since_last_update = current_bytes_downloaded - bytes_at_last_update; 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 as usize).max(1); let kilobytes_per_second = bytes_since_last_update / time_since_last_update;
let bytes_remaining = max.saturating_sub(current_bytes_downloaded); // bytes let bytes_remaining = max.saturating_sub(current_bytes_downloaded); // bytes
progress.update_window(kilobytes_per_second); progress.update_window(kilobytes_per_second as usize);
push_update(progress, bytes_remaining); push_update(&progress, bytes_remaining).await;
} }
#[throttle(1, Duration::from_millis(500))] pub async fn push_update(progress: &ProgressObject, bytes_remaining: usize) {
pub fn push_update(progress: &ProgressObject, bytes_remaining: usize) {
let average_speed = progress.rolling.get_average(); let average_speed = progress.rolling.get_average();
let time_remaining = (bytes_remaining / 1000) / average_speed.max(1); let time_remaining = (bytes_remaining / 1000) / average_speed.max(1);
update_ui(progress, average_speed, time_remaining); update_ui(progress, average_speed, time_remaining).await;
update_queue(progress); update_queue(progress).await;
} }
fn update_ui(progress_object: &ProgressObject, kilobytes_per_second: usize, time_remaining: usize) { async fn update_ui(progress_object: &ProgressObject, kilobytes_per_second: usize, time_remaining: usize) {
progress_object send!(
.sender progress_object.sender,
.send(DownloadManagerSignal::UpdateUIStats( DownloadManagerSignal::UpdateUIStats(kilobytes_per_second, time_remaining)
kilobytes_per_second, );
time_remaining,
))
.unwrap();
} }
fn update_queue(progress: &ProgressObject) { async fn update_queue(progress: &ProgressObject) {
progress send!(progress.sender, DownloadManagerSignal::UpdateUIQueue)
.sender
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
} }

View File

@@ -3,9 +3,10 @@ use std::{
sync::{Arc, Mutex, MutexGuard}, sync::{Arc, Mutex, MutexGuard},
}; };
use crate::database::models::data::DownloadableMetadata; use database::DownloadableMetadata;
use utils::lock;
#[derive(Clone)] #[derive(Clone, Debug)]
pub struct Queue { pub struct Queue {
inner: Arc<Mutex<VecDeque<DownloadableMetadata>>>, inner: Arc<Mutex<VecDeque<DownloadableMetadata>>>,
} }
@@ -24,10 +25,10 @@ impl Queue {
} }
} }
pub fn read(&self) -> VecDeque<DownloadableMetadata> { pub fn read(&self) -> VecDeque<DownloadableMetadata> {
self.inner.lock().unwrap().clone() lock!(self.inner).clone()
} }
pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> { pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> {
self.inner.lock().unwrap() lock!(self.inner)
} }
pub fn pop_front(&self) -> Option<DownloadableMetadata> { pub fn pop_front(&self) -> Option<DownloadableMetadata> {
self.edit().pop_front() self.edit().pop_front()

View File

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

View File

@@ -0,0 +1,28 @@
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
pub struct SyncSemaphore {
inner: Arc<AtomicUsize>
}
impl SyncSemaphore {
pub fn new() -> Self {
Self { inner: Arc::new(AtomicUsize::new(0)) }
}
pub fn acquire(&self) -> SyncSemaphorePermit {
self.inner.fetch_add(1, Ordering::Relaxed);
SyncSemaphorePermit(self.inner.clone())
}
pub fn permits(&self) -> usize {
self.inner.fetch_add(0, Ordering::Relaxed)
}
}
pub struct SyncSemaphorePermit(Arc<AtomicUsize>);
impl Drop for SyncSemaphorePermit {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}

View File

@@ -0,0 +1,37 @@
[package]
name = "games"
version = "0.1.0"
edition = "2024"
[dependencies]
aes = "0.8.4"
async-scoped = { version = "0.9.0", features = ["use-tokio"] }
async-trait = "0.1.89"
atomic-instant-full = "0.1.0"
bitcode = "0.6.7"
boxcar = "0.2.14"
crossbeam-channel = "0.5.15"
ctr = "0.9.2"
database = { path = "../database", version = "0.1.0" }
download_manager = { path = "../download_manager", version = "0.1.0" }
droplet-rs = { git = "https://github.com/Drop-OSS/droplet-rs" }
futures-util = "*"
hex = "0.4.3"
log = "0.4.28"
native_model = { git = "https://github.com/Drop-OSS/native_model.git", version = "0.6.4", features = [
"rmp_serde_1_3"
] }
rayon = "1.11.0"
remote = { path = "../remote", version = "0.1.0" }
reqwest = "0.12.23"
rustix = "1.1.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
serde_with = "3.15.0"
sha2 = "0.10.9"
sysinfo = "0.37.2"
tauri = "*"
throttle_my_fn = "0.2.6"
tokio = { version = "*", features = ["rt", "sync"] }
tokio-util = { version = "*", features = ["io"] }
utils = { path = "../utils", version = "0.1.0" }

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,
pub entries: Vec<CollectionObject>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, Encode, Decode)]
#[serde(rename_all = "camelCase")]
pub struct CollectionObject {
pub collection_id: String,
pub game_id: String,
pub game: Game,
}

View File

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

View File

@@ -0,0 +1,531 @@
use async_trait::async_trait;
use database::{
ApplicationTransientStatus, DownloadableMetadata, borrow_db_checked, borrow_db_mut_checked,
};
use download_manager::depot_manager::DepotManager;
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 droplet_rs::manifest::Manifest;
use log::{debug, error, info, warn};
use remote::auth::generate_authorization_header;
use remote::error::RemoteAccessError;
use remote::requests::generate_url;
use remote::utils::DROP_CLIENT_ASYNC;
use std::fmt::Debug;
use std::mem;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tauri::AppHandle;
use tokio::sync::mpsc::Sender;
use utils::{app_emit, lock, send};
use crate::downloads::utils::get_disk_available;
use crate::library::{on_game_complete, push_game_update, set_partially_installed};
use crate::state::GameStatusManager;
use super::download_logic::download_game_chunk;
use super::drop_data::DropData;
static RETRY_COUNT: usize = 3;
pub struct GameDownloadAgent {
pub metadata: DownloadableMetadata,
pub control_flag: DownloadThreadControl,
pub manifest: Mutex<Option<Manifest>>,
pub progress: Arc<ProgressObject>,
depot_manager: Arc<DepotManager>,
sender: Sender<DownloadManagerSignal>,
pub dropdata: DropData,
status: Mutex<DownloadStatus>,
}
impl Debug for GameDownloadAgent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GameDownloadAgent").finish()
}
}
impl GameDownloadAgent {
pub async fn new_from_index(
metadata: DownloadableMetadata,
target_download_dir: usize,
sender: Sender<DownloadManagerSignal>,
depot_manager: Arc<DepotManager>,
) -> Result<Self, ApplicationDownloadError> {
let base_dir = {
let db_lock = borrow_db_checked();
db_lock.applications.install_dirs[target_download_dir].clone()
};
Self::new(metadata, base_dir, sender, depot_manager).await
}
pub async fn new(
metadata: DownloadableMetadata,
base_dir: PathBuf,
sender: Sender<DownloadManagerSignal>,
depot_manager: Arc<DepotManager>,
) -> Result<Self, ApplicationDownloadError> {
// Don't run by default
let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop);
let base_dir_path = Path::new(&base_dir);
info!("base dir {}", base_dir_path.display());
let data_base_dir_path = base_dir_path.join(metadata.id.clone());
info!("data dir path {}", data_base_dir_path.display());
let stored_manifest = DropData::generate(
metadata.id.clone(),
metadata.version.clone(),
metadata.target_platform,
data_base_dir_path.clone(),
);
let result = Self {
metadata,
control_flag,
manifest: Mutex::new(None),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
dropdata: stored_manifest,
status: Mutex::new(DownloadStatus::Queued),
depot_manager,
};
result.ensure_manifest_exists().await?;
let required_space = lock!(result.manifest).as_ref().unwrap().size;
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_id: self.metadata.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.control_flag.set(DownloadThreadControlFlag::Go);
Ok(())
}
// Blocking
pub async 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()
.await
.map_err(ApplicationDownloadError::Communication);
debug!(
"{} took {}ms to download",
self.metadata.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.metadata.id),
("version", &self.metadata.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: Manifest = 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 manifest = lock!(self.manifest);
let manifest = manifest.as_ref().unwrap();
self.progress.set_max(manifest.size.try_into().unwrap());
self.progress.set_size(manifest.chunks.len());
self.progress.reset();
}
async fn run(&self) -> Result<bool, RemoteAccessError> {
self.depot_manager.sync_depots().await?;
self.setup_progress();
let (chunks, key) = {
let manifest = lock!(self.manifest);
let manifest = manifest.as_ref().unwrap();
(manifest.chunks.clone(), manifest.key)
};
let chunk_len = chunks.len();
let mut completed_chunks = {
let completed_chunks = lock!(self.dropdata.contexts);
completed_chunks.clone()
};
let max_download_threads = borrow_db_checked().settings.max_download_threads;
let (sender, recv) = crossbeam_channel::bounded(16);
let unsafe_self: &'static GameDownloadAgent = unsafe { mem::transmute(self) };
let local_completed_chunks = completed_chunks.clone();
let download_join_handle = tauri::async_runtime::spawn_blocking(move || {
let thread_pool = rayon::ThreadPoolBuilder::new()
.num_threads(max_download_threads)
.build()
.unwrap();
thread_pool.scope(move |s| {
for (index, (chunk_id, chunk_data)) in chunks.into_iter().enumerate() {
let local_sender = sender.clone();
let progress = unsafe_self.progress.get(index);
let progress_handle =
ProgressHandle::new(progress, unsafe_self.progress.clone());
let chunk_length = chunk_data.files.iter().map(|v| v.length).sum();
if *local_completed_chunks.get(&chunk_id).unwrap_or(&false) {
progress_handle.skip(chunk_length);
continue;
}
let sender = unsafe_self.sender.clone();
let (depot, permit) = match unsafe_self
.depot_manager
.next_depot(&unsafe_self.metadata.id, &unsafe_self.metadata.version)
{
Ok(v) => v,
Err(err) => {
tauri::async_runtime::spawn(async move {
send!(sender, DownloadManagerSignal::Error(ApplicationDownloadError::Communication(err)));
});
return;
}
};
s.spawn(move |_| {
for i in 0..RETRY_COUNT {
let loop_progress_handle = progress_handle.clone();
let base_path = unsafe_self.dropdata.base_path.clone();
match download_game_chunk(
&unsafe_self.metadata.id,
&unsafe_self.metadata.version,
&chunk_id,
&depot,
&key,
&chunk_data,
base_path,
&unsafe_self.control_flag,
loop_progress_handle,
) {
Ok(true) => {
local_sender.send(chunk_id.clone()).unwrap();
drop(permit); // Take ownership
return;
}
Ok(false) => return,
Err(e) => {
warn!("got error for chunk id {}: {e:?}", chunk_id);
let retry = true; /*matches!(
&e,
ApplicationDownloadError::Communication(_)
| ApplicationDownloadError::Checksum
| ApplicationDownloadError::Lock
| ApplicationDownloadError::IoError(_)
);*/
if i == RETRY_COUNT - 1 || !retry {
warn!("retry logic failed, not re-attempting.");
tauri::async_runtime::spawn(async move {
send!(sender, DownloadManagerSignal::Error(e));
});
return;
}
}
}
}
});
}
drop(sender);
});
});
let mut outputs = Vec::new();
while let Ok(chunk_id) = recv.recv() {
outputs.push(chunk_id);
}
download_join_handle
.await
.expect("failed to complete download");
for completed_chunk in outputs {
completed_chunks.insert(completed_chunk, true);
}
let drop_data_chunks = completed_chunks
.iter()
.map(|v| (v.0.to_string(), *v.1))
.collect::<Vec<(String, bool)>>();
self.dropdata.set_contexts(&drop_data_chunks);
self.dropdata.write();
info!("completed {} chunks", drop_data_chunks.len());
// If there are any contexts left which are false
if completed_chunks.len() != chunk_len {
info!(
"download agent for {} exited without completing ({}/{})",
self.metadata.id.clone(),
completed_chunks.len(),
chunk_len,
);
return Ok(false);
}
Ok(true)
}
#[allow(dead_code)]
fn setup_validate(&self, app_handle: &AppHandle) {
self.setup_progress();
self.control_flag.set(DownloadThreadControlFlag::Go);
let status = ApplicationTransientStatus::Validating {
version_id: self.metadata.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();
}
}
#[async_trait]
impl Downloadable for GameDownloadAgent {
async fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
*lock!(self.status) = DownloadStatus::Downloading;
self.download(app_handle).await
}
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 {
self.metadata.clone()
}
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_id: self.metadata.version.clone(),
};
db_lock
.applications
.transient_statuses
.insert(self.metadata(), status.clone());
push_game_update(app_handle, &self.metadata.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.metadata.id,
None,
GameStatusManager::fetch_state(&self.metadata.id, &handle),
);
}
async 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,
)
.await
{
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) {
self.cancel(app_handle);
}
fn status(&self) -> DownloadStatus {
lock!(self.status).clone()
}
}

View File

@@ -0,0 +1,146 @@
use std::fs::{Permissions, set_permissions};
use std::io::{Read, Seek as _, SeekFrom, Write as _};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use aes::cipher::{KeyIvInit, StreamCipher};
use download_manager::error::ApplicationDownloadError;
use download_manager::util::download_thread_control_flag::{
DownloadThreadControl, DownloadThreadControlFlag,
};
use download_manager::util::progress_object::ProgressHandle;
use droplet_rs::manifest::ChunkData;
use log::{debug, info};
use remote::auth::generate_authorization_header;
use remote::error::{DropServerError, RemoteAccessError};
use remote::utils::DROP_CLIENT_SYNC;
use sha2::Digest;
use tauri::Url;
const READ_BUF_LEN: usize = 1024 * 1024;
type Aes128Ctr64LE = ctr::Ctr64LE<aes::Aes128>;
#[allow(clippy::too_many_arguments)]
pub fn download_game_chunk(
game_id: &str,
version_id: &str,
chunk_id: &str,
depot: &str,
key: &[u8; 16],
chunk_data: &ChunkData,
base_path: PathBuf,
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 = Url::parse(depot)
.map_err(|v| ApplicationDownloadError::DownloadError(v.into()))?
.join(&format!(
"content/{}/{}/{}",
game_id, version_id, chunk_id
))
.map_err(|v| ApplicationDownloadError::DownloadError(v.into()))?;
let response = DROP_CLIENT_SYNC
.get(url)
.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::FetchErrorLegacy(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),
));
}
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
return Ok(false);
}
let timestep = start.elapsed().as_millis();
debug!("took {}ms to start downloading", timestep);
/*let stream = response
.bytes_stream()
.map(|v| v.map_err(|err| std::io::Error::other(err)));
let mut stream_reader = StreamReader::new(stream);*/
let mut stream_reader = response;
let mut hasher = sha2::Sha256::new();
let mut cipher = Aes128Ctr64LE::new(key.into(), &chunk_data.iv.into());
let mut read_buf = vec![0u8; READ_BUF_LEN];
for file in &chunk_data.files {
let path = base_path.join(file.filename.clone());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file_handle = std::fs::OpenOptions::new()
.truncate(false)
.write(true)
.append(false)
.create(true)
.open(&path)?;
file_handle.seek(SeekFrom::Start(file.start.try_into().unwrap()))?;
let mut remaining = file.length;
while remaining > 0 {
let amount = stream_reader.read(&mut read_buf[0..remaining.min(READ_BUF_LEN)])?;
progress.add(amount);
remaining -= amount;
cipher.apply_keystream(&mut read_buf[0..amount]);
hasher.update(&read_buf[0..amount]);
file_handle.write_all(&read_buf[0..amount])?;
}
#[cfg(unix)]
{
drop(file_handle);
let permissions = if file.permissions == 0 {
0o744
} else {
file.permissions
};
let permissions = Permissions::from_mode(permissions);
set_permissions(path, permissions)
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
}
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
return Ok(false);
}
}
let digest = hex::encode(hasher.finalize());
if digest != chunk_data.checksum {
return Err(ApplicationDownloadError::Checksum);
}
Ok(true)
}

View File

@@ -1,17 +1,23 @@
use std::{ use std::{
collections::HashMap, fs::File, io::{self, Read, Write}, path::{Path, PathBuf} collections::HashMap,
fs::File,
io::{self, Read, Write},
path::{Path, PathBuf},
}; };
use database::platform::Platform;
use log::error; use log::error;
use native_model::{Decode, Encode}; use native_model::{Decode, Encode};
use utils::lock;
pub type DropData = v1::DropData; pub type DropData = v1::DropData;
pub static DROP_DATA_PATH: &str = ".dropdata"; pub static DROPDATA_PATH: &str = ".dropdata";
pub mod v1 { pub mod v1 {
use std::{collections::HashMap, path::PathBuf, sync::Mutex}; use std::{collections::HashMap, path::PathBuf, sync::Mutex};
use database::platform::Platform;
use native_model::native_model; use native_model::native_model;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -20,16 +26,18 @@ pub mod v1 {
pub struct DropData { pub struct DropData {
pub game_id: String, pub game_id: String,
pub game_version: String, pub game_version: String,
pub target_platform: Platform,
pub contexts: Mutex<HashMap<String, bool>>, pub contexts: Mutex<HashMap<String, bool>>,
pub base_path: PathBuf, pub base_path: PathBuf,
} }
impl DropData { impl DropData {
pub fn new(game_id: String, game_version: String, base_path: PathBuf) -> Self { pub fn new(game_id: String, game_version: String, target_platform: Platform, base_path: PathBuf) -> Self {
Self { Self {
base_path, base_path,
game_id, game_id,
game_version, game_version,
target_platform,
contexts: Mutex::new(HashMap::new()), contexts: Mutex::new(HashMap::new()),
} }
} }
@@ -37,19 +45,24 @@ pub mod v1 {
} }
impl DropData { impl DropData {
pub fn generate(game_id: String, game_version: String, base_path: PathBuf) -> Self { pub fn generate(game_id: String, game_version: String, target_platform: Platform, base_path: PathBuf) -> Self {
match DropData::read(&base_path) { match DropData::read(&base_path) {
Ok(v) => v, Ok(v) => v,
Err(_) => DropData::new(game_id, game_version, base_path), Err(_) => DropData::new(game_id, game_version, target_platform, base_path),
} }
} }
pub fn read(base_path: &Path) -> Result<Self, io::Error> { pub fn read(base_path: &Path) -> Result<Self, io::Error> {
let mut file = File::open(base_path.join(DROP_DATA_PATH))?; let mut file = File::open(base_path.join(DROPDATA_PATH))?;
let mut s = Vec::new(); let mut s = Vec::new();
file.read_to_end(&mut s)?; file.read_to_end(&mut s)?;
Ok(native_model::rmp_serde_1_3::RmpSerde::decode(s).unwrap()) 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) { pub fn write(&self) {
let manifest_raw = match native_model::rmp_serde_1_3::RmpSerde::encode(&self) { let manifest_raw = match native_model::rmp_serde_1_3::RmpSerde::encode(&self) {
@@ -57,7 +70,7 @@ impl DropData {
Err(_) => return, Err(_) => return,
}; };
let mut file = match File::create(self.base_path.join(DROP_DATA_PATH)) { let mut file = match File::create(self.base_path.join(DROPDATA_PATH)) {
Ok(file) => file, Ok(file) => file,
Err(e) => { Err(e) => {
error!("{e}"); error!("{e}");
@@ -71,20 +84,15 @@ impl DropData {
} }
} }
pub fn set_contexts(&self, completed_contexts: &[(String, bool)]) { pub fn set_contexts(&self, completed_contexts: &[(String, bool)]) {
*self.contexts.lock().unwrap() = completed_contexts.iter().map(|s| (s.0.clone(), s.1)).collect(); *lock!(self.contexts) = completed_contexts
.iter()
.map(|s| (s.0.clone(), s.1))
.collect();
} }
pub fn set_context(&self, context: String, state: bool) { pub fn set_context(&self, context: String, state: bool) {
self.contexts.lock().unwrap().entry(context).insert_entry(state); lock!(self.contexts).entry(context).insert_entry(state);
}
pub fn get_completed_contexts(&self) -> Vec<String> {
self.contexts
.lock()
.unwrap()
.iter()
.filter_map(|x| if *x.1 { Some(x.0.clone()) } else { None })
.collect()
} }
pub fn get_contexts(&self) -> HashMap<String, bool> { pub fn get_contexts(&self) -> HashMap<String, bool> {
self.contexts.lock().unwrap().clone() 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,46 @@
use serde::{Deserialize, Serialize};
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(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

@@ -1,6 +1,6 @@
pub mod commands;
pub mod download_agent; pub mod download_agent;
mod download_logic; mod download_logic;
pub mod drop_data; pub mod drop_data;
pub mod error;
mod manifest; mod manifest;
pub mod validate; pub mod utils;

View File

@@ -1,10 +1,8 @@
use std::path::PathBuf; use std::{io, path::PathBuf, sync::Arc};
use futures_lite::io; use download_manager::error::ApplicationDownloadError;
use sysinfo::{Disk, DiskRefreshKind, Disks}; use sysinfo::{Disk, DiskRefreshKind, Disks};
use crate::error::application_download_error::ApplicationDownloadError;
pub fn get_disk_available(mount_point: PathBuf) -> Result<u64, ApplicationDownloadError> { pub fn get_disk_available(mount_point: PathBuf) -> Result<u64, ApplicationDownloadError> {
let disks = Disks::new_with_refreshed_list_specifics(DiskRefreshKind::nothing().with_storage()); let disks = Disks::new_with_refreshed_list_specifics(DiskRefreshKind::nothing().with_storage());
@@ -21,7 +19,7 @@ pub fn get_disk_available(mount_point: PathBuf) -> Result<u64, ApplicationDownlo
return Ok(disk.available_space()); return Ok(disk.available_space());
} }
} }
Err(ApplicationDownloadError::IoError(io::Error::other( Err(ApplicationDownloadError::IoError(Arc::new(
"could not find disk of path", io::Error::other("could not find disk of path"),
).kind())) )))
} }

View File

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

View File

@@ -0,0 +1,302 @@
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_ASYNC
};
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 {
pub game: Game,
pub status: GameStatusWithTransient,
pub 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 {
pub id: String,
#[serde(rename = "type")]
pub game_type: String,
pub m_name: String,
pub m_short_description: String,
pub m_description: String,
// mDevelopers
// mPublishers
pub m_icon_object_id: String,
pub m_banner_object_id: String,
pub m_cover_object_id: String,
pub m_image_library_object_ids: Vec<String>,
pub 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.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 async fn on_game_complete(
meta: &DownloadableMetadata,
install_dir: String,
app_handle: &AppHandle,
) -> Result<(), RemoteAccessError> {
// Fetch game version information from remote
let response = generate_url(
&["/api/v1/client/game", &meta.id, "version", &meta.version],
&[],
)?;
let response = DROP_CLIENT_ASYNC
.get(response)
.header("Authorization", generate_authorization_header())
.send()
.await?;
let game_version: GameVersion = response.json().await?;
let mut handle = borrow_db_mut_checked();
handle
.applications
.game_versions
.insert(meta.version.clone(), game_version.clone());
handle
.applications
.installed_game_version
.insert(meta.id.clone(), meta.clone());
drop(handle);
let setup_configuration = game_version
.setups
.iter()
.find(|v| v.platform == meta.target_platform);
let status = if setup_configuration.is_none() {
GameDownloadStatus::Installed {
version_name: meta.version.clone(),
install_dir,
}
} else {
GameDownloadStatus::SetupRequired {
version_name: meta.version.clone(),
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),
}
);
app_emit!(app_handle, "update_library", ());
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
}
}

View File

@@ -1,16 +1,11 @@
use std::fs; use std::fs;
use database::{DownloadType, DownloadableMetadata, borrow_db_mut_checked};
use log::warn; use log::warn;
use crate::{ use crate::{
database::{ downloads::drop_data::{DROPDATA_PATH, DropData},
db::borrow_db_mut_checked, library::set_partially_installed_db,
models::data::v1::{DownloadType, DownloadableMetadata},
},
games::{
downloads::drop_data::{v1::DropData, DROP_DATA_PATH},
library::set_partially_installed_db,
},
}; };
pub fn scan_install_dirs() { pub fn scan_install_dirs() {
@@ -20,15 +15,15 @@ pub fn scan_install_dirs() {
continue; continue;
}; };
for game in files.into_iter().flatten() { for game in files.into_iter().flatten() {
let drop_data_file = game.path().join(DROP_DATA_PATH); let drop_data_file = game.path().join(DROPDATA_PATH);
if !drop_data_file.exists() { if !drop_data_file.exists() {
continue; continue;
} }
let game_id = game.file_name().into_string().unwrap(); let game_id = game.file_name().display().to_string();
let Ok(drop_data) = DropData::read(&game.path()) else { let Ok(drop_data) = DropData::read(&game.path()) else {
warn!( warn!(
".dropdata exists for {}, but couldn't read it. is it corrupted?", ".dropdata exists for {}, but couldn't read it. is it corrupted?",
game.file_name().into_string().unwrap() game.file_name().display()
); );
continue; continue;
}; };
@@ -38,7 +33,8 @@ pub fn scan_install_dirs() {
let metadata = DownloadableMetadata::new( let metadata = DownloadableMetadata::new(
drop_data.game_id, drop_data.game_id,
Some(drop_data.game_version), drop_data.game_version,
drop_data.target_platform,
DownloadType::Game, DownloadType::Game,
); );
set_partially_installed_db( set_partially_installed_db(

View File

@@ -1,4 +1,6 @@
use crate::database::models::data::{ApplicationTransientStatus, Database, GameDownloadStatus}; use database::models::data::{
ApplicationTransientStatus, Database, DownloadType, GameDownloadStatus,
};
pub type GameStatusWithTransient = ( pub type GameStatusWithTransient = (
Option<GameDownloadStatus>, Option<GameDownloadStatus>,
@@ -8,10 +10,14 @@ pub struct GameStatusManager {}
impl GameStatusManager { impl GameStatusManager {
pub fn fetch_state(game_id: &String, database: &Database) -> GameStatusWithTransient { pub fn fetch_state(game_id: &String, database: &Database) -> GameStatusWithTransient {
let online_state = match database.applications.installed_game_version.get(game_id) { let online_state = database
Some(meta) => database.applications.transient_statuses.get(meta).cloned(), .applications
None => None, .transient_statuses
}; .iter()
.find(|v| v.0.id == *game_id && v.0.download_type == DownloadType::Game)
.map(|v| v.1.clone())
.clone();
let offline_state = database.applications.game_statuses.get(game_id).cloned(); let offline_state = database.applications.game_statuses.get(game_id).cloned();
if online_state.is_some() { if online_state.is_some() {

View File

@@ -0,0 +1,20 @@
[package]
name = "process"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4.42"
client = { path = "../client", version = "0.1.0" }
database = { path = "../database", version = "0.1.0" }
dynfmt = { version = "0.1.5", features = ["curly"] }
games = { path = "../games", version = "0.1.0" }
log = "0.4.28"
page_size = "0.6.0"
serde = "1.0.228"
serde_with = "3.15.0"
shared_child = "1.1.1"
shell-words = "1.1.1"
tauri = "*"
tauri-plugin-opener = "*"
utils = { path = "../utils", version = "0.1.0" }

View File

@@ -0,0 +1,50 @@
use std::{fmt::Display, io::{self, Error}, sync::Arc};
use serde_with::SerializeDisplay;
#[derive(SerializeDisplay, Clone)]
pub enum ProcessError {
NotInstalled,
AlreadyRunning,
InvalidID,
InvalidVersion,
RequiredDependency(String, String),
IOError(Arc<Error>),
FormatError(String), // String errors supremacy
InvalidPlatform,
OpenerError(Arc<tauri_plugin_opener::Error>),
InvalidArguments(String),
FailedLaunch(String),
}
impl Display for ProcessError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
ProcessError::NotInstalled => "Game not installed",
ProcessError::AlreadyRunning => "Game already running",
ProcessError::InvalidID => "Invalid game ID",
ProcessError::InvalidVersion => "Invalid game version",
ProcessError::IOError(error) => &error.to_string(),
ProcessError::InvalidPlatform => "This game cannot be played on the current platform",
ProcessError::FormatError(error) => &format!("Could not format template: {error:?}"),
ProcessError::OpenerError(error) => &format!("Could not open directory: {error:?}"),
ProcessError::InvalidArguments(arguments) => {
&format!("Invalid arguments in command {arguments}")
}
ProcessError::FailedLaunch(game_id) => {
&format!("Drop detected that the game {game_id} may have failed to launch properly")
}
ProcessError::RequiredDependency(game_id, version_id) => &format!(
"Missing a required dependency to launch this game: {} {}",
game_id, version_id
),
};
write!(f, "{s}")
}
}
impl From<io::Error> for ProcessError {
fn from(value: io::Error) -> Self {
ProcessError::IOError(Arc::new(value))
}
}

View File

@@ -8,7 +8,13 @@ pub struct DropFormatArgs {
} }
impl DropFormatArgs { impl DropFormatArgs {
pub fn new(launch_string: String, working_dir: &String, executable_name: &String, absolute_executable_name: String) -> Self { pub fn new(
launch_string: String,
working_dir: &String,
executable_name: &String,
absolute_executable_name: String,
original: Option<String>,
) -> Self {
let mut positional = Vec::new(); let mut positional = Vec::new();
let mut map: HashMap<&'static str, String> = HashMap::new(); let mut map: HashMap<&'static str, String> = HashMap::new();
@@ -17,6 +23,10 @@ impl DropFormatArgs {
map.insert("dir", working_dir.to_string()); map.insert("dir", working_dir.to_string());
map.insert("exe", executable_name.to_string()); map.insert("exe", executable_name.to_string());
map.insert("abs_exe", absolute_executable_name); map.insert("abs_exe", absolute_executable_name);
if let Some(original) = original {
map.insert("executor", original);
}
Self { positional, map } Self { positional, map }
} }

View File

@@ -0,0 +1,43 @@
#![feature(nonpoison_mutex)]
#![feature(sync_nonpoison)]
#![feature(extend_one)]
use std::{
ops::Deref,
sync::{OnceLock, nonpoison::Mutex},
};
use tauri::AppHandle;
use crate::process_manager::ProcessManager;
pub static PROCESS_MANAGER: ProcessManagerWrapper = ProcessManagerWrapper::new();
pub mod error;
pub mod format;
pub mod process_handlers;
pub mod process_manager;
mod parser;
pub struct ProcessManagerWrapper(OnceLock<Mutex<ProcessManager<'static>>>);
impl ProcessManagerWrapper {
const fn new() -> Self {
ProcessManagerWrapper(OnceLock::new())
}
pub fn init(app_handle: AppHandle) {
PROCESS_MANAGER
.0
.set(Mutex::new(ProcessManager::new(app_handle)))
.unwrap_or_else(|_| panic!("Failed to initialise Process Manager")); // Using panic! here because we can't implement Debug
}
}
impl Deref for ProcessManagerWrapper {
type Target = Mutex<ProcessManager<'static>>;
fn deref(&self) -> &Self::Target {
match self.0.get() {
Some(process_manager) => process_manager,
None => unreachable!("Download manager should always be initialised"),
}
}
}

View File

@@ -0,0 +1,50 @@
use std::path::PathBuf;
use crate::error::ProcessError;
#[derive(Debug)]
pub struct ParsedCommand {
pub env: Vec<String>,
pub command: String,
pub args: Vec<String>,
}
impl ParsedCommand {
pub fn parse(raw: String) -> Result<Self, ProcessError> {
let parts =
shell_words::split(&raw).map_err(|e| ProcessError::InvalidArguments(e.to_string()))?;
let args =
parts
.iter()
.position(|v| !v.contains("="))
.ok_or(ProcessError::InvalidArguments(
"Cannot parse launch".to_owned(),
))?;
let env = &parts[0..args];
let command = parts[args].clone();
let args = &parts[(args + 1)..];
Ok(Self {
args: args.to_vec(),
command,
env: env.to_vec(),
})
}
pub fn make_absolute(&mut self, base: PathBuf) {
self.command = base
.join(self.command.clone())
.to_string_lossy()
.to_string();
}
pub fn reconstruct(self) -> String {
let mut v = vec![];
v.extend(self.env);
v.extend_one(self.command);
v.extend(self.args);
v.join(" ")
}
}
pub struct LaunchParameters(pub String, pub PathBuf);

View File

@@ -0,0 +1,131 @@
use std::fs::create_dir_all;
use client::compat::{COMPAT_INFO, UMU_LAUNCHER_EXECUTABLE};
use database::{
Database, DownloadableMetadata, GameVersion, db::DATA_ROOT_DIR, platform::Platform,
};
use crate::{error::ProcessError, process_manager::ProcessHandler};
pub struct NativeGameLauncher;
impl ProcessHandler for NativeGameLauncher {
fn create_launch_process(
&self,
_meta: &DownloadableMetadata,
launch_command: String,
_game_version: &GameVersion,
_current_dir: &str,
) -> Result<String, ProcessError> {
Ok(format!("\"{}\"", launch_command))
}
fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool {
true
}
}
pub struct UMULauncher;
impl ProcessHandler for UMULauncher {
fn create_launch_process(
&self,
meta: &DownloadableMetadata,
launch_command: String,
game_version: &GameVersion,
_current_dir: &str,
) -> Result<String, ProcessError> {
let launch_config = game_version
.launches
.iter()
.find(|v| v.platform == meta.target_platform)
.ok_or(ProcessError::NotInstalled)?;
let game_id = match &launch_config.umu_id_override {
Some(game_override) => {
if game_override.is_empty() {
game_version.version_id.clone()
} else {
game_override.clone()
}
}
None => game_version.version_id.clone(),
};
let pfx_dir = DATA_ROOT_DIR.join("pfx");
let pfx_dir = pfx_dir.join(meta.id.clone());
create_dir_all(&pfx_dir)?;
Ok(format!(
"GAMEID={game_id} WINEPREFIX={} {} {umu:?} {launch}",
pfx_dir.to_string_lossy(),
match meta.target_platform {
Platform::Linux => "UMU_NO_PROTON=1",
_ => "",
},
umu = UMU_LAUNCHER_EXECUTABLE
.as_ref()
.expect("Failed to get UMU_LAUNCHER_EXECUTABLE as ref"),
launch = launch_command,
))
}
fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool {
let Some(compat_info) = &*COMPAT_INFO else {
return false;
};
compat_info.umu_installed
}
}
pub struct AsahiMuvmLauncher;
impl ProcessHandler for AsahiMuvmLauncher {
fn create_launch_process(
&self,
meta: &DownloadableMetadata,
launch_command: String,
game_version: &GameVersion,
current_dir: &str,
) -> Result<String, ProcessError> {
let umu_launcher = UMULauncher {};
let umu_string = umu_launcher.create_launch_process(
meta,
launch_command,
game_version,
current_dir,
)?;
let mut args_cmd = umu_string
.split("umu-run")
.collect::<Vec<&str>>()
.into_iter();
let args = args_cmd
.next()
.ok_or(ProcessError::InvalidArguments(umu_string.clone()))?
.trim();
let cmd = format!(
"umu-run{}",
args_cmd
.next()
.ok_or(ProcessError::InvalidArguments(umu_string.clone()))?
);
Ok(format!("{args} muvm -- {cmd}"))
}
#[allow(unreachable_code)]
#[allow(unused_variables)]
fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool {
#[cfg(not(target_os = "linux"))]
return false;
#[cfg(not(target_arch = "aarch64"))]
return false;
let page_size = page_size::get();
if page_size != 16384 {
return false;
}
let Some(compat_info) = &*COMPAT_INFO else {
return false;
};
compat_info.umu_installed
}
}

View File

@@ -0,0 +1,524 @@
use std::{
collections::HashMap,
fs::{OpenOptions, create_dir_all},
io,
path::PathBuf,
process::{Command, ExitStatus},
sync::Arc,
thread::spawn,
time::{Duration, SystemTime},
};
use database::{
ApplicationTransientStatus, Database, DownloadableMetadata, GameDownloadStatus, GameVersion,
borrow_db_checked, borrow_db_mut_checked, db::DATA_ROOT_DIR, platform::Platform,
};
use dynfmt::Format;
use dynfmt::SimpleCurlyFormat;
use games::{library::push_game_update, state::GameStatusManager};
use log::{debug, info, warn};
use serde::Serialize;
use shared_child::SharedChild;
use tauri::{AppHandle, Emitter as _};
use crate::{
PROCESS_MANAGER,
error::ProcessError,
format::DropFormatArgs,
parser::{LaunchParameters, ParsedCommand},
process_handlers::{AsahiMuvmLauncher, NativeGameLauncher, UMULauncher},
};
pub struct RunningProcess {
handle: Arc<SharedChild>,
start: SystemTime,
manually_killed: bool,
}
pub struct ProcessManager<'a> {
current_platform: Platform,
log_output_dir: PathBuf,
processes: HashMap<String, RunningProcess>,
game_launchers: Vec<(
(Platform, Platform),
&'a (dyn ProcessHandler + Sync + Send + 'static),
)>,
app_handle: AppHandle,
}
#[derive(Serialize)]
pub struct LaunchOption {
name: String,
}
impl ProcessManager<'_> {
pub fn new(app_handle: AppHandle) -> Self {
let log_output_dir = DATA_ROOT_DIR.join("logs");
ProcessManager {
#[cfg(target_os = "windows")]
current_platform: Platform::Windows,
#[cfg(target_os = "macos")]
current_platform: Platform::macOS,
#[cfg(target_os = "linux")]
current_platform: Platform::Linux,
processes: HashMap::new(),
log_output_dir,
game_launchers: vec![
// Current platform to target platform
(
(Platform::Windows, Platform::Windows),
&NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
(
(Platform::Linux, Platform::Linux),
&UMULauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
(
(Platform::macOS, Platform::macOS),
&NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
(
(Platform::Linux, Platform::Windows),
&AsahiMuvmLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
(
(Platform::Linux, Platform::Windows),
&UMULauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
],
app_handle,
}
}
pub fn kill_game(&mut self, game_id: String) -> Result<(), io::Error> {
match self.processes.get_mut(&game_id) {
Some(process) => {
process.manually_killed = true;
process.handle.kill()?;
let exit_status = process.handle.wait()?;
info!("exit status: {:?}", exit_status);
Ok(())
}
None => Err(io::Error::new(
io::ErrorKind::NotFound,
"Game ID not running",
)),
}
}
pub fn get_log_dir(&self, game_id: String) -> PathBuf {
self.log_output_dir.join(game_id)
}
fn on_process_finish(
&mut self,
game_id: String,
result: Result<ExitStatus, std::io::Error>,
) -> Result<(), ProcessError> {
if !self.processes.contains_key(&game_id) {
warn!(
"process on_finish was called, but game_id is no longer valid. finished with result: {result:?}"
);
return Ok(());
}
debug!("process for {:?} exited with {:?}", &game_id, result);
let process = match self.processes.remove(&game_id) {
Some(process) => process,
None => {
info!("Attempted to stop process {game_id} which didn't exist");
return Ok(());
}
};
let mut db_handle = borrow_db_mut_checked();
let meta = db_handle
.applications
.installed_game_version
.get(&game_id)
.cloned()
.unwrap_or_else(|| panic!("Could not get installed version of {}", &game_id));
db_handle.applications.transient_statuses.remove(&meta);
let current_state = db_handle.applications.game_statuses.get(&game_id).cloned();
if let Some(GameDownloadStatus::SetupRequired {
version_name,
install_dir,
}) = current_state
&& let Ok(exit_code) = result
&& exit_code.success()
{
db_handle.applications.game_statuses.insert(
game_id.clone(),
GameDownloadStatus::Installed {
version_name: version_name.to_string(),
install_dir: install_dir.to_string(),
},
);
}
let elapsed = process.start.elapsed().unwrap_or(Duration::ZERO);
// If we started and ended really quickly, something might've gone wrong
// Or if the status isn't 0
// Or if it's an error
if !process.manually_killed
&& (elapsed.as_secs() <= 2 || result.map_or(true, |r| !r.success()))
{
warn!("drop detected that the game {game_id} may have failed to launch properly");
let _ = self.app_handle.emit("launch_external_error", &game_id);
}
let version_data = match db_handle.applications.game_versions.get(&meta.version) {
// This unwrap here should be resolved by just making the hashmap accept an option rather than just a String
Some(res) => res,
None => todo!(),
};
let status = GameStatusManager::fetch_state(&game_id, &db_handle);
push_game_update(
&self.app_handle,
&game_id,
Some(version_data.clone()),
status,
);
Ok(())
}
fn fetch_process_handler(
&self,
db_lock: &Database,
target_platform: &Platform,
) -> Result<&(dyn ProcessHandler + Send + Sync), ProcessError> {
Ok(self
.game_launchers
.iter()
.find(|e| {
let (e_current, e_target) = e.0;
e_current == self.current_platform
&& e_target == *target_platform
&& e.1.valid_for_platform(db_lock, target_platform)
})
.ok_or(ProcessError::InvalidPlatform)?
.1)
}
pub fn valid_platform(&self, platform: &Platform) -> bool {
let db_lock = borrow_db_checked();
let process_handler = self.fetch_process_handler(&db_lock, platform);
process_handler.is_ok()
}
pub fn get_launch_options(game_id: String) -> Result<Vec<LaunchOption>, ProcessError> {
let db_lock = borrow_db_checked();
let meta = db_lock
.applications
.installed_game_version
.get(&game_id)
.cloned()
.ok_or(ProcessError::NotInstalled)?;
let game_version = db_lock
.applications
.game_versions
.get(&meta.version)
.ok_or(ProcessError::InvalidVersion)?;
let launch_options = game_version
.launches
.iter()
.filter(|v| v.platform == meta.target_platform)
.map(|v| LaunchOption {
name: v.name.clone(),
})
.collect::<Vec<LaunchOption>>();
Ok(launch_options)
}
pub fn launch_process(
&mut self,
game_id: String,
launch_process_index: usize,
) -> Result<(), ProcessError> {
if self.processes.contains_key(&game_id) {
return Err(ProcessError::AlreadyRunning);
}
let mut db_lock = borrow_db_mut_checked();
let meta = db_lock
.applications
.installed_game_version
.get(&game_id)
.cloned()
.ok_or(ProcessError::NotInstalled)?;
let game_status = db_lock
.applications
.game_statuses
.get(&game_id)
.ok_or(ProcessError::NotInstalled)?;
let (version_name, install_dir) = match game_status {
GameDownloadStatus::Installed {
version_name,
install_dir,
} => (version_name, install_dir),
GameDownloadStatus::SetupRequired {
version_name,
install_dir,
} => (version_name, install_dir),
_ => return Err(ProcessError::NotInstalled),
};
debug!(
"Launching process {:?} with version {:?}",
&game_id,
db_lock.applications.game_versions.get(&game_id)
);
let game_version = db_lock
.applications
.game_versions
.get(version_name)
.ok_or(ProcessError::InvalidVersion)?;
// TODO: refactor this path with open_process_logs
let game_log_folder = &self.get_log_dir(game_id);
create_dir_all(game_log_folder)?;
let current_time = chrono::offset::Local::now();
let log_file = OpenOptions::new()
.write(true)
.truncate(true)
.read(true)
.create(true)
.open(game_log_folder.join(format!(
"{}-{}.log",
&meta.version,
current_time.timestamp()
)))?;
let error_file = OpenOptions::new()
.write(true)
.truncate(true)
.read(true)
.create(true)
.open(game_log_folder.join(format!(
"{}-{}-error.log",
&meta.version,
current_time.timestamp()
)))?;
let target_platform = meta.target_platform;
let process_handler = self.fetch_process_handler(&db_lock, &target_platform)?;
let (target_command, executor) = match game_status {
GameDownloadStatus::Installed {
version_name: _,
install_dir: _,
} => {
let (_, launch_config) = game_version
.launches
.iter()
.filter(|v| v.platform == target_platform)
.enumerate()
.find(|(i, _)| *i == launch_process_index)
.ok_or(ProcessError::NotInstalled)?;
(
launch_config.command.clone(),
launch_config.executor.as_ref(),
)
}
GameDownloadStatus::SetupRequired {
version_name: _,
install_dir: _,
} => {
let setup_config = game_version
.setups
.iter()
.find(|v| v.platform == target_platform)
.ok_or(ProcessError::NotInstalled)?;
(setup_config.command.clone(), None)
}
_ => unreachable!("Game registered as 'Partially Installed'"),
};
let target_command = ParsedCommand::parse(target_command)?;
let launch_parameters = if let Some(executor) = executor {
let err = ProcessError::RequiredDependency(
executor.game_id.clone(),
executor.version_id.clone(),
);
let executor_metadata = db_lock
.applications
.installed_game_version
.get(&executor.game_id)
.ok_or(err.clone())?;
let executor_game_status = db_lock
.applications
.game_statuses
.get(&executor.game_id)
.ok_or(err.clone())?;
let executor_install_dir = match executor_game_status {
GameDownloadStatus::Installed {
version_name: _,
install_dir,
} => Ok(install_dir),
GameDownloadStatus::SetupRequired {
version_name: _,
install_dir: _,
} => todo!(),
_ => Err(err.clone()),
}?;
let executor_game_version = db_lock
.applications
.game_versions
.get(&executor.version_id)
.ok_or(err.clone())?;
let executor_launch_config = executor_game_version
.launches
.iter()
.find(|v| v.launch_id == executor.launch_id)
.ok_or(err)?;
println!("{}", executor_launch_config.command);
let mut exe_command = ParsedCommand::parse(executor_launch_config.command.clone())?;
println!("{:?}", exe_command);
exe_command.env.extend(target_command.env);
exe_command.make_absolute(executor_install_dir.into());
exe_command.args.iter_mut().for_each(|v| {
*v = v.replace("{executor}", &target_command.command);
});
let executor_launch_string = process_handler.create_launch_process(
executor_metadata,
exe_command.reconstruct(),
executor_game_version,
install_dir,
)?;
LaunchParameters(executor_launch_string, install_dir.into())
} else {
let target_launch_string = process_handler.create_launch_process(
&meta,
target_command.reconstruct(),
game_version,
install_dir,
)?;
let mut parsed_launch = ParsedCommand::parse(target_launch_string.clone())?;
let executable_name = parsed_launch.command.clone();
parsed_launch.make_absolute(install_dir.into());
let format_args = DropFormatArgs::new(
target_launch_string,
install_dir,
&executable_name,
parsed_launch.command,
None,
);
let target_launch_string = SimpleCurlyFormat
.format(&game_version.launch_template, &format_args)
.map_err(|e| ProcessError::FormatError(e.to_string()))?
.to_string();
let target_launch_string = SimpleCurlyFormat
.format(&target_launch_string, format_args)
.map_err(|e| ProcessError::FormatError(e.to_string()))?
.to_string();
LaunchParameters(target_launch_string, install_dir.into())
};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
let mut command = Command::new("cmd");
#[cfg(target_os = "windows")]
command.raw_arg(format!("/C \"{}\"", &launch_string));
info!(
"launching (in {}): {}",
launch_parameters.1.to_string_lossy(),
launch_parameters.0
);
#[cfg(unix)]
let mut command: Command = Command::new("sh");
#[cfg(unix)]
command.args(vec!["-c", &launch_parameters.0]);
command
.stderr(error_file)
.stdout(log_file)
.env_remove("RUST_LOG")
.current_dir(launch_parameters.1);
let child = command.spawn()?;
let launch_process_handle =
Arc::new(SharedChild::new(child)?);
db_lock
.applications
.transient_statuses
.insert(meta.clone(), ApplicationTransientStatus::Running {});
push_game_update(
&self.app_handle,
&meta.id,
None,
(None, Some(ApplicationTransientStatus::Running {})),
);
let wait_thread_handle = launch_process_handle.clone();
let wait_thread_game_id = meta.clone();
self.processes.insert(
meta.id,
RunningProcess {
handle: wait_thread_handle,
start: SystemTime::now(),
manually_killed: false,
},
);
spawn(move || {
let result: Result<ExitStatus, std::io::Error> = launch_process_handle.wait();
PROCESS_MANAGER
.lock()
.on_process_finish(wait_thread_game_id.id, result)
});
Ok(())
}
}
pub trait ProcessHandler: Send + 'static {
fn create_launch_process(
&self,
meta: &DownloadableMetadata,
launch_command: String,
game_version: &GameVersion,
current_dir: &str,
) -> Result<String, ProcessError>;
fn valid_for_platform(&self, db: &Database, target: &Platform) -> bool;
}

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