forked from Drop-OSS/archived-drop-app
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
285e5e4c51 | ||
|
|
c74ef58c25 | ||
|
|
5ad005161f | ||
|
|
cc086a9f16 | ||
|
|
f41df6531c | ||
|
|
28d7a741c1 | ||
|
|
490a689497 | ||
|
|
84f4210479 | ||
|
|
a2d1a989e0 | ||
|
|
83d2301056 | ||
|
|
87bbe1da49 | ||
|
|
cc57ca7076 | ||
|
|
70cecdad19 | ||
|
|
3f18d15d39 | ||
|
|
97b5cd5e78 | ||
|
|
7e70a17a43 | ||
|
|
8d61a68b8a | ||
|
|
44a1be6991 | ||
|
|
4f5fccf0c1 | ||
|
|
5eef2bf60f | ||
|
|
ec6294b8e7 | ||
|
|
17c375bcab | ||
|
|
cb55ac2bf5 | ||
|
|
e11db851a5 | ||
|
|
16365713cf |
70
.github/workflows/release.yml
vendored
70
.github/workflows/release.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 'publish'
|
||||
name: "publish"
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
@@ -18,16 +18,16 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest' # for Arm based macs (M1 and above).
|
||||
args: '--target aarch64-apple-darwin'
|
||||
- platform: 'macos-latest' # for Intel based macs.
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: 'ubuntu-22.04' # for Tauri v1 you could replace this with ubuntu-20.04.
|
||||
args: ''
|
||||
- platform: 'ubuntu-22.04-arm'
|
||||
args: '--target aarch64-unknown-linux-gnu'
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
- platform: "macos-14" # for Arm based macs (M1 and above).
|
||||
args: "--target aarch64-apple-darwin"
|
||||
- platform: "macos-14" # for Intel based macs.
|
||||
args: "--target x86_64-apple-darwin"
|
||||
- platform: "ubuntu-22.04" # for Tauri v1 you could replace this with ubuntu-20.04.
|
||||
args: ""
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: "--target aarch64-unknown-linux-gnu"
|
||||
- platform: "windows-latest"
|
||||
args: ""
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
@@ -36,27 +36,39 @@ jobs:
|
||||
submodules: true
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: pnpm
|
||||
|
||||
|
||||
- name: install Rust nightly
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
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)
|
||||
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm' # This must match the platform value defined above.
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
# webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
|
||||
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-latest'
|
||||
if: matrix.platform == 'macos-14'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -67,18 +79,30 @@ jobs:
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security set-keychain-settings -t 3600 -u build.keychain
|
||||
|
||||
curl https://droposs.org/drop.crt --output drop.pem
|
||||
|
||||
|
||||
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 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
|
||||
|
||||
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 find-identity -v -p codesigning build.keychain
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-latest'
|
||||
if: matrix.platform == 'macos-14'
|
||||
run: |
|
||||
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Drop OSS")
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
@@ -86,7 +110,7 @@ jobs:
|
||||
echo "Certificate imported. Using identity: $CERT_ID"
|
||||
|
||||
- name: install frontend dependencies
|
||||
run: yarn install # change this to npm, pnpm or bun depending on which one you use.
|
||||
run: pnpm install # change this to npm, pnpm or bun depending on which one you use.
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -97,8 +121,8 @@ jobs:
|
||||
NO_STRIP: true
|
||||
with:
|
||||
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
|
||||
releaseName: 'Auto-release v__VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install. This release was created automatically.'
|
||||
releaseName: "Auto-release v__VERSION__"
|
||||
releaseBody: "See the assets to download this version and install. This release was created automatically."
|
||||
releaseDraft: false
|
||||
prerelease: true
|
||||
args: ${{ matrix.args }}
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -29,4 +29,6 @@ src-tauri/flamegraph.svg
|
||||
src-tauri/perf*
|
||||
|
||||
/*.AppImage
|
||||
/squashfs-root
|
||||
/squashfs-root
|
||||
|
||||
/target/
|
||||
|
||||
2
.gitlab-ci-local/.gitignore
vendored
2
.gitlab-ci-local/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
24
README.md
24
README.md
@@ -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
|
||||
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)
|
||||
## Internals
|
||||
|
||||
## Current features
|
||||
Currently supported are the following features:
|
||||
- Signin (with custom server)
|
||||
- Database registering & recovery
|
||||
- Dynamic library fetching from server
|
||||
- Installing & uninstalling games
|
||||
- Download progress monitoring
|
||||
- Launching / playing games
|
||||
It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI.
|
||||
|
||||
## Development
|
||||
Before setting up a development environemnt, be sure that you have a server set up. The instructions for this can be found on the [Drop Docs](https://docs.droposs.org/docs/guides/quickstart).
|
||||
|
||||
Install dependencies with `yarn`
|
||||
Then, install dependencies with `yarn`. This'll install the custom builder's dependencies. Then, check everything works properly with `yarn tauri build`.
|
||||
|
||||
Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use shell script `./nvidia-prop-dev.sh`
|
||||
Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use shell script `./nvidia-prop-dev.sh`
|
||||
|
||||
To manually specify the logging level, add the environment variable `RUST_LOG=[debug, info, warn, error]` to `yarn tauri dev`:
|
||||
|
||||
e.g. `RUST_LOG=debug yarn tauri dev`
|
||||
|
||||
## Contributing
|
||||
Check the original [Drop repo](https://github.com/Drop-OSS/drop/blob/main/CONTRIBUTING.md) for contributing guidelines.
|
||||
Check out the contributing guide on our Developer Docs: [Drop Developer Docs - Contributing](https://developer.droposs.org/contributing).
|
||||
|
||||
11
build.mjs
11
build.mjs
@@ -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 expectedPath = `./${view}/package.json`;
|
||||
return fs.existsSync(expectedPath);
|
||||
@@ -33,10 +40,10 @@ for (const view of views) {
|
||||
process.chdir(`./${view}`);
|
||||
|
||||
loggerChild.info(`Install deps for "${view}"`);
|
||||
await spawn("yarn");
|
||||
await spawn("pnpm install");
|
||||
|
||||
loggerChild.info(`Building "${view}"`);
|
||||
await spawn("yarn build", {
|
||||
await spawn("pnpm run build", {
|
||||
env: { ...process.env, NUXT_APP_BASE_URL: `/${view}/` },
|
||||
});
|
||||
|
||||
|
||||
72
libs/appletrust/add-certificate.swift
Normal file
72
libs/appletrust/add-certificate.swift
Normal 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)
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<LoadingIndicator />
|
||||
<NuxtLoadingIndicator color="#2563eb" />
|
||||
<NuxtLayout class="select-none w-screen h-screen">
|
||||
<NuxtPage />
|
||||
<ModalStack />
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
initialNavigation,
|
||||
setupHooks,
|
||||
} from "./composables/state-navigation.js";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { AppState } from "./types.js";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -36,9 +38,8 @@ async function fetchState() {
|
||||
}
|
||||
await fetchState();
|
||||
|
||||
// This is inefficient but apparently we do it lol
|
||||
router.beforeEach(async () => {
|
||||
await fetchState();
|
||||
listen("update_state", (event) => {
|
||||
state.value = event.payload as AppState;
|
||||
});
|
||||
|
||||
setupHooks();
|
||||
|
||||
@@ -6,6 +6,7 @@ html,
|
||||
body {
|
||||
-ms-overflow-style: none; /* IE and Edge /
|
||||
scrollbar-width: none; / Firefox */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
|
||||
106
main/components/DependencyRequiredModal.vue
Normal file
106
main/components/DependencyRequiredModal.vue
Normal 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>
|
||||
@@ -37,7 +37,7 @@
|
||||
</NuxtLink>
|
||||
<div class="h-0.5 rounded-full w-full bg-zinc-800" />
|
||||
<div class="flex flex-col mb-1">
|
||||
<MenuItem v-slot="{ active }">
|
||||
<MenuItem v-if="state.user.admin" v-slot="{ active }">
|
||||
<a
|
||||
:href="adminUrl"
|
||||
target="_blank"
|
||||
|
||||
87
main/components/InstallDirectorySelector.vue
Normal file
87
main/components/InstallDirectorySelector.vue
Normal 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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="mb-3 inline-flex gap-x-2">
|
||||
<div
|
||||
class="relative transition-transform duration-300 hover:scale-105 active:scale-95"
|
||||
@@ -20,72 +20,134 @@
|
||||
/>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<ArrowPathIcon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5">
|
||||
<NuxtLink
|
||||
v-for="nav in filteredNavigation"
|
||||
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5 h-full">
|
||||
<Disclosure
|
||||
as="div"
|
||||
v-for="(nav, navIndex) in filteredNavigation"
|
||||
:key="nav.id"
|
||||
:class="[
|
||||
'transition-all duration-300 rounded-lg flex items-center py-2 px-3 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
|
||||
nav.index === currentNavigation
|
||||
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
|
||||
: nav.isInstalled.value
|
||||
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
|
||||
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
|
||||
]"
|
||||
:href="nav.route"
|
||||
:class="['first:pt-0 last:pb-0', nav.tools ? 'mt-auto' : '']"
|
||||
v-slot="{ open }"
|
||||
:default-open="nav.deft"
|
||||
>
|
||||
<div class="flex items-center w-full gap-x-3">
|
||||
<div
|
||||
class="flex-none transition-transform duration-300 hover:-rotate-2"
|
||||
<dt>
|
||||
<DisclosureButton
|
||||
class="flex w-full items-center justify-between text-left text-gray-900 dark:text-white"
|
||||
>
|
||||
<img
|
||||
class="size-8 object-cover bg-zinc-900 rounded-lg transition-all duration-300 shadow-sm"
|
||||
:src="icons[nav.id]"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1">
|
||||
<p
|
||||
class="truncate text-xs font-display leading-5 flex-1 font-semibold"
|
||||
>
|
||||
{{ nav.label }}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs font-medium"
|
||||
:class="[gameStatusTextStyle[games[nav.id].status.value.type]]"
|
||||
>
|
||||
{{ gameStatusText[games[nav.id].status.value.type] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<span class="text-sm font-semibold font-display">{{
|
||||
nav.name
|
||||
}}</span>
|
||||
<span class="ml-6 relative flex size-4">
|
||||
<MinusIcon class="absolute inset-0 size-4" aria-hidden="true" />
|
||||
<MinusIcon
|
||||
:class="[ !open ? 'rotate-90' : 'rotate-0', 'transition-all absolute inset-0 size-4']"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</DisclosureButton>
|
||||
</dt>
|
||||
<DisclosurePanel as="dd" class="mt-2 flex flex-col gap-y-1.5">
|
||||
<NuxtLink
|
||||
v-for="item in nav.items"
|
||||
:key="nav.id"
|
||||
:class="[
|
||||
'transition-all duration-300 rounded-lg flex items-center px-1 py-1.5 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
|
||||
currentNavigation == item.id
|
||||
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
|
||||
: item.isInstalled.value
|
||||
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
|
||||
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
|
||||
]"
|
||||
:href="item.route"
|
||||
>
|
||||
<div class="flex items-center w-full gap-x-2">
|
||||
<div
|
||||
class="flex-none transition-transform duration-300 hover:-rotate-2"
|
||||
>
|
||||
<img
|
||||
class="size-6 object-cover bg-zinc-900 rounded transition-all duration-300 shadow-sm"
|
||||
:src="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>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="h-full grow flex p-8 justify-center text-zinc-100"
|
||||
>
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-6 h-6 text-transparent animate-spin fill-zinc-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { 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 { GameStatusEnum, type Game, type GameStatus } from "~/types";
|
||||
import {
|
||||
GameStatusEnum,
|
||||
type Collection as Collection,
|
||||
type Game,
|
||||
type GameStatus,
|
||||
} from "~/types";
|
||||
import { TransitionGroup } from "vue";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
// Style information
|
||||
const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
|
||||
[GameStatusEnum.Installed]: "text-green-500",
|
||||
[GameStatusEnum.Downloading]: "text-blue-500",
|
||||
[GameStatusEnum.Downloading]: "text-zinc-400",
|
||||
[GameStatusEnum.Validating]: "text-blue-300",
|
||||
[GameStatusEnum.Running]: "text-green-500",
|
||||
[GameStatusEnum.Remote]: "text-zinc-500",
|
||||
[GameStatusEnum.Queued]: "text-blue-500",
|
||||
[GameStatusEnum.Updating]: "text-blue-500",
|
||||
[GameStatusEnum.Running]: "text-blue-500",
|
||||
[GameStatusEnum.Remote]: "text-zinc-700",
|
||||
[GameStatusEnum.Queued]: "text-zinc-400",
|
||||
[GameStatusEnum.Updating]: "text-zinc-400",
|
||||
[GameStatusEnum.Uninstalling]: "text-zinc-100",
|
||||
[GameStatusEnum.SetupRequired]: "text-yellow-500",
|
||||
[GameStatusEnum.PartiallyInstalled]: "text-gray-400",
|
||||
@@ -107,71 +169,147 @@ const router = useRouter();
|
||||
|
||||
const searchQuery = ref("");
|
||||
|
||||
const loading = ref(false);
|
||||
const games: {
|
||||
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
|
||||
} = {};
|
||||
const icons: { [key: string]: string } = {};
|
||||
|
||||
const rawGames: Ref<Game[], Game[]> = ref([]);
|
||||
const collections: Ref<Collection[]> = ref([]);
|
||||
|
||||
async function calculateGames(clearAll = false) {
|
||||
if (clearAll) rawGames.value = [];
|
||||
async function calculateGames(clearAll = false, forceRefresh = false) {
|
||||
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
|
||||
// add all the necessary state, and it freaks tf out
|
||||
const newGames = await invoke<typeof rawGames.value>("fetch_library");
|
||||
for (const game of newGames) {
|
||||
const library = await invoke<FetchLibraryResponse>("fetch_library", {
|
||||
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;
|
||||
games[game.id] = await useGame(game.id);
|
||||
}
|
||||
for (const game of newGames) {
|
||||
if (icons[game.id]) continue;
|
||||
icons[game.id] = await useObject(game.mIconObjectId);
|
||||
}
|
||||
rawGames.value = newGames;
|
||||
|
||||
const libraryCollection = {
|
||||
id: "library",
|
||||
name: "Library",
|
||||
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(() =>
|
||||
rawGames.value.map((game) => {
|
||||
const status = games[game.id].status;
|
||||
collections.value.map((collection) => {
|
||||
const items = collection.entries.map(({ game }) => {
|
||||
const status = games[game.id].status;
|
||||
|
||||
const isInstalled = computed(
|
||||
() =>
|
||||
status.value.type == GameStatusEnum.Installed ||
|
||||
status.value.type == GameStatusEnum.SetupRequired
|
||||
);
|
||||
const isInstalled = computed(
|
||||
() => status.value.type != GameStatusEnum.Remote,
|
||||
);
|
||||
|
||||
const item = {
|
||||
label: game.mName,
|
||||
route: `/library/${game.id}`,
|
||||
prefix: `/library/${game.id}`,
|
||||
isInstalled,
|
||||
id: game.id,
|
||||
const item = {
|
||||
label: game.mName,
|
||||
route: `/library/${game.id}`,
|
||||
prefix: `/library/${game.id}`,
|
||||
icon: game.mIconObjectId,
|
||||
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(() => {
|
||||
if (!searchQuery.value)
|
||||
return navigation.value.map((e, i) => ({ ...e, index: i }));
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return navigation.value
|
||||
.filter((nav) => nav.label.toLowerCase().includes(query))
|
||||
.map((e, i) => ({ ...e, index: i }));
|
||||
.map((c) => ({
|
||||
...c,
|
||||
items: c.items.filter((nav) => nav.label.toLowerCase().includes(query)),
|
||||
}))
|
||||
.filter((e) => e.items.length > 0);
|
||||
});
|
||||
|
||||
listen("update_library", async (event) => {
|
||||
console.log("Updating library");
|
||||
let oldNavigation = navigation.value[currentNavigation.value];
|
||||
await calculateGames();
|
||||
recalculateNavigation();
|
||||
if (oldNavigation !== navigation.value[currentNavigation.value]) {
|
||||
console.log("Triggered");
|
||||
let oldNavigation = currentNavigation.value;
|
||||
await calculateGames(false, true);
|
||||
if (oldNavigation !== currentNavigation.value) {
|
||||
router.push("/library");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<template></template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const loading = useLoadingIndicator();
|
||||
|
||||
watch(loading.isLoading, console.log);
|
||||
</script>
|
||||
@@ -1,9 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
|
||||
<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"
|
||||
>
|
||||
<div class="relative">
|
||||
@@ -13,5 +19,5 @@ import { ArrowDownTrayIcon, CloudIcon } from "@heroicons/vue/20/solid";
|
||||
/>
|
||||
</div>
|
||||
Offline
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -32,3 +32,19 @@ listen("update_stats", (event) => {
|
||||
const stats = useStatsState();
|
||||
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]}`;
|
||||
}
|
||||
@@ -48,13 +48,13 @@ export const useGame = async (gameId: string) => {
|
||||
version?: GameVersion;
|
||||
} = event.payload as any;
|
||||
gameStatusRegistry[gameId].value = parseStatus(payload.status);
|
||||
|
||||
|
||||
/**
|
||||
* I am not super happy about this.
|
||||
*
|
||||
*
|
||||
* This will mean that we will still have a version assigned if we have a game installed then uninstall it.
|
||||
* It is necessary because a flag to check if we should overwrite seems excessive, and this function gets called
|
||||
* on transient state updates.
|
||||
* on transient state updates.
|
||||
*/
|
||||
if (payload.version) {
|
||||
gameRegistry[gameId].version = payload.version;
|
||||
@@ -71,3 +71,23 @@ export const useGame = async (gameId: string) => {
|
||||
export type FrontendGameConfiguration = {
|
||||
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;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
|
||||
export const useObject = async (id: string) => {
|
||||
export const useObject = (id: string) => {
|
||||
return convertFileSrc(id, "object");
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export default defineNuxtConfig({
|
||||
css: ["~/assets/main.scss"],
|
||||
|
||||
ssr: false,
|
||||
devtools: false,
|
||||
|
||||
extends: [["../libs/drop-base"]],
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "view",
|
||||
"private": true,
|
||||
"version": "0.3.2-dl",
|
||||
"version": "0.3.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt generate",
|
||||
@@ -13,7 +13,9 @@
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@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",
|
||||
"markdown-it": "^14.1.0",
|
||||
"micromark": "^4.0.1",
|
||||
@@ -32,6 +34,5 @@
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.8.3",
|
||||
"vue-tsc": "^2.2.10"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
}
|
||||
|
||||
25
main/pages/community.vue
Normal file
25
main/pages/community.vue
Normal 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>
|
||||
@@ -170,8 +170,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="space-y-6">
|
||||
<div v-if="versionOptions && versionOptions.length > 0">
|
||||
<div class="space-y-6">
|
||||
<div v-if="versionOptions && versionOptions.length > 0 && currentVersionOption">
|
||||
<Listbox as="div" v-model="installVersionIndex">
|
||||
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
|
||||
>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"
|
||||
>
|
||||
<span class="block truncate"
|
||||
>{{ versionOptions[installVersionIndex].versionName }}
|
||||
>{{
|
||||
currentVersionOption.displayName ||
|
||||
currentVersionOption.versionPath
|
||||
}}
|
||||
on
|
||||
{{ versionOptions[installVersionIndex].platform }}</span
|
||||
{{ currentVersionOption.platform }} ({{
|
||||
formatKilobytes(
|
||||
currentVersionOption.size / 1024
|
||||
)
|
||||
}}B)</span
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
@@ -206,7 +213,7 @@
|
||||
<ListboxOption
|
||||
as="template"
|
||||
v-for="(version, versionIdx) in versionOptions"
|
||||
:key="version.versionName"
|
||||
:key="version.versionId"
|
||||
:value="versionIdx"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
@@ -223,8 +230,12 @@
|
||||
: 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ version.versionName }} on
|
||||
{{ version.platform }}</span
|
||||
>{{ version.displayName || version.versionPath }} on
|
||||
{{ version.platform }} ({{
|
||||
formatKilobytes(
|
||||
versionOptions[installVersionIndex].size / 1024
|
||||
)
|
||||
}}B)</span
|
||||
>
|
||||
|
||||
<span
|
||||
@@ -243,7 +254,10 @@
|
||||
</div>
|
||||
</Listbox>
|
||||
</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-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
@@ -256,83 +270,108 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="installDirs">
|
||||
<Listbox as="div" v-model="installDir">
|
||||
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
|
||||
>Install to</ListboxLabel
|
||||
<div v-else class="w-full flex items-center justify-center p-4">
|
||||
<div role="status">
|
||||
<svg
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
</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 class="flex">
|
||||
@@ -368,6 +407,48 @@
|
||||
</template>
|
||||
</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,
|
||||
This v-if is necessary for Vue rendering reasons
|
||||
@@ -446,6 +527,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<DependencyRequiredModal
|
||||
v-if="dependencyRequiredModal"
|
||||
v-model="dependencyRequiredModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -465,9 +551,10 @@ import {
|
||||
XMarkIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
PhotoIcon,
|
||||
PlayIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
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 { micromark } from "micromark";
|
||||
import { GameStatusEnum } from "~/types";
|
||||
@@ -496,9 +583,7 @@ const mediaUrls = await Promise.all(
|
||||
const htmlDescription = micromark(game.value.mDescription);
|
||||
|
||||
const installFlowOpen = ref(false);
|
||||
const versionOptions = ref<
|
||||
undefined | Array<{ versionName: string; platform: string }>
|
||||
>();
|
||||
const versionOptions = ref<undefined | Array<VersionOption>>();
|
||||
const installDirs = ref<undefined | Array<string>>();
|
||||
const currentImageIndex = ref(0);
|
||||
|
||||
@@ -510,13 +595,13 @@ async function installFlow() {
|
||||
installDirs.value = undefined;
|
||||
|
||||
try {
|
||||
versionOptions.value = await invoke("fetch_game_verion_options", {
|
||||
versionOptions.value = await invoke("fetch_game_version_options", {
|
||||
gameId: game.value.id,
|
||||
});
|
||||
console.log(versionOptions.value);
|
||||
installDirs.value = await invoke("fetch_download_dir_stats");
|
||||
} catch (error) {
|
||||
installError.value = (error as string).toString();
|
||||
versionOptions.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,15 +609,31 @@ const installLoading = ref(false);
|
||||
const installError = ref<string | undefined>();
|
||||
const installVersionIndex = ref(0);
|
||||
const installDir = ref(0);
|
||||
const installDepsDisabled = ref<{ [key: string]: boolean }>({});
|
||||
|
||||
const currentVersionOption = computed(() => versionOptions.value?.[installVersionIndex.value]);
|
||||
async function install() {
|
||||
try {
|
||||
if (!versionOptions.value) throw new Error("Versions have not been loaded");
|
||||
installLoading.value = true;
|
||||
await invoke("download_game", {
|
||||
gameId: game.value.id,
|
||||
gameVersion: versionOptions.value[installVersionIndex.value].versionName,
|
||||
installDir: installDir.value,
|
||||
});
|
||||
const versionOption = versionOptions.value[installVersionIndex.value];
|
||||
|
||||
const games = [
|
||||
{ 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;
|
||||
} catch (error) {
|
||||
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() {
|
||||
if (status.value.type == GameStatusEnum.SetupRequired) {
|
||||
await launchIndex(0);
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
createModal(
|
||||
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() {
|
||||
router.push("/queue");
|
||||
}
|
||||
|
||||
25
main/pages/news.vue
Normal file
25
main/pages/news.vue
Normal 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>
|
||||
@@ -4,18 +4,18 @@
|
||||
class="h-16 overflow-hidden relative rounded-xl flex flex-row border border-zinc-900"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 text-blue-400 font-display items-left justify-center pl-2"
|
||||
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 v-if="stats.time > 0" class="text-sm"
|
||||
<span class="font-bold text-zinc-100">{{ formatKilobytes(stats.speed) }}B/s</span>
|
||||
<span class="text-xs text-zinc-400"
|
||||
>{{ formatTime(stats.time) }} left</span
|
||||
>
|
||||
</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
|
||||
v-for="bar in speedHistory"
|
||||
:style="{ height: `${(bar / speedMax) * 100}%` }"
|
||||
class="w-[8px] bg-blue-600/40"
|
||||
class="w-[3px] bg-blue-600 rounded-t-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,9 +62,9 @@
|
||||
class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display"
|
||||
><span class="text-zinc-300">{{
|
||||
formatKilobytes(element.current / 1000)
|
||||
}}</span>
|
||||
}}B</span>
|
||||
/
|
||||
<span class="">{{ formatKilobytes(element.max / 1000) }}</span
|
||||
<span class="">{{ formatKilobytes(element.max / 1000) }}B</span
|
||||
><ServerIcon class="size-5"
|
||||
/></span>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ServerIcon, XMarkIcon } from "@heroicons/vue/20/solid";
|
||||
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 = {
|
||||
// [GameStatusEnum.Downloading]: "downloading",
|
||||
@@ -105,12 +105,12 @@ window.addEventListener("resize", (event) => {
|
||||
|
||||
const queue = useQueueState();
|
||||
const stats = useStatsState();
|
||||
const speedHistory = useState<Array<number>>(() => []);
|
||||
const speedHistoryMax = computed(() => windowWidth.value / 8);
|
||||
const speedHistory = useDownloadHistory();
|
||||
const speedHistoryMax = computed(() => windowWidth.value / 4);
|
||||
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<{
|
||||
[key: string]: { game: Game; status: Ref<GameStatus>; cover: string };
|
||||
@@ -122,14 +122,15 @@ function resetHistoryGraph() {
|
||||
}
|
||||
function checkReset(v: QueueState) {
|
||||
const currentGame = v.queue.at(0)?.meta.id;
|
||||
// If we don't have a game
|
||||
if (!currentGame) return;
|
||||
|
||||
// If we're finished
|
||||
if (!currentGame && previousGameId.value) {
|
||||
previousGameId.value = undefined;
|
||||
resetHistoryGraph();
|
||||
return;
|
||||
}
|
||||
// If we don't have a game
|
||||
if (!currentGame) return;
|
||||
// If we started a new download
|
||||
if (currentGame && !previousGameId.value) {
|
||||
previousGameId.value = currentGame;
|
||||
@@ -149,9 +150,10 @@ watch(queue, (v) => {
|
||||
});
|
||||
|
||||
watch(stats, (v) => {
|
||||
if(v.speed == 0) return;
|
||||
const newLength = speedHistory.value.push(v.speed);
|
||||
if (newLength > speedHistoryMax.value) {
|
||||
speedHistory.value.splice(0, 1);
|
||||
speedHistory.value.splice(0, newLength - speedHistoryMax.value);
|
||||
}
|
||||
checkReset(queue.value);
|
||||
});
|
||||
@@ -182,21 +184,10 @@ async function cancelGame(meta: DownloadableMetadata) {
|
||||
await invoke("cancel_game", { meta });
|
||||
}
|
||||
|
||||
function formatKilobytes(bytes: number): string {
|
||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
const scalar = 1000;
|
||||
|
||||
while (value >= scalar && unitIndex < units.length - 1) {
|
||||
value /= scalar;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (seconds == 0) {
|
||||
return `0s`;
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ platformInfo.value = currentPlatform;
|
||||
async function openDataDir() {
|
||||
if (!dataDir.value) return;
|
||||
try {
|
||||
await open(dataDir.value);
|
||||
await invoke("open_fs", { path: dataDir.value });
|
||||
} catch (error) {
|
||||
console.error("Failed to open data dir:", error);
|
||||
}
|
||||
@@ -126,7 +126,7 @@ async function openLogFile() {
|
||||
if (!dataDir.value) return;
|
||||
try {
|
||||
const logPath = `${dataDir.value}/drop.log`;
|
||||
await open(logPath);
|
||||
await invoke("open_fs", { path: logPath });
|
||||
} catch (error) {
|
||||
console.error("Failed to open log file:", error);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
<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>
|
||||
|
||||
@@ -1,37 +1,12 @@
|
||||
<template>
|
||||
<div class="grow w-full h-full flex items-center justify-center">
|
||||
<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>
|
||||
<iframe :src="convertedStoreUrl" class="grow w-full h-full" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
BuildingStorefrontIcon,
|
||||
} 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>
|
||||
|
||||
8217
main/pnpm-lock.yaml
generated
Normal file
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
3
main/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- esbuild
|
||||
@@ -27,6 +27,7 @@ export type AppState = {
|
||||
|
||||
export type Game = {
|
||||
id: string;
|
||||
type: "Game" | "Executor" | "Redist";
|
||||
mName: string;
|
||||
mShortDescription: string;
|
||||
mDescription: string;
|
||||
@@ -37,6 +38,14 @@ export type Game = {
|
||||
mImageCarouselObjectIds: string[];
|
||||
};
|
||||
|
||||
export type Collection = {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
isTools?: boolean;
|
||||
entries: Array<{ gameId: string; game: Game }>;
|
||||
};
|
||||
|
||||
export type GameVersion = {
|
||||
launchCommandTemplate: string;
|
||||
};
|
||||
|
||||
8091
main/yarn.lock
8091
main/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 yarn tauri dev
|
||||
GSK_RENDERER=ngl pnpm tauri dev
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
ARCH=$(uname -m)
|
||||
|
||||
# build tauri apps
|
||||
# NO_STRIP=true yarn tauri build -- --verbose
|
||||
# NO_STRIP=true pnpm tauri build -- --verbose
|
||||
|
||||
# unpack appimage
|
||||
APPIMAGE=$(ls ./src-tauri/target/release/bundle/appimage/*.AppImage)
|
||||
|
||||
11
package.json
11
package.json
@@ -7,16 +7,11 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"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-pretty": "^13.1.1"
|
||||
"pino-pretty": "^13.1.1",
|
||||
"tauri": "^0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.7.1"
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
}
|
||||
|
||||
5583
pnpm-lock.yaml
generated
Normal file
5583
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
pnpm-workspace.yaml
Normal file
21
pnpm-workspace.yaml
Normal 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
3229
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,118 +1,150 @@
|
||||
[package]
|
||||
name = "drop-app"
|
||||
version = "0.3.2-dl"
|
||||
version = "0.4.0"
|
||||
description = "The client application for the open-source, self-hosted game distribution platform Drop"
|
||||
authors = ["Drop OSS"]
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = [
|
||||
"client",
|
||||
"cloud_saves",
|
||||
"database",
|
||||
"download_manager",
|
||||
"games",
|
||||
"process",
|
||||
"remote",
|
||||
"utils",
|
||||
]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib", "staticlib"]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# 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
|
||||
name = "drop_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
rustflags = ["-C", "target-feature=+aes,+sse2"]
|
||||
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.0", features = [] }
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[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"
|
||||
bitcode = "0.6.6"
|
||||
boxcar = "0.2.7"
|
||||
bytes = "1.10.1"
|
||||
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"
|
||||
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-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"
|
||||
futures-lite = "2.6.0"
|
||||
page_size = "0.6.0"
|
||||
schemars = "0.8.22"
|
||||
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"
|
||||
humansize = "2.1.3"
|
||||
# tailscale = { path = "./tailscale" }
|
||||
|
||||
[dependencies.dynfmt]
|
||||
version = "0.1.5"
|
||||
features = ["curly"]
|
||||
|
||||
[dependencies.tauri]
|
||||
version = "2.7.0"
|
||||
features = ["protocol-asset", "tray-icon"]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.40.0"
|
||||
features = ["rt", "tokio-macros", "signal"]
|
||||
tar = "0.4.44"
|
||||
tauri-plugin-autostart = "*"
|
||||
tauri-plugin-deep-link = "*"
|
||||
tauri-plugin-dialog = "*"
|
||||
tauri-plugin-opener = "*"
|
||||
tauri-plugin-os = "*"
|
||||
tauri-plugin-shell = "*"
|
||||
tempfile = "3.19.1"
|
||||
throttle_my_fn = "0.2.6"
|
||||
tokio-util = { version = "0.7.16", features = ["io"] }
|
||||
umu-wrapper-lib = "0.1.0"
|
||||
url = "2.5.2"
|
||||
urlencoding = "2.1.3"
|
||||
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]
|
||||
version = "1.3.0"
|
||||
features = ["console_appender", "file_appender"]
|
||||
|
||||
[dependencies.rustbreak]
|
||||
version = "2"
|
||||
features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
|
||||
|
||||
[dependencies.rustix]
|
||||
version = "0.38.37"
|
||||
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]
|
||||
version = "1"
|
||||
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]
|
||||
lto = true
|
||||
panic = "abort"
|
||||
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
4862
src-tauri/client/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
src-tauri/client/Cargo.toml
Normal file
12
src-tauri/client/Cargo.toml
Normal 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 = "*"
|
||||
10
src-tauri/client/src/app_state.rs
Normal file
10
src-tauri/client/src/app_state.rs
Normal 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>
|
||||
}
|
||||
12
src-tauri/client/src/app_status.rs
Normal file
12
src-tauri/client/src/app_status.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Eq, PartialEq)]
|
||||
pub enum AppStatus {
|
||||
NotConfigured,
|
||||
Offline,
|
||||
ServerError,
|
||||
SignedOut,
|
||||
SignedIn,
|
||||
SignedInNeedsReauth,
|
||||
ServerUnavailable,
|
||||
}
|
||||
26
src-tauri/client/src/autostart.rs
Normal file
26
src-tauri/client/src/autostart.rs
Normal 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(())
|
||||
}
|
||||
52
src-tauri/client/src/compat.rs
Normal file
52
src-tauri/client/src/compat.rs
Normal 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()
|
||||
}
|
||||
5
src-tauri/client/src/lib.rs
Normal file
5
src-tauri/client/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod app_status;
|
||||
pub mod autostart;
|
||||
pub mod compat;
|
||||
pub mod user;
|
||||
pub mod app_state;
|
||||
12
src-tauri/client/src/user.rs
Normal file
12
src-tauri/client/src/user.rs
Normal 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,
|
||||
}
|
||||
19
src-tauri/cloud_saves/Cargo.toml
Normal file
19
src-tauri/cloud_saves/Cargo.toml
Normal 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"
|
||||
234
src-tauri/cloud_saves/src/backup_manager.rs
Normal file
234
src-tauri/cloud_saves/src/backup_manager.rs
Normal 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 {}
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::process::process_manager::Platform;
|
||||
use database::platform::Platform;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Condition {
|
||||
Os(Platform)
|
||||
Os(Platform),
|
||||
Other
|
||||
}
|
||||
27
src-tauri/cloud_saves/src/error.rs
Normal file
27
src-tauri/cloud_saves/src/error.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use serde_with::SerializeDisplay;
|
||||
|
||||
#[derive(Debug, SerializeDisplay, Clone, Copy)]
|
||||
|
||||
pub enum BackupError {
|
||||
InvalidSystem,
|
||||
|
||||
NotFound,
|
||||
|
||||
ParseError,
|
||||
}
|
||||
|
||||
impl Display for BackupError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
BackupError::InvalidSystem => "Attempted to generate path for invalid system",
|
||||
|
||||
BackupError::NotFound => "Could not generate or find path",
|
||||
|
||||
BackupError::ParseError => "Failed to parse path",
|
||||
};
|
||||
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
8
src-tauri/cloud_saves/src/lib.rs
Normal file
8
src-tauri/cloud_saves/src/lib.rs
Normal 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;
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::database::db::GameVersion;
|
||||
|
||||
use super::conditions::{Condition};
|
||||
use database::GameVersion;
|
||||
|
||||
use super::conditions::Condition;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CloudSaveMetadata {
|
||||
@@ -16,15 +15,17 @@ pub struct GameFile {
|
||||
pub id: Option<String>,
|
||||
pub data_type: DataType,
|
||||
pub tags: Vec<Tag>,
|
||||
pub conditions: Vec<Condition>
|
||||
pub conditions: Vec<Condition>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
||||
pub enum DataType {
|
||||
Registry,
|
||||
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")]
|
||||
pub enum Tag {
|
||||
Config,
|
||||
@@ -32,4 +33,4 @@ pub enum Tag {
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use database::platform::Platform;
|
||||
use regex::Regex;
|
||||
use crate::process::process_manager::Platform;
|
||||
|
||||
use super::placeholder::*;
|
||||
|
||||
|
||||
pub fn normalize(path: &str, os: Platform) -> String {
|
||||
let mut path = path.trim().trim_end_matches(['/', '\\']).replace('\\', "/");
|
||||
|
||||
@@ -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 UNNECESSARY_DOUBLE_STAR_1: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"([^/*])\*{2,}").unwrap());
|
||||
static UNNECESSARY_DOUBLE_STAR_2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*{2,}([^/*])").unwrap());
|
||||
static UNNECESSARY_DOUBLE_STAR_1: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"([^/*])\*{2,}").unwrap());
|
||||
static UNNECESSARY_DOUBLE_STAR_2: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\*{2,}([^/*])").unwrap());
|
||||
static ENDING_WILDCARD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\*)+$").unwrap());
|
||||
static ENDING_DOT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\.)$").unwrap());
|
||||
static INTERMEDIATE_DOT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\./)").unwrap());
|
||||
static BLANK_SEGMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\s+/)").unwrap());
|
||||
static APP_DATA: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%appdata%").unwrap());
|
||||
static APP_DATA_ROAMING: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Roaming").unwrap());
|
||||
static APP_DATA_LOCAL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%localappdata%").unwrap());
|
||||
static APP_DATA_LOCAL_2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Local/").unwrap());
|
||||
static USER_PROFILE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%").unwrap());
|
||||
static DOCUMENTS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/Documents").unwrap());
|
||||
static APP_DATA_ROAMING: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Roaming").unwrap());
|
||||
static APP_DATA_LOCAL: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)%localappdata%").unwrap());
|
||||
static APP_DATA_LOCAL_2: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Local/").unwrap());
|
||||
static USER_PROFILE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)%userprofile%").unwrap());
|
||||
static DOCUMENTS: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)%userprofile%/Documents").unwrap());
|
||||
|
||||
for (pattern, replacement) in [
|
||||
(&CONSECUTIVE_SLASHES, "/"),
|
||||
@@ -66,7 +72,9 @@ pub fn normalize(path: &str, os: Platform) -> String {
|
||||
|
||||
fn too_broad(path: &str) -> bool {
|
||||
println!("Path: {}", path);
|
||||
use {BASE, HOME, ROOT, STORE_USER_ID, WIN_APP_DATA, WIN_DIR, WIN_DOCUMENTS, XDG_CONFIG, XDG_DATA};
|
||||
use {
|
||||
BASE, HOME, ROOT, STORE_USER_ID, WIN_APP_DATA, WIN_DIR, WIN_DOCUMENTS, XDG_CONFIG, XDG_DATA,
|
||||
};
|
||||
|
||||
let path_lower = path.to_lowercase();
|
||||
|
||||
@@ -77,7 +85,9 @@ fn too_broad(path: &str) -> bool {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -124,7 +134,6 @@ fn too_broad(path: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Drive letters:
|
||||
let drives: Regex = Regex::new(r"^[a-zA-Z]:$").unwrap();
|
||||
@@ -159,4 +168,4 @@ pub fn usable(path: &str) -> bool {
|
||||
&& !path.starts_with("../")
|
||||
&& !too_broad(path)
|
||||
&& !unprintable.is_match(path)
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,12 @@ pub enum CommonPath {
|
||||
|
||||
impl CommonPath {
|
||||
pub fn get(&self) -> Option<PathBuf> {
|
||||
static CONFIG: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::config_dir());
|
||||
static DATA: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::data_dir());
|
||||
static DATA_LOCAL: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::data_local_dir());
|
||||
static DOCUMENT: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::document_dir());
|
||||
static HOME: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::home_dir());
|
||||
static PUBLIC: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::public_dir());
|
||||
static CONFIG: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::config_dir);
|
||||
static DATA: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::data_dir);
|
||||
static DATA_LOCAL: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::data_local_dir);
|
||||
static DOCUMENT: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::document_dir);
|
||||
static HOME: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::home_dir);
|
||||
static PUBLIC: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::public_dir);
|
||||
|
||||
#[cfg(windows)]
|
||||
static DATA_LOCAL_LOW: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
|
||||
@@ -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 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);
|
||||
@@ -1,22 +1,17 @@
|
||||
use std::{
|
||||
fs::{self, create_dir_all, File},
|
||||
io::{self, ErrorKind, Read, Write},
|
||||
fs::{self, File, create_dir_all},
|
||||
io::{self, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
thread::sleep,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use super::{
|
||||
backup_manager::BackupHandler, conditions::Condition, metadata::GameFile, placeholder::*,
|
||||
};
|
||||
use crate::error::BackupError;
|
||||
|
||||
use super::{backup_manager::BackupHandler, placeholder::*};
|
||||
use database::GameVersion;
|
||||
use log::{debug, warn};
|
||||
use rustix::path::Arg;
|
||||
use tempfile::tempfile;
|
||||
|
||||
use crate::{
|
||||
database::db::GameVersion, error::backup_error::BackupError, process::process_manager::Platform,
|
||||
};
|
||||
|
||||
use super::{backup_manager::BackupManager, metadata::CloudSaveMetadata, normalise::normalize};
|
||||
|
||||
pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
|
||||
@@ -31,7 +26,7 @@ pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
|
||||
.iter()
|
||||
.find_map(|p| match p {
|
||||
super::conditions::Condition::Os(os) => Some(os),
|
||||
_ => None,
|
||||
_ => None
|
||||
})
|
||||
.cloned()
|
||||
{
|
||||
@@ -64,7 +59,7 @@ pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
|
||||
let binding = serde_json::to_string(meta).unwrap();
|
||||
let serialized = binding.as_bytes();
|
||||
let mut file = tempfile().unwrap();
|
||||
file.write(serialized).unwrap();
|
||||
file.write_all(serialized).unwrap();
|
||||
tarball.append_file("metadata", &mut file).unwrap();
|
||||
tarball.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
@@ -97,7 +92,7 @@ pub fn extract(file: PathBuf) -> Result<(), BackupError> {
|
||||
.iter()
|
||||
.find_map(|p| match p {
|
||||
super::conditions::Condition::Os(os) => Some(os),
|
||||
_ => None,
|
||||
_ => None
|
||||
})
|
||||
.cloned()
|
||||
{
|
||||
@@ -116,7 +111,7 @@ pub fn extract(file: PathBuf) -> Result<(), BackupError> {
|
||||
};
|
||||
|
||||
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!(
|
||||
"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 dest_path = dest.as_ref();
|
||||
|
||||
let metadata = fs::metadata(&src_path)?;
|
||||
let metadata = fs::metadata(src_path)?;
|
||||
|
||||
if metadata.is_file() {
|
||||
// Ensure the parent directory of the destination exists for a file copy
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(&src_path, &dest_path)?;
|
||||
fs::copy(src_path, dest_path)?;
|
||||
} else if metadata.is_dir() {
|
||||
// For directories, we call the recursive helper function.
|
||||
// The destination for the recursive copy is the `dest_path` itself.
|
||||
copy_dir_recursive(&src_path, &dest_path)?;
|
||||
copy_dir_recursive(src_path, dest_path)?;
|
||||
} else {
|
||||
// Handle other file types like symlinks if necessary,
|
||||
// for now, return an error or skip.
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
return Err(io::Error::other(
|
||||
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<()> {
|
||||
fs::create_dir_all(&dest)?;
|
||||
fs::create_dir_all(dest)?;
|
||||
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
@@ -220,43 +214,3 @@ pub fn parse_path(
|
||||
println!("Final line: {:?}", &s);
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn test() {
|
||||
let mut meta = CloudSaveMetadata {
|
||||
files: vec![
|
||||
GameFile {
|
||||
path: String::from("<home>/favicon.png"),
|
||||
id: None,
|
||||
data_type: super::metadata::DataType::File,
|
||||
tags: Vec::new(),
|
||||
conditions: vec![Condition::Os(Platform::Linux)],
|
||||
},
|
||||
GameFile {
|
||||
path: String::from("<home>/Documents/Pixel Art"),
|
||||
id: None,
|
||||
data_type: super::metadata::DataType::File,
|
||||
tags: Vec::new(),
|
||||
conditions: vec![Condition::Os(Platform::Linux)],
|
||||
},
|
||||
],
|
||||
game_version: GameVersion {
|
||||
game_id: String::new(),
|
||||
version_name: String::new(),
|
||||
platform: Platform::Linux,
|
||||
launch_command: String::new(),
|
||||
launch_args: Vec::new(),
|
||||
launch_command_template: String::new(),
|
||||
setup_command: String::new(),
|
||||
setup_args: Vec::new(),
|
||||
setup_command_template: String::new(),
|
||||
only_setup: true,
|
||||
version_index: 0,
|
||||
delta: false,
|
||||
umu_id_override: None,
|
||||
},
|
||||
save_id: String::from("aaaaaaa"),
|
||||
};
|
||||
//resolve(&mut meta);
|
||||
|
||||
extract("save".into()).unwrap();
|
||||
}
|
||||
19
src-tauri/database/Cargo.toml
Normal file
19
src-tauri/database/Cargo.toml
Normal 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"
|
||||
37
src-tauri/database/src/db.rs
Normal file
37
src-tauri/database/src/db.rs
Normal 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"))
|
||||
});
|
||||
249
src-tauri/database/src/interface.rs
Normal file
249
src-tauri/database/src/interface.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src-tauri/database/src/lib.rs
Normal file
14
src-tauri/database/src/lib.rs
Normal 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,
|
||||
};
|
||||
284
src-tauri/database/src/models.rs
Normal file
284
src-tauri/database/src/models.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src-tauri/database/src/platform.rs
Normal file
47
src-tauri/database/src/platform.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src-tauri/download_manager/Cargo.toml
Normal file
20
src-tauri/download_manager/Cargo.toml
Normal 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" }
|
||||
144
src-tauri/download_manager/src/depot_manager.rs
Normal file
144
src-tauri/download_manager/src/depot_manager.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
use core::panic;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
mpsc::{Receiver, Sender, channel},
|
||||
},
|
||||
thread::{JoinHandle, spawn},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use database::DownloadableMetadata;
|
||||
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::{
|
||||
database::models::data::DownloadableMetadata,
|
||||
error::application_download_error::ApplicationDownloadError,
|
||||
games::library::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent},
|
||||
depot_manager::DepotManager,
|
||||
download_manager_frontend::DownloadStatus,
|
||||
error::ApplicationDownloadError,
|
||||
frontend_updates::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent},
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -75,17 +78,17 @@ pub struct DownloadManagerBuilder {
|
||||
status: Arc<Mutex<DownloadManagerStatus>>,
|
||||
app_handle: AppHandle,
|
||||
|
||||
current_download_agent: Option<DownloadAgent>, // Should be the only download agent in the map with the "Go" flag
|
||||
current_download_thread: Mutex<Option<JoinHandle<()>>>,
|
||||
active_control_flag: Option<DownloadThreadControl>,
|
||||
}
|
||||
impl DownloadManagerBuilder {
|
||||
pub fn build(app_handle: AppHandle) -> DownloadManager {
|
||||
let queue = Queue::new();
|
||||
let (command_sender, command_receiver) = channel();
|
||||
let (command_sender, command_receiver) = mpsc::channel(1500);
|
||||
let active_progress = Arc::new(Mutex::new(None));
|
||||
let status = Arc::new(Mutex::new(DownloadManagerStatus::Empty));
|
||||
|
||||
let depot_manager = Arc::new(DepotManager::new());
|
||||
let manager = Self {
|
||||
download_agent_registry: HashMap::new(),
|
||||
download_queue: queue.clone(),
|
||||
@@ -95,73 +98,91 @@ impl DownloadManagerBuilder {
|
||||
progress: active_progress.clone(),
|
||||
app_handle,
|
||||
|
||||
current_download_agent: None,
|
||||
current_download_thread: Mutex::new(None),
|
||||
active_control_flag: None,
|
||||
};
|
||||
|
||||
let terminator = spawn(|| manager.manage_queue());
|
||||
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) {
|
||||
*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();
|
||||
let download_agent = self.download_agent_registry.remove(meta).unwrap();
|
||||
self.cleanup_current_download();
|
||||
self.cleanup_current_download().await;
|
||||
download_agent
|
||||
}
|
||||
|
||||
// CAREFUL WITH THIS FUNCTION
|
||||
// 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.progress.lock().unwrap() = None;
|
||||
self.current_download_agent = None;
|
||||
*lock!(self.progress) = None;
|
||||
|
||||
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
|
||||
*download_thread_lock = None;
|
||||
drop(download_thread_lock);
|
||||
if let Some(unfinished_thread) = {
|
||||
let mut download_thread_lock = lock!(self.current_download_thread);
|
||||
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);
|
||||
if let Some(current_flag) = &self.active_control_flag {
|
||||
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();
|
||||
if let Some(current_download_thread) = download_thread_lock.take() {
|
||||
current_download_thread.join().unwrap();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn manage_queue(mut self) -> Result<(), ()> {
|
||||
async fn manage_queue(mut self) -> Result<(), ()> {
|
||||
loop {
|
||||
let signal = match self.command_receiver.recv() {
|
||||
Ok(signal) => signal,
|
||||
Err(_) => return Err(()),
|
||||
let signal = match self.command_receiver.recv().await {
|
||||
Some(signal) => signal,
|
||||
None => return Err(()),
|
||||
};
|
||||
|
||||
match signal {
|
||||
DownloadManagerSignal::Go => {
|
||||
self.manage_go_signal();
|
||||
self.manage_go_signal().await;
|
||||
}
|
||||
DownloadManagerSignal::Stop => {
|
||||
self.manage_stop_signal();
|
||||
}
|
||||
DownloadManagerSignal::Completed(meta) => {
|
||||
self.manage_completed_signal(meta);
|
||||
self.manage_completed_signal(meta).await;
|
||||
}
|
||||
DownloadManagerSignal::Queue(download_agent) => {
|
||||
self.manage_queue_signal(download_agent);
|
||||
self.manage_queue_signal(download_agent).await;
|
||||
}
|
||||
DownloadManagerSignal::Error(e) => {
|
||||
self.manage_error_signal(e);
|
||||
self.manage_error_signal(e).await;
|
||||
}
|
||||
DownloadManagerSignal::UpdateUIQueue => {
|
||||
self.push_ui_queue_update();
|
||||
@@ -170,16 +191,16 @@ impl DownloadManagerBuilder {
|
||||
self.push_ui_stats_update(kbs, time);
|
||||
}
|
||||
DownloadManagerSignal::Finish => {
|
||||
self.stop_and_wait_current_download();
|
||||
self.stop_and_wait_current_download().await;
|
||||
return Ok(());
|
||||
}
|
||||
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");
|
||||
let meta = download_agent.metadata();
|
||||
|
||||
@@ -190,16 +211,14 @@ impl DownloadManagerBuilder {
|
||||
return;
|
||||
}
|
||||
|
||||
download_agent.on_initialised(&self.app_handle);
|
||||
download_agent.on_queued(&self.app_handle);
|
||||
self.download_queue.append(meta.clone());
|
||||
self.download_agent_registry.insert(meta, download_agent);
|
||||
|
||||
self.sender
|
||||
.send(DownloadManagerSignal::UpdateUIQueue)
|
||||
.unwrap();
|
||||
send!(self.sender, DownloadManagerSignal::UpdateUIQueue);
|
||||
}
|
||||
|
||||
fn manage_go_signal(&mut self) {
|
||||
async fn manage_go_signal(&mut self) {
|
||||
debug!("got signal Go");
|
||||
if self.download_agent_registry.is_empty() {
|
||||
debug!(
|
||||
@@ -209,23 +228,13 @@ impl DownloadManagerBuilder {
|
||||
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());
|
||||
|
||||
// Should always be Some if the above two statements keep going
|
||||
let agent_data = self.download_queue.read().front().unwrap().clone();
|
||||
|
||||
info!("starting download for {agent_data:?}");
|
||||
let agent_data = if let Some(agent_data) = self.download_queue.read().front() {
|
||||
agent_data.clone()
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
let download_agent = self
|
||||
.download_agent_registry
|
||||
@@ -233,33 +242,52 @@ impl DownloadManagerBuilder {
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
let status = download_agent.status();
|
||||
|
||||
// This download is already going
|
||||
if status != DownloadStatus::Queued {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure all others are marked as queued
|
||||
for agent in self.download_agent_registry.values() {
|
||||
if agent.metadata() != agent_data && agent.status() != DownloadStatus::Queued {
|
||||
agent.on_queued(&self.app_handle);
|
||||
}
|
||||
}
|
||||
|
||||
info!("starting download for {agent_data:?}");
|
||||
self.active_control_flag = Some(download_agent.control_flag());
|
||||
self.current_download_agent = Some(download_agent.clone());
|
||||
|
||||
let sender = self.sender.clone();
|
||||
|
||||
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
|
||||
let mut download_thread_lock = lock!(self.current_download_thread);
|
||||
let app_handle = self.app_handle.clone();
|
||||
|
||||
*download_thread_lock = Some(spawn(move || {
|
||||
*download_thread_lock = Some(tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
let download_result = match download_agent.download(&app_handle) {
|
||||
// Ok(true) is for completed and exited properly
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("download {:?} has error {}", download_agent.metadata(), &e);
|
||||
download_agent.on_error(&app_handle, &e);
|
||||
sender.send(DownloadManagerSignal::Error(e)).unwrap();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let download_result =
|
||||
match download_agent.download(&app_handle).await {
|
||||
// Ok(true) is for completed and exited properly
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("download {:?} has error {}", download_agent.metadata(), &e);
|
||||
download_agent.on_error(&app_handle, &e);
|
||||
send!(sender, DownloadManagerSignal::Error(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// If the download gets cancelled
|
||||
// If the download gets canceled
|
||||
// immediately return, on_cancelled gets called for us earlier
|
||||
if !download_result {
|
||||
return;
|
||||
}
|
||||
|
||||
if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop {
|
||||
return;
|
||||
}
|
||||
|
||||
let validate_result = match download_agent.validate(&app_handle) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
@@ -269,17 +297,22 @@ impl DownloadManagerBuilder {
|
||||
&e
|
||||
);
|
||||
download_agent.on_error(&app_handle, &e);
|
||||
sender.send(DownloadManagerSignal::Error(e)).unwrap();
|
||||
send!(sender, DownloadManagerSignal::Error(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop {
|
||||
return;
|
||||
}
|
||||
|
||||
if validate_result {
|
||||
download_agent.on_complete(&app_handle);
|
||||
sender
|
||||
.send(DownloadManagerSignal::Completed(download_agent.metadata()))
|
||||
.unwrap();
|
||||
sender.send(DownloadManagerSignal::UpdateUIQueue).unwrap();
|
||||
download_agent.on_complete(&app_handle).await;
|
||||
send!(
|
||||
sender,
|
||||
DownloadManagerSignal::Completed(download_agent.metadata())
|
||||
);
|
||||
send!(sender, DownloadManagerSignal::UpdateUIQueue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -290,67 +323,56 @@ impl DownloadManagerBuilder {
|
||||
active_control_flag.set(DownloadThreadControlFlag::Go);
|
||||
}
|
||||
fn manage_stop_signal(&mut self) {
|
||||
debug!("got signal Stop");
|
||||
|
||||
if let Some(active_control_flag) = self.active_control_flag.clone() {
|
||||
self.set_status(DownloadManagerStatus::Paused);
|
||||
active_control_flag.set(DownloadThreadControlFlag::Stop);
|
||||
}
|
||||
}
|
||||
fn manage_completed_signal(&mut self, meta: DownloadableMetadata) {
|
||||
debug!("got signal Completed");
|
||||
if let Some(interface) = &self.current_download_agent
|
||||
&& interface.metadata() == meta
|
||||
async fn manage_completed_signal(&mut self, meta: DownloadableMetadata) {
|
||||
if let Some(interface) = self.download_queue.read().front()
|
||||
&& interface == &meta
|
||||
{
|
||||
self.remove_and_cleanup_front_download(&meta);
|
||||
self.remove_and_cleanup_front_download(&meta).await;
|
||||
}
|
||||
|
||||
self.push_ui_queue_update();
|
||||
self.sender.send(DownloadManagerSignal::Go).unwrap();
|
||||
send!(self.sender, DownloadManagerSignal::Go);
|
||||
}
|
||||
fn manage_error_signal(&mut self, error: ApplicationDownloadError) {
|
||||
debug!("got signal Error");
|
||||
if let Some(current_agent) = self.current_download_agent.clone() {
|
||||
async fn manage_error_signal(&mut self, error: ApplicationDownloadError) {
|
||||
info!("got signal Error");
|
||||
if let Some(metadata) = self.download_queue.read().front()
|
||||
&& let Some(current_agent) = self.download_agent_registry.get(metadata)
|
||||
{
|
||||
current_agent.on_error(&self.app_handle, &error);
|
||||
|
||||
self.stop_and_wait_current_download();
|
||||
self.remove_and_cleanup_front_download(¤t_agent.metadata());
|
||||
self.stop_and_wait_current_download().await;
|
||||
self.remove_and_cleanup_front_download(metadata).await;
|
||||
}
|
||||
self.push_ui_queue_update();
|
||||
self.set_status(DownloadManagerStatus::Error);
|
||||
}
|
||||
fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) {
|
||||
debug!("got signal Cancel");
|
||||
async fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) {
|
||||
// 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 {
|
||||
if ¤t_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.download_queue.pop_front();
|
||||
|
||||
self.cleanup_current_download();
|
||||
debug!("current download queue: {:?}", self.download_queue.read());
|
||||
}
|
||||
// TODO: Collapse these two into a single if statement somehow
|
||||
else if let Some(download_agent) = self.download_agent_registry.get(meta) {
|
||||
let index = self.download_queue.get_by_meta(meta);
|
||||
if let Some(index) = index {
|
||||
download_agent.on_cancelled(&self.app_handle);
|
||||
let _ = self.download_queue.edit().remove(index).unwrap();
|
||||
let removed = self.download_agent_registry.remove(meta);
|
||||
debug!(
|
||||
"removed {:?} from queue {:?}",
|
||||
removed.map(|x| x.metadata()),
|
||||
self.download_queue.read()
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let Some(download_agent) = self.download_agent_registry.get(meta) {
|
||||
self.cleanup_current_download().await;
|
||||
self.download_agent_registry.remove(meta);
|
||||
}
|
||||
// else just cancel it
|
||||
else if let Some(download_agent) = self.download_agent_registry.get(meta) {
|
||||
let index = self.download_queue.get_by_meta(meta);
|
||||
if let Some(index) = index {
|
||||
download_agent.on_cancelled(&self.app_handle);
|
||||
let _ = self.download_queue.edit().remove(index).unwrap();
|
||||
let _ = self.download_queue.edit().remove(index);
|
||||
let removed = self.download_agent_registry.remove(meta);
|
||||
debug!(
|
||||
"removed {:?} from queue {:?}",
|
||||
@@ -360,11 +382,12 @@ impl DownloadManagerBuilder {
|
||||
}
|
||||
}
|
||||
self.push_ui_queue_update();
|
||||
send!(self.sender, DownloadManagerSignal::Go);
|
||||
}
|
||||
fn push_ui_stats_update(&self, kbs: usize, time: usize) {
|
||||
let event_data = StatsUpdateEvent { speed: kbs, time };
|
||||
|
||||
self.app_handle.emit("update_stats", event_data).unwrap();
|
||||
app_emit!(&self.app_handle, "update_stats", event_data);
|
||||
}
|
||||
fn push_ui_queue_update(&self) {
|
||||
let queue = &self.download_queue.read();
|
||||
@@ -383,6 +406,6 @@ impl DownloadManagerBuilder {
|
||||
.collect();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,25 @@
|
||||
use std::{
|
||||
any::Any,
|
||||
collections::VecDeque,
|
||||
fmt::Debug,
|
||||
sync::{
|
||||
mpsc::{SendError, Sender},
|
||||
Mutex, MutexGuard,
|
||||
},
|
||||
thread::JoinHandle,
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
use database::DownloadableMetadata;
|
||||
use log::{debug, info};
|
||||
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::{
|
||||
database::models::data::DownloadableMetadata,
|
||||
error::application_download_error::ApplicationDownloadError,
|
||||
};
|
||||
use crate::{depot_manager::DepotManager, error::ApplicationDownloadError};
|
||||
|
||||
use super::{
|
||||
download_manager_builder::{CurrentProgressObject, DownloadAgent},
|
||||
util::queue::Queue,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DownloadManagerSignal {
|
||||
/// Resumes (or starts) the `DownloadManager`
|
||||
Go,
|
||||
@@ -62,7 +60,7 @@ impl Serialize for DownloadManagerStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[derive(Serialize, Clone, Debug, PartialEq)]
|
||||
pub enum DownloadStatus {
|
||||
Queued,
|
||||
Downloading,
|
||||
@@ -81,36 +79,46 @@ pub enum DownloadStatus {
|
||||
/// which provides raw access to the underlying queue.
|
||||
/// THIS EDITING IS BLOCKING!!!
|
||||
pub struct DownloadManager {
|
||||
terminator: Mutex<Option<JoinHandle<Result<(), ()>>>>,
|
||||
terminator: Mutex<Option<JoinHandle<()>>>,
|
||||
download_queue: Queue,
|
||||
progress: CurrentProgressObject,
|
||||
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)]
|
||||
impl DownloadManager {
|
||||
pub fn new(
|
||||
terminator: JoinHandle<Result<(), ()>>,
|
||||
terminator: JoinHandle<()>,
|
||||
download_queue: Queue,
|
||||
progress: CurrentProgressObject,
|
||||
command_sender: Sender<DownloadManagerSignal>,
|
||||
depot_manager: Arc<DepotManager>,
|
||||
) -> Self {
|
||||
Self {
|
||||
terminator: Mutex::new(Some(terminator)),
|
||||
download_queue,
|
||||
progress,
|
||||
command_sender,
|
||||
depot_manager
|
||||
}
|
||||
}
|
||||
|
||||
pub fn queue_download(
|
||||
pub async fn queue_download(
|
||||
&self,
|
||||
download: DownloadAgent,
|
||||
) -> Result<(), SendError<DownloadManagerSignal>> {
|
||||
info!("creating download with meta {:?}", download.metadata());
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::Queue(download))?;
|
||||
self.command_sender.send(DownloadManagerSignal::Go)
|
||||
.send(DownloadManagerSignal::Queue(download))
|
||||
.await?;
|
||||
self.command_sender.send(DownloadManagerSignal::Go).await
|
||||
}
|
||||
pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> {
|
||||
self.download_queue.edit()
|
||||
@@ -119,68 +127,63 @@ impl DownloadManager {
|
||||
self.download_queue.read()
|
||||
}
|
||||
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())
|
||||
}
|
||||
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 current_index = get_index_from_id(&mut queue, meta).unwrap();
|
||||
let to_move = queue.remove(current_index).unwrap();
|
||||
let current_index =
|
||||
get_index_from_id(&mut queue, meta).expect("Failed to get meta index from id");
|
||||
let to_move = queue
|
||||
.remove(current_index)
|
||||
.expect("Failed to remove meta at index from queue");
|
||||
queue.insert(new_index, to_move);
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::UpdateUIQueue)
|
||||
.unwrap();
|
||||
send!(self.command_sender, DownloadManagerSignal::UpdateUIQueue);
|
||||
}
|
||||
pub fn cancel(&self, meta: DownloadableMetadata) {
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::Cancel(meta))
|
||||
.unwrap();
|
||||
pub async fn cancel(&self, meta: DownloadableMetadata) {
|
||||
send!(self.command_sender, DownloadManagerSignal::Cancel(meta));
|
||||
}
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
|
||||
let needs_pause = current_index == 0 || new_index == 0;
|
||||
if needs_pause {
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::Stop)
|
||||
.unwrap();
|
||||
send!(self.command_sender, DownloadManagerSignal::Stop);
|
||||
}
|
||||
|
||||
debug!("moving download at index {current_index} to index {new_index}");
|
||||
|
||||
let mut queue = self.edit();
|
||||
let to_move = queue.remove(current_index).unwrap();
|
||||
queue.insert(new_index, to_move);
|
||||
drop(queue);
|
||||
{
|
||||
let mut queue = self.edit();
|
||||
let to_move = queue.remove(current_index).expect("Failed to get");
|
||||
queue.insert(new_index, to_move);
|
||||
}
|
||||
|
||||
if needs_pause {
|
||||
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
|
||||
send!(self.command_sender, DownloadManagerSignal::Go);
|
||||
}
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::UpdateUIQueue)
|
||||
.unwrap();
|
||||
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
|
||||
send!(self.command_sender, DownloadManagerSignal::UpdateUIQueue);
|
||||
send!(self.command_sender, DownloadManagerSignal::Go);
|
||||
}
|
||||
pub fn pause_downloads(&self) {
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::Stop)
|
||||
.unwrap();
|
||||
pub async fn pause_downloads(&self) {
|
||||
send!(self.command_sender, DownloadManagerSignal::Stop);
|
||||
}
|
||||
pub fn resume_downloads(&self) {
|
||||
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
|
||||
pub async fn resume_downloads(&self) {
|
||||
send!(self.command_sender, DownloadManagerSignal::Go);
|
||||
}
|
||||
pub fn ensure_terminated(&self) -> Result<Result<(), ()>, Box<dyn Any + Send>> {
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::Finish)
|
||||
.unwrap();
|
||||
let terminator = self.terminator.lock().unwrap().take();
|
||||
terminator.unwrap().join()
|
||||
pub async fn ensure_terminated(&self) -> Result<(), tauri::Error> {
|
||||
send!(self.command_sender, DownloadManagerSignal::Finish);
|
||||
let terminator = lock!(self.terminator).take();
|
||||
terminator.unwrap().await
|
||||
}
|
||||
pub fn get_sender(&self) -> Sender<DownloadManagerSignal> {
|
||||
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
|
||||
33
src-tauri/download_manager/src/downloadable.rs
Normal file
33
src-tauri/download_manager/src/downloadable.rs
Normal 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);
|
||||
}
|
||||
82
src-tauri/download_manager/src/error.rs
Normal file
82
src-tauri/download_manager/src/error.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
24
src-tauri/download_manager/src/frontend_updates.rs
Normal file
24
src-tauri/download_manager/src/frontend_updates.rs
Normal 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,
|
||||
}
|
||||
45
src-tauri/download_manager/src/lib.rs
Normal file
45
src-tauri/download_manager/src/lib.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord)]
|
||||
@@ -22,7 +22,11 @@ impl From<DownloadThreadControlFlag> for bool {
|
||||
/// false => Stop
|
||||
impl From<bool> for DownloadThreadControlFlag {
|
||||
fn from(value: bool) -> Self {
|
||||
if value { DownloadThreadControlFlag::Go } else { DownloadThreadControlFlag::Stop }
|
||||
if value {
|
||||
DownloadThreadControlFlag::Go
|
||||
} else {
|
||||
DownloadThreadControlFlag::Stop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ pub mod download_thread_control_flag;
|
||||
pub mod progress_object;
|
||||
pub mod queue;
|
||||
pub mod rolling_progress_updates;
|
||||
pub mod semaphore;
|
||||
@@ -1,20 +1,20 @@
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
mpsc::Sender,
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProgressObject {
|
||||
max: Arc<Mutex<usize>>,
|
||||
progress_instances: Arc<Mutex<Vec<Arc<AtomicUsize>>>>,
|
||||
@@ -23,7 +23,7 @@ pub struct ProgressObject {
|
||||
//last_update: Arc<RwLock<Instant>>,
|
||||
last_update_time: Arc<AtomicInstant>,
|
||||
bytes_last_update: Arc<AtomicUsize>,
|
||||
rolling: RollingProgressWindow<10>,
|
||||
rolling: RollingProgressWindow<1000>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -44,8 +44,8 @@ impl ProgressHandle {
|
||||
}
|
||||
pub fn add(&self, amount: usize) {
|
||||
self.progress
|
||||
.fetch_add(amount, std::sync::atomic::Ordering::AcqRel);
|
||||
calculate_update(&self.progress_object);
|
||||
.fetch_add(amount, std::sync::atomic::Ordering::AcqRel);
|
||||
tauri::async_runtime::spawn(calculate_update(self.progress_object.clone()));
|
||||
}
|
||||
pub fn skip(&self, amount: usize) {
|
||||
self.progress
|
||||
@@ -74,12 +74,10 @@ impl ProgressObject {
|
||||
}
|
||||
|
||||
pub fn set_time_now(&self) {
|
||||
*self.start.lock().unwrap() = Instant::now();
|
||||
*lock!(self.start) = Instant::now();
|
||||
}
|
||||
pub fn sum(&self) -> usize {
|
||||
self.progress_instances
|
||||
.lock()
|
||||
.unwrap()
|
||||
lock!(self.progress_instances)
|
||||
.iter()
|
||||
.map(|instance| instance.load(Ordering::Acquire))
|
||||
.sum()
|
||||
@@ -88,39 +86,42 @@ impl ProgressObject {
|
||||
self.set_time_now();
|
||||
self.bytes_last_update.store(0, Ordering::Release);
|
||||
self.rolling.reset();
|
||||
self.progress_instances
|
||||
.lock()
|
||||
.unwrap()
|
||||
lock!(self.progress_instances)
|
||||
.iter()
|
||||
.for_each(|x| x.store(0, Ordering::SeqCst));
|
||||
}
|
||||
pub fn get_max(&self) -> usize {
|
||||
*self.max.lock().unwrap()
|
||||
*lock!(self.max)
|
||||
}
|
||||
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) {
|
||||
*self.progress_instances.lock().unwrap() =
|
||||
*lock!(self.progress_instances) =
|
||||
(0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect();
|
||||
}
|
||||
pub fn get_progress(&self) -> f64 {
|
||||
self.sum() as f64 / self.get_max() as f64
|
||||
}
|
||||
pub fn get(&self, index: usize) -> Arc<AtomicUsize> {
|
||||
self.progress_instances.lock().unwrap()[index].clone()
|
||||
lock!(self.progress_instances)[index].clone()
|
||||
}
|
||||
fn update_window(&self, kilobytes_per_second: usize) {
|
||||
self.rolling.update(kilobytes_per_second);
|
||||
}
|
||||
}
|
||||
|
||||
#[throttle(1, Duration::from_millis(20))]
|
||||
pub fn calculate_update(progress: &ProgressObject) {
|
||||
pub async fn calculate_update(progress: Arc<ProgressObject>) {
|
||||
let last_update_time = progress
|
||||
.last_update_time
|
||||
.swap(Instant::now(), Ordering::SeqCst);
|
||||
let time_since_last_update = Instant::now().duration_since(last_update_time).as_millis();
|
||||
.load(Ordering::SeqCst);
|
||||
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 max = progress.get_max();
|
||||
@@ -128,38 +129,32 @@ pub fn calculate_update(progress: &ProgressObject) {
|
||||
.bytes_last_update
|
||||
.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
|
||||
|
||||
progress.update_window(kilobytes_per_second);
|
||||
push_update(progress, bytes_remaining);
|
||||
progress.update_window(kilobytes_per_second as usize);
|
||||
push_update(&progress, bytes_remaining).await;
|
||||
}
|
||||
|
||||
#[throttle(1, Duration::from_millis(500))]
|
||||
pub fn push_update(progress: &ProgressObject, bytes_remaining: usize) {
|
||||
pub async fn push_update(progress: &ProgressObject, bytes_remaining: usize) {
|
||||
let average_speed = progress.rolling.get_average();
|
||||
let time_remaining = (bytes_remaining / 1000) / average_speed.max(1);
|
||||
|
||||
update_ui(progress, average_speed, time_remaining);
|
||||
update_queue(progress);
|
||||
update_ui(progress, average_speed, time_remaining).await;
|
||||
update_queue(progress).await;
|
||||
}
|
||||
|
||||
fn update_ui(progress_object: &ProgressObject, kilobytes_per_second: usize, time_remaining: usize) {
|
||||
progress_object
|
||||
.sender
|
||||
.send(DownloadManagerSignal::UpdateUIStats(
|
||||
kilobytes_per_second,
|
||||
time_remaining,
|
||||
))
|
||||
.unwrap();
|
||||
async fn update_ui(progress_object: &ProgressObject, kilobytes_per_second: usize, time_remaining: usize) {
|
||||
send!(
|
||||
progress_object.sender,
|
||||
DownloadManagerSignal::UpdateUIStats(kilobytes_per_second, time_remaining)
|
||||
);
|
||||
}
|
||||
|
||||
fn update_queue(progress: &ProgressObject) {
|
||||
progress
|
||||
.sender
|
||||
.send(DownloadManagerSignal::UpdateUIQueue)
|
||||
.unwrap();
|
||||
async fn update_queue(progress: &ProgressObject) {
|
||||
send!(progress.sender, DownloadManagerSignal::UpdateUIQueue)
|
||||
}
|
||||
@@ -3,9 +3,10 @@ use std::{
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
use crate::database::models::data::DownloadableMetadata;
|
||||
use database::DownloadableMetadata;
|
||||
use utils::lock;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Queue {
|
||||
inner: Arc<Mutex<VecDeque<DownloadableMetadata>>>,
|
||||
}
|
||||
@@ -24,10 +25,10 @@ impl Queue {
|
||||
}
|
||||
}
|
||||
pub fn read(&self) -> VecDeque<DownloadableMetadata> {
|
||||
self.inner.lock().unwrap().clone()
|
||||
lock!(self.inner).clone()
|
||||
}
|
||||
pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> {
|
||||
self.inner.lock().unwrap()
|
||||
lock!(self.inner)
|
||||
}
|
||||
pub fn pop_front(&self) -> Option<DownloadableMetadata> {
|
||||
self.edit().pop_front()
|
||||
@@ -1,13 +1,19 @@
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RollingProgressWindow<const S: usize> {
|
||||
window: Arc<[AtomicUsize; S]>,
|
||||
current: Arc<AtomicUsize>,
|
||||
}
|
||||
impl<const S: usize> Default for RollingProgressWindow<S> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<const S: usize> RollingProgressWindow<S> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -22,17 +28,22 @@ impl<const S: usize> RollingProgressWindow<S> {
|
||||
}
|
||||
pub fn get_average(&self) -> usize {
|
||||
let current = self.current.load(Ordering::SeqCst);
|
||||
self.window
|
||||
let valid = self
|
||||
.window
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i < ¤t)
|
||||
.map(|(_, x)| x.load(Ordering::Acquire))
|
||||
.sum::<usize>()
|
||||
/ S
|
||||
.collect::<Vec<usize>>();
|
||||
let amount = valid.len();
|
||||
let sum = valid.into_iter().sum::<usize>();
|
||||
|
||||
sum / amount
|
||||
}
|
||||
pub fn reset(&self) {
|
||||
self.window
|
||||
.iter()
|
||||
.for_each(|x| x.store(0, Ordering::Release));
|
||||
self.current.store(0, Ordering::Release);
|
||||
}
|
||||
}
|
||||
28
src-tauri/download_manager/src/util/semaphore.rs
Normal file
28
src-tauri/download_manager/src/util/semaphore.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
src-tauri/games/Cargo.toml
Normal file
37
src-tauri/games/Cargo.toml
Normal 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" }
|
||||
24
src-tauri/games/src/collections/collection.rs
Normal file
24
src-tauri/games/src/collections/collection.rs
Normal 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,
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
pub mod collection;
|
||||
pub mod commands;
|
||||
531
src-tauri/games/src/downloads/download_agent.rs
Normal file
531
src-tauri/games/src/downloads/download_agent.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
146
src-tauri/games/src/downloads/download_logic.rs
Normal file
146
src-tauri/games/src/downloads/download_logic.rs
Normal 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)
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
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 native_model::{Decode, Encode};
|
||||
use utils::lock;
|
||||
|
||||
pub type DropData = v1::DropData;
|
||||
|
||||
pub static DROP_DATA_PATH: &str = ".dropdata";
|
||||
pub static DROPDATA_PATH: &str = ".dropdata";
|
||||
|
||||
pub mod v1 {
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Mutex};
|
||||
|
||||
use database::platform::Platform;
|
||||
use native_model::native_model;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -20,16 +26,18 @@ pub mod v1 {
|
||||
pub struct DropData {
|
||||
pub game_id: String,
|
||||
pub game_version: String,
|
||||
pub target_platform: Platform,
|
||||
pub contexts: Mutex<HashMap<String, bool>>,
|
||||
pub base_path: PathBuf,
|
||||
}
|
||||
|
||||
impl DropData {
|
||||
pub fn new(game_id: String, game_version: String, base_path: PathBuf) -> Self {
|
||||
pub fn new(game_id: String, game_version: String, target_platform: Platform, base_path: PathBuf) -> Self {
|
||||
Self {
|
||||
base_path,
|
||||
game_id,
|
||||
game_version,
|
||||
target_platform,
|
||||
contexts: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
@@ -37,19 +45,24 @@ pub mod v1 {
|
||||
}
|
||||
|
||||
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) {
|
||||
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> {
|
||||
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();
|
||||
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) {
|
||||
let manifest_raw = match native_model::rmp_serde_1_3::RmpSerde::encode(&self) {
|
||||
@@ -57,7 +70,7 @@ impl DropData {
|
||||
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,
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
@@ -71,20 +84,15 @@ impl DropData {
|
||||
}
|
||||
}
|
||||
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) {
|
||||
self.contexts.lock().unwrap().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()
|
||||
lock!(self.contexts).entry(context).insert_entry(state);
|
||||
}
|
||||
pub fn get_contexts(&self) -> HashMap<String, bool> {
|
||||
self.contexts.lock().unwrap().clone()
|
||||
lock!(self.contexts).clone()
|
||||
}
|
||||
}
|
||||
29
src-tauri/games/src/downloads/error.rs
Normal file
29
src-tauri/games/src/downloads/error.rs
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
46
src-tauri/games/src/downloads/manifest.rs
Normal file
46
src-tauri/games/src/downloads/manifest.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
pub mod commands;
|
||||
pub mod download_agent;
|
||||
mod download_logic;
|
||||
pub mod drop_data;
|
||||
pub mod error;
|
||||
mod manifest;
|
||||
pub mod validate;
|
||||
pub mod utils;
|
||||
@@ -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 crate::error::application_download_error::ApplicationDownloadError;
|
||||
|
||||
pub fn get_disk_available(mount_point: PathBuf) -> Result<u64, ApplicationDownloadError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Err(ApplicationDownloadError::IoError(io::Error::other(
|
||||
"could not find disk of path",
|
||||
).kind()))
|
||||
Err(ApplicationDownloadError::IoError(Arc::new(
|
||||
io::Error::other("could not find disk of path"),
|
||||
)))
|
||||
}
|
||||
8
src-tauri/games/src/lib.rs
Normal file
8
src-tauri/games/src/lib.rs
Normal 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;
|
||||
302
src-tauri/games/src/library.rs
Normal file
302
src-tauri/games/src/library.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
use std::fs;
|
||||
|
||||
use database::{DownloadType, DownloadableMetadata, borrow_db_mut_checked};
|
||||
use log::warn;
|
||||
|
||||
use crate::{
|
||||
database::{
|
||||
db::borrow_db_mut_checked,
|
||||
models::data::v1::{DownloadType, DownloadableMetadata},
|
||||
},
|
||||
games::{
|
||||
downloads::drop_data::{v1::DropData, DROP_DATA_PATH},
|
||||
library::set_partially_installed_db,
|
||||
},
|
||||
downloads::drop_data::{DROPDATA_PATH, DropData},
|
||||
library::set_partially_installed_db,
|
||||
};
|
||||
|
||||
pub fn scan_install_dirs() {
|
||||
@@ -20,15 +15,15 @@ pub fn scan_install_dirs() {
|
||||
continue;
|
||||
};
|
||||
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() {
|
||||
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 {
|
||||
warn!(
|
||||
".dropdata exists for {}, but couldn't read it. is it corrupted?",
|
||||
game.file_name().into_string().unwrap()
|
||||
game.file_name().display()
|
||||
);
|
||||
continue;
|
||||
};
|
||||
@@ -38,7 +33,8 @@ pub fn scan_install_dirs() {
|
||||
|
||||
let metadata = DownloadableMetadata::new(
|
||||
drop_data.game_id,
|
||||
Some(drop_data.game_version),
|
||||
drop_data.game_version,
|
||||
drop_data.target_platform,
|
||||
DownloadType::Game,
|
||||
);
|
||||
set_partially_installed_db(
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::database::models::data::{ApplicationTransientStatus, Database, GameDownloadStatus};
|
||||
use database::models::data::{
|
||||
ApplicationTransientStatus, Database, DownloadType, GameDownloadStatus,
|
||||
};
|
||||
|
||||
pub type GameStatusWithTransient = (
|
||||
Option<GameDownloadStatus>,
|
||||
@@ -8,10 +10,14 @@ pub struct GameStatusManager {}
|
||||
|
||||
impl GameStatusManager {
|
||||
pub fn fetch_state(game_id: &String, database: &Database) -> GameStatusWithTransient {
|
||||
let online_state = match database.applications.installed_game_version.get(game_id) {
|
||||
Some(meta) => database.applications.transient_statuses.get(meta).cloned(),
|
||||
None => None,
|
||||
};
|
||||
let online_state = database
|
||||
.applications
|
||||
.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();
|
||||
|
||||
if online_state.is_some() {
|
||||
20
src-tauri/process/Cargo.toml
Normal file
20
src-tauri/process/Cargo.toml
Normal 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" }
|
||||
50
src-tauri/process/src/error.rs
Normal file
50
src-tauri/process/src/error.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,13 @@ pub struct 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 map: HashMap<&'static str, String> = HashMap::new();
|
||||
|
||||
@@ -17,6 +23,10 @@ impl DropFormatArgs {
|
||||
map.insert("dir", working_dir.to_string());
|
||||
map.insert("exe", executable_name.to_string());
|
||||
map.insert("abs_exe", absolute_executable_name);
|
||||
|
||||
if let Some(original) = original {
|
||||
map.insert("executor", original);
|
||||
}
|
||||
|
||||
Self { positional, map }
|
||||
}
|
||||
43
src-tauri/process/src/lib.rs
Normal file
43
src-tauri/process/src/lib.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src-tauri/process/src/parser.rs
Normal file
50
src-tauri/process/src/parser.rs
Normal 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);
|
||||
131
src-tauri/process/src/process_handlers.rs
Normal file
131
src-tauri/process/src/process_handlers.rs
Normal 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
|
||||
}
|
||||
}
|
||||
524
src-tauri/process/src/process_manager.rs
Normal file
524
src-tauri/process/src/process_manager.rs
Normal 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_parameters.0));
|
||||
|
||||
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;
|
||||
}
|
||||
34
src-tauri/remote/Cargo.toml
Normal file
34
src-tauri/remote/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "remote"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.89"
|
||||
bitcode = "0.6.7"
|
||||
bytes = "1.11.0"
|
||||
chrono = "0.4.42"
|
||||
client = { path = "../client", version = "0.1.0" }
|
||||
database = { path = "../database", version = "0.1.0" }
|
||||
droplet-rs = "0.7.3"
|
||||
gethostname = "1.0.2"
|
||||
hex = "0.4.3"
|
||||
http = "1.3.1"
|
||||
log = "0.4.28"
|
||||
md5 = "0.8.0"
|
||||
reqwest = { version = "0.12.28", default-features = false, features = [
|
||||
"blocking",
|
||||
"http2",
|
||||
"json",
|
||||
"native-tls-alpn",
|
||||
"rustls-tls",
|
||||
"rustls-tls-native-roots",
|
||||
"stream",
|
||||
] }
|
||||
reqwest-middleware = { version = "0.4.2", features = ["json"] }
|
||||
reqwest-websocket = "0.5.1"
|
||||
serde = "1.0.228"
|
||||
serde_with = "3.15.0"
|
||||
tauri = "*"
|
||||
url = "2.5.7"
|
||||
utils = { path = "../utils", version = "0.1.0" }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user