forked from Drop-OSS/archived-drop-app
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
124d51bced |
72
.github/workflows/release.yml
vendored
72
.github/workflows/release.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: "publish"
|
name: 'publish'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
@@ -18,16 +18,16 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: "macos-14" # for Arm based macs (M1 and above).
|
- platform: 'macos-latest' # for Arm based macs (M1 and above).
|
||||||
args: "--target aarch64-apple-darwin"
|
args: '--target aarch64-apple-darwin'
|
||||||
- platform: "macos-14" # for Intel based macs.
|
- platform: 'macos-latest' # for Intel based macs.
|
||||||
args: "--target x86_64-apple-darwin"
|
args: '--target x86_64-apple-darwin'
|
||||||
- platform: "ubuntu-22.04" # for Tauri v1 you could replace this with ubuntu-20.04.
|
- platform: 'ubuntu-22.04' # for Tauri v1 you could replace this with ubuntu-20.04.
|
||||||
args: ""
|
args: ''
|
||||||
- platform: "ubuntu-22.04-arm"
|
- platform: 'ubuntu-22.04-arm'
|
||||||
args: "--target aarch64-unknown-linux-gnu"
|
args: '--target aarch64-unknown-linux-gnu'
|
||||||
- platform: "windows-latest"
|
- platform: 'windows-latest'
|
||||||
args: ""
|
args: ''
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
steps:
|
steps:
|
||||||
@@ -36,39 +36,27 @@ jobs:
|
|||||||
submodules: true
|
submodules: true
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: setup node
|
- name: setup node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
|
|
||||||
- name: install Rust nightly
|
- name: install Rust nightly
|
||||||
uses: dtolnay/rust-toolchain@nightly
|
uses: dtolnay/rust-toolchain@nightly
|
||||||
with:
|
with:
|
||||||
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
|
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
|
||||||
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: './src-tauri -> target'
|
|
||||||
|
|
||||||
- name: install dependencies (ubuntu only)
|
- name: install dependencies (ubuntu only)
|
||||||
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm' # This must match the platform value defined above.
|
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm' # This must match the platform value defined above.
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
# webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
|
# webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
|
||||||
|
|
||||||
|
|
||||||
- name: Import Apple Developer Certificate
|
- name: Import Apple Developer Certificate
|
||||||
if: matrix.platform == 'macos-14'
|
if: matrix.platform == 'macos-latest'
|
||||||
env:
|
env:
|
||||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
@@ -79,30 +67,18 @@ jobs:
|
|||||||
security default-keychain -s build.keychain
|
security default-keychain -s build.keychain
|
||||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
security set-keychain-settings -t 3600 -u build.keychain
|
security set-keychain-settings -t 3600 -u build.keychain
|
||||||
|
|
||||||
|
curl https://droposs.org/drop.crt --output drop.pem
|
||||||
echo "Created keychain"
|
|
||||||
|
|
||||||
curl https://droposs.org/drop.der --output drop.der
|
|
||||||
|
|
||||||
# swiftc libs/appletrust/add-certificate.swift
|
|
||||||
# ./add-certificate drop.der
|
|
||||||
# rm add-certificate
|
|
||||||
|
|
||||||
# echo "Added certificate to keychain using swift util"
|
|
||||||
|
|
||||||
## Script is equivalent to:
|
|
||||||
sudo security authorizationdb write com.apple.trust-settings.admin allow
|
sudo security authorizationdb write com.apple.trust-settings.admin allow
|
||||||
sudo security add-trusted-cert -d -r trustRoot -k build.keychain -p codeSign -u -1 drop.der
|
sudo security add-trusted-cert -d -r trustRoot -k build.keychain -p codeSign -u -1 drop.pem
|
||||||
sudo security authorizationdb remove com.apple.trust-settings.admin
|
sudo security authorizationdb remove com.apple.trust-settings.admin
|
||||||
|
|
||||||
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||||
echo "Imported certificate"
|
|
||||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
security find-identity -v -p codesigning build.keychain
|
security find-identity -v -p codesigning build.keychain
|
||||||
|
|
||||||
- name: Verify Certificate
|
- name: Verify Certificate
|
||||||
if: matrix.platform == 'macos-14'
|
if: matrix.platform == 'macos-latest'
|
||||||
run: |
|
run: |
|
||||||
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Drop OSS")
|
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Drop OSS")
|
||||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||||
@@ -110,19 +86,19 @@ jobs:
|
|||||||
echo "Certificate imported. Using identity: $CERT_ID"
|
echo "Certificate imported. Using identity: $CERT_ID"
|
||||||
|
|
||||||
- name: install frontend dependencies
|
- name: install frontend dependencies
|
||||||
run: pnpm install # change this to npm, pnpm or bun depending on which one you use.
|
run: yarn install # change this to npm, pnpm or bun depending on which one you use.
|
||||||
|
|
||||||
- uses: tauri-apps/tauri-action@v0
|
- uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
|
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
|
||||||
NO_STRIP: true
|
NO_STRIP: true
|
||||||
with:
|
with:
|
||||||
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
|
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
|
||||||
releaseName: "Auto-release v__VERSION__"
|
releaseName: 'Auto-release v__VERSION__'
|
||||||
releaseBody: "See the assets to download this version and install. This release was created automatically."
|
releaseBody: 'See the assets to download this version and install. This release was created automatically.'
|
||||||
releaseDraft: false
|
releaseDraft: false
|
||||||
prerelease: true
|
prerelease: true
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -29,6 +29,4 @@ src-tauri/flamegraph.svg
|
|||||||
src-tauri/perf*
|
src-tauri/perf*
|
||||||
|
|
||||||
/*.AppImage
|
/*.AppImage
|
||||||
/squashfs-root
|
/squashfs-root
|
||||||
|
|
||||||
/target/
|
|
||||||
2
.gitlab-ci-local/.gitignore
vendored
Normal file
2
.gitlab-ci-local/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
24
README.md
24
README.md
@@ -1,21 +1,29 @@
|
|||||||
# Drop Desktop Client
|
# Drop App
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Internals
|
## 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)
|
||||||
|
|
||||||
It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI.
|
## Current features
|
||||||
|
Currently supported are the following features:
|
||||||
|
- Signin (with custom server)
|
||||||
|
- Database registering & recovery
|
||||||
|
- Dynamic library fetching from server
|
||||||
|
- Installing & uninstalling games
|
||||||
|
- Download progress monitoring
|
||||||
|
- Launching / playing games
|
||||||
|
|
||||||
## Development
|
## 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).
|
|
||||||
|
|
||||||
Then, install dependencies with `yarn`. This'll install the custom builder's dependencies. Then, check everything works properly with `yarn tauri build`.
|
Install dependencies with `yarn`
|
||||||
|
|
||||||
Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use shell script `./nvidia-prop-dev.sh`
|
Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use shell script `./nvidia-prop-dev.sh`
|
||||||
|
|
||||||
To manually specify the logging level, add the environment variable `RUST_LOG=[debug, info, warn, error]` to `yarn tauri dev`:
|
To manually specify the logging level, add the environment variable `RUST_LOG=[debug, info, warn, error]` to `yarn tauri dev`:
|
||||||
|
|
||||||
e.g. `RUST_LOG=debug yarn tauri dev`
|
e.g. `RUST_LOG=debug yarn tauri dev`
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
Check out the contributing guide on our Developer Docs: [Drop Developer Docs - Contributing](https://developer.droposs.org/contributing).
|
Check the original [Drop repo](https://github.com/Drop-OSS/drop/blob/main/CONTRIBUTING.md) for contributing guidelines.
|
||||||
11
build.mjs
11
build.mjs
@@ -21,13 +21,6 @@ async function spawn(exec, opts) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedLibs = ["drop-base/package.json"];
|
|
||||||
|
|
||||||
for (const lib of expectedLibs) {
|
|
||||||
const path = `./libs/${lib}`;
|
|
||||||
if (!fs.existsSync(path)) throw `Missing "${expectedLibs}". Run "git submodule update --init --recursive"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const views = fs.readdirSync(".").filter((view) => {
|
const views = fs.readdirSync(".").filter((view) => {
|
||||||
const expectedPath = `./${view}/package.json`;
|
const expectedPath = `./${view}/package.json`;
|
||||||
return fs.existsSync(expectedPath);
|
return fs.existsSync(expectedPath);
|
||||||
@@ -40,10 +33,10 @@ for (const view of views) {
|
|||||||
process.chdir(`./${view}`);
|
process.chdir(`./${view}`);
|
||||||
|
|
||||||
loggerChild.info(`Install deps for "${view}"`);
|
loggerChild.info(`Install deps for "${view}"`);
|
||||||
await spawn("pnpm install");
|
await spawn("yarn");
|
||||||
|
|
||||||
loggerChild.info(`Building "${view}"`);
|
loggerChild.info(`Building "${view}"`);
|
||||||
await spawn("pnpm run build", {
|
await spawn("yarn build", {
|
||||||
env: { ...process.env, NUXT_APP_BASE_URL: `/${view}/` },
|
env: { ...process.env, NUXT_APP_BASE_URL: `/${view}/` },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
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)
|
|
||||||
Submodule libs/drop-base updated: 14f4e3e20b...04125e89be
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLoadingIndicator color="#2563eb" />
|
<LoadingIndicator />
|
||||||
<NuxtLayout class="select-none w-screen h-screen">
|
<NuxtLayout class="select-none w-screen h-screen">
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
<ModalStack />
|
<ModalStack />
|
||||||
@@ -15,8 +15,6 @@ import {
|
|||||||
initialNavigation,
|
initialNavigation,
|
||||||
setupHooks,
|
setupHooks,
|
||||||
} from "./composables/state-navigation.js";
|
} from "./composables/state-navigation.js";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import type { AppState } from "./types.js";
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -38,8 +36,9 @@ async function fetchState() {
|
|||||||
}
|
}
|
||||||
await fetchState();
|
await fetchState();
|
||||||
|
|
||||||
listen("update_state", (event) => {
|
// This is inefficient but apparently we do it lol
|
||||||
state.value = event.payload as AppState;
|
router.beforeEach(async () => {
|
||||||
|
await fetchState();
|
||||||
});
|
});
|
||||||
|
|
||||||
setupHooks();
|
setupHooks();
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ html,
|
|||||||
body {
|
body {
|
||||||
-ms-overflow-style: none; /* IE and Edge /
|
-ms-overflow-style: none; /* IE and Edge /
|
||||||
scrollbar-width: none; / Firefox */
|
scrollbar-width: none; / Firefox */
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
<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>
|
</NuxtLink>
|
||||||
<div class="h-0.5 rounded-full w-full bg-zinc-800" />
|
<div class="h-0.5 rounded-full w-full bg-zinc-800" />
|
||||||
<div class="flex flex-col mb-1">
|
<div class="flex flex-col mb-1">
|
||||||
<MenuItem v-if="state.user.admin" v-slot="{ active }">
|
<MenuItem v-slot="{ active }">
|
||||||
<a
|
<a
|
||||||
:href="adminUrl"
|
:href="adminUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
<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>
|
<template>
|
||||||
<div class="flex flex-col h-full">
|
<div>
|
||||||
<div class="mb-3 inline-flex gap-x-2">
|
<div class="mb-3 inline-flex gap-x-2">
|
||||||
<div
|
<div
|
||||||
class="relative transition-transform duration-300 hover:scale-105 active:scale-95"
|
class="relative transition-transform duration-300 hover:scale-105 active:scale-95"
|
||||||
@@ -20,134 +20,72 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="() => calculateGames(true, true)"
|
@click="() => calculateGames(true)"
|
||||||
class="p-1 flex items-center justify-center transition-transform duration-300 size-10 hover:scale-110 active:scale-90 rounded-lg bg-zinc-800/50 text-zinc-100"
|
class="p-1 flex items-center justify-center transition-transform duration-300 size-10 hover:scale-110 active:scale-90 rounded-lg bg-zinc-800/50 text-zinc-100"
|
||||||
>
|
>
|
||||||
<ArrowPathIcon class="size-4" />
|
<ArrowPathIcon class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5 h-full">
|
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5">
|
||||||
<Disclosure
|
<NuxtLink
|
||||||
as="div"
|
v-for="nav in filteredNavigation"
|
||||||
v-for="(nav, navIndex) in filteredNavigation"
|
|
||||||
:key="nav.id"
|
:key="nav.id"
|
||||||
:class="['first:pt-0 last:pb-0', nav.tools ? 'mt-auto' : '']"
|
:class="[
|
||||||
v-slot="{ open }"
|
'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',
|
||||||
:default-open="nav.deft"
|
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"
|
||||||
>
|
>
|
||||||
<dt>
|
<div class="flex items-center w-full gap-x-3">
|
||||||
<DisclosureButton
|
<div
|
||||||
class="flex w-full items-center justify-between text-left text-gray-900 dark:text-white"
|
class="flex-none transition-transform duration-300 hover:-rotate-2"
|
||||||
>
|
>
|
||||||
<span class="text-sm font-semibold font-display">{{
|
<img
|
||||||
nav.name
|
class="size-8 object-cover bg-zinc-900 rounded-lg transition-all duration-300 shadow-sm"
|
||||||
}}</span>
|
:src="icons[nav.id]"
|
||||||
<span class="ml-6 relative flex size-4">
|
alt=""
|
||||||
<MinusIcon class="absolute inset-0 size-4" aria-hidden="true" />
|
/>
|
||||||
<MinusIcon
|
</div>
|
||||||
:class="[ !open ? 'rotate-90' : 'rotate-0', 'transition-all absolute inset-0 size-4']"
|
<div class="flex flex-col flex-1">
|
||||||
aria-hidden="true"
|
<p
|
||||||
/>
|
class="truncate text-xs font-display leading-5 flex-1 font-semibold"
|
||||||
</span>
|
>
|
||||||
</DisclosureButton>
|
{{ nav.label }}
|
||||||
</dt>
|
</p>
|
||||||
<DisclosurePanel as="dd" class="mt-2 flex flex-col gap-y-1.5">
|
<p
|
||||||
<NuxtLink
|
class="text-xs font-medium"
|
||||||
v-for="item in nav.items"
|
:class="[gameStatusTextStyle[games[nav.id].status.value.type]]"
|
||||||
:key="nav.id"
|
>
|
||||||
:class="[
|
{{ gameStatusText[games[nav.id].status.value.type] }}
|
||||||
'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',
|
</p>
|
||||||
currentNavigation == item.id
|
</div>
|
||||||
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
|
</div>
|
||||||
: item.isInstalled.value
|
</NuxtLink>
|
||||||
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
|
|
||||||
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
|
|
||||||
]"
|
|
||||||
:href="item.route"
|
|
||||||
>
|
|
||||||
<div class="flex items-center w-full gap-x-2">
|
|
||||||
<div
|
|
||||||
class="flex-none transition-transform duration-300 hover:-rotate-2"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
class="size-6 object-cover bg-zinc-900 rounded transition-all duration-300 shadow-sm"
|
|
||||||
:src="useObject(item.icon)"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="truncate inline-flex items-center gap-x-2">
|
|
||||||
<p class="text-sm whitespace-nowrap font-display font-semibold">
|
|
||||||
{{ item.label }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="truncate text-[10px] font-bold uppercase font-display"
|
|
||||||
:class="[
|
|
||||||
gameStatusTextStyle[games[item.id].status.value.type],
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ gameStatusText[games[item.id].status.value.type] }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</DisclosurePanel>
|
|
||||||
</Disclosure>
|
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="h-full grow flex p-8 justify-center text-zinc-100"
|
|
||||||
>
|
|
||||||
<div role="status">
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="w-6 h-6 text-transparent animate-spin fill-zinc-600"
|
|
||||||
viewBox="0 0 100 101"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
|
||||||
fill="currentFill"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
|
import { ArrowPathIcon, MagnifyingGlassIcon } from "@heroicons/vue/20/solid";
|
||||||
import {
|
|
||||||
ArrowPathIcon,
|
|
||||||
MagnifyingGlassIcon,
|
|
||||||
MinusIcon,
|
|
||||||
PlusIcon,
|
|
||||||
} from "@heroicons/vue/20/solid";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import {
|
import { GameStatusEnum, type Game, type GameStatus } from "~/types";
|
||||||
GameStatusEnum,
|
|
||||||
type Collection as Collection,
|
|
||||||
type Game,
|
|
||||||
type GameStatus,
|
|
||||||
} from "~/types";
|
|
||||||
import { TransitionGroup } from "vue";
|
import { TransitionGroup } from "vue";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
// Style information
|
// Style information
|
||||||
const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
|
const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
|
||||||
[GameStatusEnum.Installed]: "text-green-500",
|
[GameStatusEnum.Installed]: "text-green-500",
|
||||||
[GameStatusEnum.Downloading]: "text-zinc-400",
|
[GameStatusEnum.Downloading]: "text-blue-500",
|
||||||
[GameStatusEnum.Validating]: "text-blue-300",
|
[GameStatusEnum.Validating]: "text-blue-300",
|
||||||
[GameStatusEnum.Running]: "text-blue-500",
|
[GameStatusEnum.Running]: "text-green-500",
|
||||||
[GameStatusEnum.Remote]: "text-zinc-700",
|
[GameStatusEnum.Remote]: "text-zinc-500",
|
||||||
[GameStatusEnum.Queued]: "text-zinc-400",
|
[GameStatusEnum.Queued]: "text-blue-500",
|
||||||
[GameStatusEnum.Updating]: "text-zinc-400",
|
[GameStatusEnum.Updating]: "text-blue-500",
|
||||||
[GameStatusEnum.Uninstalling]: "text-zinc-100",
|
[GameStatusEnum.Uninstalling]: "text-zinc-100",
|
||||||
[GameStatusEnum.SetupRequired]: "text-yellow-500",
|
[GameStatusEnum.SetupRequired]: "text-yellow-500",
|
||||||
[GameStatusEnum.PartiallyInstalled]: "text-gray-400",
|
[GameStatusEnum.PartiallyInstalled]: "text-gray-400",
|
||||||
@@ -169,147 +107,71 @@ const router = useRouter();
|
|||||||
|
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
|
|
||||||
const loading = ref(false);
|
|
||||||
const games: {
|
const games: {
|
||||||
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
|
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
|
||||||
} = {};
|
} = {};
|
||||||
|
const icons: { [key: string]: string } = {};
|
||||||
|
|
||||||
const collections: Ref<Collection[]> = ref([]);
|
const rawGames: Ref<Game[], Game[]> = ref([]);
|
||||||
|
|
||||||
async function calculateGames(clearAll = false, forceRefresh = false) {
|
async function calculateGames(clearAll = false) {
|
||||||
try {
|
if (clearAll) rawGames.value = [];
|
||||||
await calculateGamesLogic(clearAll, forceRefresh);
|
|
||||||
} catch (e) {
|
|
||||||
createModal(
|
|
||||||
ModalType.Notification,
|
|
||||||
{
|
|
||||||
title: "Failed to fetch library",
|
|
||||||
description: `Drop encountered an error while fetching your library: ${e}`,
|
|
||||||
},
|
|
||||||
(_, c) => c(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FetchLibraryResponse = {
|
|
||||||
library: Game[];
|
|
||||||
collections: Collection[];
|
|
||||||
other: Game[];
|
|
||||||
};
|
|
||||||
|
|
||||||
async function calculateGamesLogic(clearAll = false, forceRefresh = false) {
|
|
||||||
if (clearAll) {
|
|
||||||
collections.value = [];
|
|
||||||
loading.value = true;
|
|
||||||
}
|
|
||||||
// If we update immediately, the navigation gets re-rendered before we
|
// If we update immediately, the navigation gets re-rendered before we
|
||||||
// add all the necessary state, and it freaks tf out
|
// add all the necessary state, and it freaks tf out
|
||||||
const library = await invoke<FetchLibraryResponse>("fetch_library", {
|
const newGames = await invoke<typeof rawGames.value>("fetch_library");
|
||||||
hardRefresh: forceRefresh,
|
for (const game of newGames) {
|
||||||
});
|
|
||||||
const allGames = [
|
|
||||||
...library.library,
|
|
||||||
...library.collections
|
|
||||||
.map((e) => e.entries)
|
|
||||||
.flat()
|
|
||||||
.map((e) => e.game),
|
|
||||||
...library.other,
|
|
||||||
].filter((v, i, a) => a.indexOf(v) === i);
|
|
||||||
|
|
||||||
for (const game of allGames) {
|
|
||||||
if (games[game.id]) continue;
|
if (games[game.id]) continue;
|
||||||
games[game.id] = await useGame(game.id);
|
games[game.id] = await useGame(game.id);
|
||||||
}
|
}
|
||||||
|
for (const game of newGames) {
|
||||||
const libraryCollection = {
|
if (icons[game.id]) continue;
|
||||||
id: "library",
|
icons[game.id] = await useObject(game.mIconObjectId);
|
||||||
name: "Library",
|
}
|
||||||
isDefault: true,
|
rawGames.value = newGames;
|
||||||
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] : []),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait up to 300 ms for the library to load, otherwise
|
await calculateGames();
|
||||||
// show the loading state while we while
|
|
||||||
await new Promise<void>((r) => {
|
|
||||||
let hasResolved = false;
|
|
||||||
const resolveFunc = () => {
|
|
||||||
if (!hasResolved) r();
|
|
||||||
hasResolved = true;
|
|
||||||
};
|
|
||||||
calculateGames(true).then(resolveFunc);
|
|
||||||
setTimeout(resolveFunc, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
const navigation = computed(() =>
|
const navigation = computed(() =>
|
||||||
collections.value.map((collection) => {
|
rawGames.value.map((game) => {
|
||||||
const items = collection.entries.map(({ game }) => {
|
const status = games[game.id].status;
|
||||||
const status = games[game.id].status;
|
|
||||||
|
|
||||||
const isInstalled = computed(
|
const isInstalled = computed(
|
||||||
() => status.value.type != GameStatusEnum.Remote,
|
() =>
|
||||||
);
|
status.value.type == GameStatusEnum.Installed ||
|
||||||
|
status.value.type == GameStatusEnum.SetupRequired
|
||||||
|
);
|
||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
label: game.mName,
|
label: game.mName,
|
||||||
route: `/library/${game.id}`,
|
route: `/library/${game.id}`,
|
||||||
prefix: `/library/${game.id}`,
|
prefix: `/library/${game.id}`,
|
||||||
icon: game.mIconObjectId,
|
isInstalled,
|
||||||
isInstalled,
|
id: game.id,
|
||||||
id: game.id,
|
|
||||||
type: game.type,
|
|
||||||
};
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: collection.id,
|
|
||||||
name: collection.name,
|
|
||||||
deft: collection.isDefault,
|
|
||||||
tools: collection.isTools ?? false,
|
|
||||||
items,
|
|
||||||
};
|
};
|
||||||
}),
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { currentNavigation, recalculateNavigation } = useCurrentNavigationIndex(
|
||||||
|
navigation.value
|
||||||
);
|
);
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const currentNavigation = computed(() => {
|
|
||||||
return route.path.slice("/library/".length);
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredNavigation = computed(() => {
|
const filteredNavigation = computed(() => {
|
||||||
if (!searchQuery.value)
|
if (!searchQuery.value)
|
||||||
return navigation.value.map((e, i) => ({ ...e, index: i }));
|
return navigation.value.map((e, i) => ({ ...e, index: i }));
|
||||||
const query = searchQuery.value.toLowerCase();
|
const query = searchQuery.value.toLowerCase();
|
||||||
return navigation.value
|
return navigation.value
|
||||||
.map((c) => ({
|
.filter((nav) => nav.label.toLowerCase().includes(query))
|
||||||
...c,
|
.map((e, i) => ({ ...e, index: i }));
|
||||||
items: c.items.filter((nav) => nav.label.toLowerCase().includes(query)),
|
|
||||||
}))
|
|
||||||
.filter((e) => e.items.length > 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
listen("update_library", async (event) => {
|
listen("update_library", async (event) => {
|
||||||
console.log("Updating library");
|
console.log("Updating library");
|
||||||
let oldNavigation = currentNavigation.value;
|
let oldNavigation = navigation.value[currentNavigation.value];
|
||||||
await calculateGames(false, true);
|
await calculateGames();
|
||||||
if (oldNavigation !== currentNavigation.value) {
|
recalculateNavigation();
|
||||||
|
if (oldNavigation !== navigation.value[currentNavigation.value]) {
|
||||||
|
console.log("Triggered");
|
||||||
router.push("/library");
|
router.push("/library");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
7
main/components/LoadingIndicator.vue
Normal file
7
main/components/LoadingIndicator.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template></template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const loading = useLoadingIndicator();
|
||||||
|
|
||||||
|
watch(loading.isLoading, console.log);
|
||||||
|
</script>
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowDownTrayIcon, CloudIcon } from "@heroicons/vue/20/solid";
|
import { ArrowDownTrayIcon, CloudIcon } from "@heroicons/vue/20/solid";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
|
|
||||||
async function checkOffline() {
|
|
||||||
const isOffline = await invoke("check_online");
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<div
|
||||||
@click="checkOffline"
|
|
||||||
class="transition inline-flex items-center rounded-sm px-4 py-1.5 bg-zinc-900 text-sm text-zinc-400 gap-x-2"
|
class="transition inline-flex items-center rounded-sm px-4 py-1.5 bg-zinc-900 text-sm text-zinc-400 gap-x-2"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -19,5 +13,5 @@ async function checkOffline() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
Offline
|
Offline
|
||||||
</button>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -32,19 +32,3 @@ listen("update_stats", (event) => {
|
|||||||
const stats = useStatsState();
|
const stats = useStatsState();
|
||||||
stats.value = event.payload as StatsState;
|
stats.value = event.payload as StatsState;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useDownloadHistory = () => useState<Array<number>>('history', () => []);
|
|
||||||
|
|
||||||
export function formatKilobytes(bytes: number): string {
|
|
||||||
const units = ["K", "M", "G", "T", "P"];
|
|
||||||
let value = bytes;
|
|
||||||
let unitIndex = 0;
|
|
||||||
const scalar = 1000;
|
|
||||||
|
|
||||||
while (value >= scalar && unitIndex < units.length - 1) {
|
|
||||||
value /= scalar;
|
|
||||||
unitIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
|
||||||
}
|
|
||||||
@@ -48,13 +48,13 @@ export const useGame = async (gameId: string) => {
|
|||||||
version?: GameVersion;
|
version?: GameVersion;
|
||||||
} = event.payload as any;
|
} = event.payload as any;
|
||||||
gameStatusRegistry[gameId].value = parseStatus(payload.status);
|
gameStatusRegistry[gameId].value = parseStatus(payload.status);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* I am not super happy about this.
|
* I am not super happy about this.
|
||||||
*
|
*
|
||||||
* This will mean that we will still have a version assigned if we have a game installed then uninstall it.
|
* This will mean that we will still have a version assigned if we have a game installed then uninstall it.
|
||||||
* It is necessary because a flag to check if we should overwrite seems excessive, and this function gets called
|
* It is necessary because a flag to check if we should overwrite seems excessive, and this function gets called
|
||||||
* on transient state updates.
|
* on transient state updates.
|
||||||
*/
|
*/
|
||||||
if (payload.version) {
|
if (payload.version) {
|
||||||
gameRegistry[gameId].version = payload.version;
|
gameRegistry[gameId].version = payload.version;
|
||||||
@@ -71,23 +71,3 @@ export const useGame = async (gameId: string) => {
|
|||||||
export type FrontendGameConfiguration = {
|
export type FrontendGameConfiguration = {
|
||||||
launchString: string;
|
launchString: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LaunchResult =
|
|
||||||
| { result: "Success" }
|
|
||||||
| { result: "InstallRequired"; data: [string, string] };
|
|
||||||
|
|
||||||
export type VersionOption = {
|
|
||||||
versionId: string;
|
|
||||||
displayName?: string;
|
|
||||||
versionPath: string;
|
|
||||||
platform: string;
|
|
||||||
size: number;
|
|
||||||
requiredContent: Array<{
|
|
||||||
gameId: string;
|
|
||||||
versionId: string;
|
|
||||||
name: string;
|
|
||||||
iconObjectId: string;
|
|
||||||
shortDescription: string;
|
|
||||||
size: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
export const useObject = (id: string) => {
|
export const useObject = async (id: string) => {
|
||||||
return convertFileSrc(id, "object");
|
return convertFileSrc(id, "object");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export default defineNuxtConfig({
|
|||||||
css: ["~/assets/main.scss"],
|
css: ["~/assets/main.scss"],
|
||||||
|
|
||||||
ssr: false,
|
ssr: false,
|
||||||
devtools: false,
|
|
||||||
|
|
||||||
extends: [["../libs/drop-base"]],
|
extends: [["../libs/drop-base"]],
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "view",
|
"name": "view",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.4",
|
"version": "0.3.2-dl",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt generate",
|
"build": "nuxt generate",
|
||||||
@@ -13,9 +13,7 @@
|
|||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@tauri-apps/api": "^2.7.0",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
|
||||||
"@tauri-apps/plugin-shell": "^2.3.3",
|
|
||||||
"koa": "^2.16.1",
|
"koa": "^2.16.1",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"micromark": "^4.0.1",
|
"micromark": "^4.0.1",
|
||||||
@@ -34,5 +32,6 @@
|
|||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vue-tsc": "^2.2.10"
|
"vue-tsc": "^2.2.10"
|
||||||
}
|
},
|
||||||
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<form class="space-y-6">
|
||||||
<div v-if="versionOptions && versionOptions.length > 0 && currentVersionOption">
|
<div v-if="versionOptions && versionOptions.length > 0">
|
||||||
<Listbox as="div" v-model="installVersionIndex">
|
<Listbox as="div" v-model="installVersionIndex">
|
||||||
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
|
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
|
||||||
>Version</ListboxLabel
|
>Version</ListboxLabel
|
||||||
@@ -181,16 +181,9 @@
|
|||||||
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
|
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
|
||||||
>
|
>
|
||||||
<span class="block truncate"
|
<span class="block truncate"
|
||||||
>{{
|
>{{ versionOptions[installVersionIndex].versionName }}
|
||||||
currentVersionOption.displayName ||
|
|
||||||
currentVersionOption.versionPath
|
|
||||||
}}
|
|
||||||
on
|
on
|
||||||
{{ currentVersionOption.platform }} ({{
|
{{ versionOptions[installVersionIndex].platform }}</span
|
||||||
formatKilobytes(
|
|
||||||
currentVersionOption.size / 1024
|
|
||||||
)
|
|
||||||
}}B)</span
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||||
@@ -213,7 +206,7 @@
|
|||||||
<ListboxOption
|
<ListboxOption
|
||||||
as="template"
|
as="template"
|
||||||
v-for="(version, versionIdx) in versionOptions"
|
v-for="(version, versionIdx) in versionOptions"
|
||||||
:key="version.versionId"
|
:key="version.versionName"
|
||||||
:value="versionIdx"
|
:value="versionIdx"
|
||||||
v-slot="{ active, selected }"
|
v-slot="{ active, selected }"
|
||||||
>
|
>
|
||||||
@@ -230,12 +223,8 @@
|
|||||||
: 'font-normal',
|
: 'font-normal',
|
||||||
'block truncate',
|
'block truncate',
|
||||||
]"
|
]"
|
||||||
>{{ version.displayName || version.versionPath }} on
|
>{{ version.versionName }} on
|
||||||
{{ version.platform }} ({{
|
{{ version.platform }}</span
|
||||||
formatKilobytes(
|
|
||||||
versionOptions[installVersionIndex].size / 1024
|
|
||||||
)
|
|
||||||
}}B)</span
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@@ -254,10 +243,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</Listbox>
|
</Listbox>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else class="mt-1 rounded-md bg-red-600/10 p-4">
|
||||||
v-else-if="versionOptions === null || versionOptions?.length == 0"
|
|
||||||
class="mt-1 rounded-md bg-red-600/10 p-4"
|
|
||||||
>
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||||
@@ -270,108 +256,83 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<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 v-if="installDirs">
|
<div v-if="installDirs">
|
||||||
<InstallDirectorySelector
|
<Listbox as="div" v-model="installDir">
|
||||||
:install-dirs="installDirs"
|
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
|
||||||
v-model="installDir"
|
>Install to</ListboxLabel
|
||||||
/>
|
|
||||||
</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">
|
<div class="relative mt-2">
|
||||||
<img
|
<ListboxButton
|
||||||
class="size-12 flex-none"
|
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"
|
||||||
:src="useObject(content.iconObjectId)"
|
>
|
||||||
alt=""
|
<span class="block truncate">{{
|
||||||
/>
|
installDirs[installDir]
|
||||||
<div class="min-w-0 flex-auto">
|
}}</span>
|
||||||
<p class="text-sm/6 font-semibold text-white">
|
<span
|
||||||
<button
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||||
@click="
|
>
|
||||||
() =>
|
<ChevronUpDownIcon
|
||||||
(installDepsDisabled[content.versionId] =
|
class="h-5 w-5 text-gray-400"
|
||||||
!installDepsDisabled[content.versionId])
|
aria-hidden="true"
|
||||||
"
|
/>
|
||||||
>
|
</span>
|
||||||
<span class="absolute inset-x-0 -top-px bottom-0"></span>
|
</ListboxButton>
|
||||||
{{ content.name }}
|
|
||||||
</button>
|
<transition
|
||||||
</p>
|
leave-active-class="transition ease-in duration-100"
|
||||||
<p class="mt-1 flex text-xs/5 text-gray-400">
|
leave-from-class="opacity-100"
|
||||||
{{ content.shortDescription }}
|
leave-to-class="opacity-0"
|
||||||
</p>
|
>
|
||||||
</div>
|
<ListboxOptions
|
||||||
</div>
|
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"
|
||||||
<div class="flex shrink-0 items-center gap-x-2">
|
>
|
||||||
<div class="hidden sm:flex sm:flex-col sm:items-end">
|
<ListboxOption
|
||||||
<p
|
as="template"
|
||||||
class="inline-flex items-center gap-x-1 text-xs/5 text-gray-400"
|
v-for="(dir, dirIdx) in installDirs"
|
||||||
|
:key="dir"
|
||||||
|
:value="dirIdx"
|
||||||
|
v-slot="{ active, selected }"
|
||||||
>
|
>
|
||||||
{{ formatKilobytes(content.size / 1024) }}B
|
<li
|
||||||
<ServerIcon class="size-3" />
|
:class="[
|
||||||
</p>
|
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
|
||||||
</div>
|
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||||
<CheckIcon
|
]"
|
||||||
v-if="!installDepsDisabled[content.versionId]"
|
>
|
||||||
class="size-5 flex-none text-green-500"
|
<span
|
||||||
aria-hidden="true"
|
:class="[
|
||||||
/>
|
selected
|
||||||
<MinusIcon
|
? 'font-semibold text-zinc-100'
|
||||||
v-else
|
: 'font-normal',
|
||||||
class="size-5 flex-none text-gray-500"
|
'block truncate',
|
||||||
aria-hidden="true"
|
]"
|
||||||
/>
|
>{{ dir }}</span
|
||||||
</div>
|
>
|
||||||
</li>
|
|
||||||
</ul>
|
<span
|
||||||
|
v-if="selected"
|
||||||
|
:class="[
|
||||||
|
active ? 'text-white' : 'text-blue-600',
|
||||||
|
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ListboxOption>
|
||||||
|
</ListboxOptions>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
<div class="text-zinc-400 text-sm mt-2">
|
||||||
|
Add more install directories in
|
||||||
|
<PageWidget to="/settings/downloads">
|
||||||
|
<WrenchIcon class="size-3" />
|
||||||
|
Settings
|
||||||
|
</PageWidget>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<div v-if="installError" class="mt-1 rounded-md bg-red-600/10 p-4">
|
<div v-if="installError" class="mt-1 rounded-md bg-red-600/10 p-4">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
@@ -407,48 +368,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</ModalTemplate>
|
</ModalTemplate>
|
||||||
|
|
||||||
<ModalTemplate :model-value="launchOptionsOpen">
|
|
||||||
<template #default>
|
|
||||||
<div class="sm:flex sm:items-start">
|
|
||||||
<div class="mt-3 text-center sm:mt-0 sm:text-left">
|
|
||||||
<h3 class="text-base font-semibold text-zinc-100">
|
|
||||||
Launch {{ game.mName }}
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2">
|
|
||||||
<p class="text-sm text-zinc-400">
|
|
||||||
The instance admin has configured multiple ways to start this
|
|
||||||
game. Select an option to start.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol class="space-y-2">
|
|
||||||
<li v-for="(launchData, launchIdx) in launchOptions!">
|
|
||||||
<button
|
|
||||||
class="transition w-full rounded-sm bg-zinc-800 inline-flex items-center text-sm py-2 px-3 gap-x-2 text-zinc-100 hover:text-zinc-300 hover:bg-zinc-700"
|
|
||||||
@click="() => launchIndex(launchIdx)"
|
|
||||||
>
|
|
||||||
<PlayIcon class="size-4" />
|
|
||||||
<span>
|
|
||||||
{{ launchData.name }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</template>
|
|
||||||
<template #buttons>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
|
||||||
@click="launchOptions = undefined"
|
|
||||||
ref="cancelButtonRef"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</ModalTemplate>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Dear future DecDuck,
|
Dear future DecDuck,
|
||||||
This v-if is necessary for Vue rendering reasons
|
This v-if is necessary for Vue rendering reasons
|
||||||
@@ -527,11 +446,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<DependencyRequiredModal
|
|
||||||
v-if="dependencyRequiredModal"
|
|
||||||
v-model="dependencyRequiredModal"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -551,10 +465,9 @@ import {
|
|||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
ArrowsPointingOutIcon,
|
ArrowsPointingOutIcon,
|
||||||
PhotoIcon,
|
PhotoIcon,
|
||||||
PlayIcon,
|
|
||||||
} from "@heroicons/vue/20/solid";
|
} from "@heroicons/vue/20/solid";
|
||||||
import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline";
|
import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline";
|
||||||
import { MinusIcon, ServerIcon, XCircleIcon } from "@heroicons/vue/24/solid";
|
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { micromark } from "micromark";
|
import { micromark } from "micromark";
|
||||||
import { GameStatusEnum } from "~/types";
|
import { GameStatusEnum } from "~/types";
|
||||||
@@ -583,7 +496,9 @@ const mediaUrls = await Promise.all(
|
|||||||
const htmlDescription = micromark(game.value.mDescription);
|
const htmlDescription = micromark(game.value.mDescription);
|
||||||
|
|
||||||
const installFlowOpen = ref(false);
|
const installFlowOpen = ref(false);
|
||||||
const versionOptions = ref<undefined | Array<VersionOption>>();
|
const versionOptions = ref<
|
||||||
|
undefined | Array<{ versionName: string; platform: string }>
|
||||||
|
>();
|
||||||
const installDirs = ref<undefined | Array<string>>();
|
const installDirs = ref<undefined | Array<string>>();
|
||||||
const currentImageIndex = ref(0);
|
const currentImageIndex = ref(0);
|
||||||
|
|
||||||
@@ -595,13 +510,13 @@ async function installFlow() {
|
|||||||
installDirs.value = undefined;
|
installDirs.value = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
versionOptions.value = await invoke("fetch_game_version_options", {
|
versionOptions.value = await invoke("fetch_game_verion_options", {
|
||||||
gameId: game.value.id,
|
gameId: game.value.id,
|
||||||
});
|
});
|
||||||
|
console.log(versionOptions.value);
|
||||||
installDirs.value = await invoke("fetch_download_dir_stats");
|
installDirs.value = await invoke("fetch_download_dir_stats");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
installError.value = (error as string).toString();
|
installError.value = (error as string).toString();
|
||||||
versionOptions.value = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,31 +524,15 @@ const installLoading = ref(false);
|
|||||||
const installError = ref<string | undefined>();
|
const installError = ref<string | undefined>();
|
||||||
const installVersionIndex = ref(0);
|
const installVersionIndex = ref(0);
|
||||||
const installDir = ref(0);
|
const installDir = ref(0);
|
||||||
const installDepsDisabled = ref<{ [key: string]: boolean }>({});
|
|
||||||
|
|
||||||
const currentVersionOption = computed(() => versionOptions.value?.[installVersionIndex.value]);
|
|
||||||
async function install() {
|
async function install() {
|
||||||
try {
|
try {
|
||||||
if (!versionOptions.value) throw new Error("Versions have not been loaded");
|
if (!versionOptions.value) throw new Error("Versions have not been loaded");
|
||||||
installLoading.value = true;
|
installLoading.value = true;
|
||||||
const versionOption = versionOptions.value[installVersionIndex.value];
|
await invoke("download_game", {
|
||||||
|
gameId: game.value.id,
|
||||||
const games = [
|
gameVersion: versionOptions.value[installVersionIndex.value].versionName,
|
||||||
{ gameId: game.value.id, versionId: versionOption.versionId },
|
installDir: installDir.value,
|
||||||
...versionOption.requiredContent
|
});
|
||||||
.filter((v) => !installDepsDisabled.value[v.versionId])
|
|
||||||
.map((v) => ({ gameId: v.gameId, versionId: v.versionId })),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const game of games) {
|
|
||||||
await invoke("download_game", {
|
|
||||||
gameId: game.gameId,
|
|
||||||
versionId: game.versionId,
|
|
||||||
installDir: installDir.value,
|
|
||||||
targetPlatform: versionOption.platform,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
installFlowOpen.value = false;
|
installFlowOpen.value = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
installError.value = (error as string).toString();
|
installError.value = (error as string).toString();
|
||||||
@@ -650,24 +549,9 @@ async function resumeDownload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const launchOptions = ref<Array<{ name: string }> | undefined>(undefined);
|
|
||||||
const launchOptionsOpen = computed(() => launchOptions.value !== undefined);
|
|
||||||
|
|
||||||
async function launch() {
|
async function launch() {
|
||||||
if (status.value.type == GameStatusEnum.SetupRequired) {
|
|
||||||
await launchIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const fetchedLaunchOptions = await invoke<Array<{ name: string }>>(
|
await invoke("launch_game", { id: game.value.id });
|
||||||
"get_launch_options",
|
|
||||||
{ id: game.value.id }
|
|
||||||
);
|
|
||||||
if (fetchedLaunchOptions.length == 1) {
|
|
||||||
await launchIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
launchOptions.value = fetchedLaunchOptions;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
createModal(
|
createModal(
|
||||||
ModalType.Notification,
|
ModalType.Notification,
|
||||||
@@ -682,36 +566,6 @@ async function launch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dependencyRequiredModal = ref<
|
|
||||||
{ gameId: string; versionId: string } | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
async function launchIndex(index: number) {
|
|
||||||
launchOptions.value = undefined;
|
|
||||||
try {
|
|
||||||
const result = await invoke<LaunchResult>("launch_game", {
|
|
||||||
id: game.value.id,
|
|
||||||
index,
|
|
||||||
});
|
|
||||||
if (result.result == "InstallRequired") {
|
|
||||||
dependencyRequiredModal.value = {
|
|
||||||
gameId: result.data[0],
|
|
||||||
versionId: result.data[1],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
createModal(
|
|
||||||
ModalType.Notification,
|
|
||||||
{
|
|
||||||
title: `Couldn't run "${game.value.mName}"`,
|
|
||||||
description: `Drop failed to launch "${game.value.mName}": ${e}`,
|
|
||||||
buttonText: "Close",
|
|
||||||
},
|
|
||||||
(e, c) => c()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queue() {
|
async function queue() {
|
||||||
router.push("/queue");
|
router.push("/queue");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<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"
|
class="h-16 overflow-hidden relative rounded-xl flex flex-row border border-zinc-900"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 font-display items-left justify-center pl-2"
|
class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 text-blue-400 font-display items-left justify-center pl-2"
|
||||||
>
|
>
|
||||||
<span class="font-bold text-zinc-100">{{ formatKilobytes(stats.speed) }}B/s</span>
|
<span class="font-semibold">{{ formatKilobytes(stats.speed) }}/s</span>
|
||||||
<span class="text-xs text-zinc-400"
|
<span v-if="stats.time > 0" class="text-sm"
|
||||||
>{{ formatTime(stats.time) }} left</span
|
>{{ formatTime(stats.time) }} left</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute inset-0 h-full flex flex-row items-end justify-end space-x-[1px]">
|
<div class="absolute inset-0 h-full flex flex-row items-end justify-end">
|
||||||
<div
|
<div
|
||||||
v-for="bar in speedHistory"
|
v-for="bar in speedHistory"
|
||||||
:style="{ height: `${(bar / speedMax) * 100}%` }"
|
:style="{ height: `${(bar / speedMax) * 100}%` }"
|
||||||
class="w-[3px] bg-blue-600 rounded-t-full"
|
class="w-[8px] bg-blue-600/40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,9 +62,9 @@
|
|||||||
class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display"
|
class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display"
|
||||||
><span class="text-zinc-300">{{
|
><span class="text-zinc-300">{{
|
||||||
formatKilobytes(element.current / 1000)
|
formatKilobytes(element.current / 1000)
|
||||||
}}B</span>
|
}}</span>
|
||||||
/
|
/
|
||||||
<span class="">{{ formatKilobytes(element.max / 1000) }}B</span
|
<span class="">{{ formatKilobytes(element.max / 1000) }}</span
|
||||||
><ServerIcon class="size-5"
|
><ServerIcon class="size-5"
|
||||||
/></span>
|
/></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ServerIcon, XMarkIcon } from "@heroicons/vue/20/solid";
|
import { ServerIcon, XMarkIcon } from "@heroicons/vue/20/solid";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { type DownloadableMetadata, type Game, type GameStatus } from "~/types";
|
import { GameStatusEnum, type DownloadableMetadata, type Game, type GameStatus } from "~/types";
|
||||||
|
|
||||||
// const actionNames = {
|
// const actionNames = {
|
||||||
// [GameStatusEnum.Downloading]: "downloading",
|
// [GameStatusEnum.Downloading]: "downloading",
|
||||||
@@ -105,12 +105,12 @@ window.addEventListener("resize", (event) => {
|
|||||||
|
|
||||||
const queue = useQueueState();
|
const queue = useQueueState();
|
||||||
const stats = useStatsState();
|
const stats = useStatsState();
|
||||||
const speedHistory = useDownloadHistory();
|
const speedHistory = useState<Array<number>>(() => []);
|
||||||
const speedHistoryMax = computed(() => windowWidth.value / 4);
|
const speedHistoryMax = computed(() => windowWidth.value / 8);
|
||||||
const speedMax = computed(
|
const speedMax = computed(
|
||||||
() => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.1
|
() => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.3
|
||||||
);
|
);
|
||||||
const previousGameId = useState<string | undefined>('previous_game');
|
const previousGameId = ref<string | undefined>();
|
||||||
|
|
||||||
const games: Ref<{
|
const games: Ref<{
|
||||||
[key: string]: { game: Game; status: Ref<GameStatus>; cover: string };
|
[key: string]: { game: Game; status: Ref<GameStatus>; cover: string };
|
||||||
@@ -122,15 +122,14 @@ function resetHistoryGraph() {
|
|||||||
}
|
}
|
||||||
function checkReset(v: QueueState) {
|
function checkReset(v: QueueState) {
|
||||||
const currentGame = v.queue.at(0)?.meta.id;
|
const currentGame = v.queue.at(0)?.meta.id;
|
||||||
// If we don't have a game
|
|
||||||
if (!currentGame) return;
|
|
||||||
|
|
||||||
// If we're finished
|
// If we're finished
|
||||||
if (!currentGame && previousGameId.value) {
|
if (!currentGame && previousGameId.value) {
|
||||||
previousGameId.value = undefined;
|
previousGameId.value = undefined;
|
||||||
resetHistoryGraph();
|
resetHistoryGraph();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// If we don't have a game
|
||||||
|
if (!currentGame) return;
|
||||||
// If we started a new download
|
// If we started a new download
|
||||||
if (currentGame && !previousGameId.value) {
|
if (currentGame && !previousGameId.value) {
|
||||||
previousGameId.value = currentGame;
|
previousGameId.value = currentGame;
|
||||||
@@ -150,10 +149,9 @@ watch(queue, (v) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(stats, (v) => {
|
watch(stats, (v) => {
|
||||||
if(v.speed == 0) return;
|
|
||||||
const newLength = speedHistory.value.push(v.speed);
|
const newLength = speedHistory.value.push(v.speed);
|
||||||
if (newLength > speedHistoryMax.value) {
|
if (newLength > speedHistoryMax.value) {
|
||||||
speedHistory.value.splice(0, newLength - speedHistoryMax.value);
|
speedHistory.value.splice(0, 1);
|
||||||
}
|
}
|
||||||
checkReset(queue.value);
|
checkReset(queue.value);
|
||||||
});
|
});
|
||||||
@@ -184,10 +182,21 @@ async function cancelGame(meta: DownloadableMetadata) {
|
|||||||
await invoke("cancel_game", { meta });
|
await invoke("cancel_game", { meta });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatKilobytes(bytes: number): string {
|
||||||
if (seconds == 0) {
|
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||||
return `0s`;
|
let value = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
const scalar = 1000;
|
||||||
|
|
||||||
|
while (value >= scalar && unitIndex < units.length - 1) {
|
||||||
|
value /= scalar;
|
||||||
|
unitIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
if (seconds < 60) {
|
if (seconds < 60) {
|
||||||
return `${Math.round(seconds)}s`;
|
return `${Math.round(seconds)}s`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ platformInfo.value = currentPlatform;
|
|||||||
async function openDataDir() {
|
async function openDataDir() {
|
||||||
if (!dataDir.value) return;
|
if (!dataDir.value) return;
|
||||||
try {
|
try {
|
||||||
await invoke("open_fs", { path: dataDir.value });
|
await open(dataDir.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to open data dir:", error);
|
console.error("Failed to open data dir:", error);
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ async function openLogFile() {
|
|||||||
if (!dataDir.value) return;
|
if (!dataDir.value) return;
|
||||||
try {
|
try {
|
||||||
const logPath = `${dataDir.value}/drop.log`;
|
const logPath = `${dataDir.value}/drop.log`;
|
||||||
await invoke("open_fs", { path: logPath });
|
await open(logPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to open log file:", error);
|
console.error("Failed to open log file:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="grow w-full h-full flex items-center justify-center">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<WrenchScrewdriverIcon
|
|
||||||
class="h-12 w-12 text-blue-600"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div class="mt-3 text-center sm:mt-5">
|
|
||||||
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
|
|
||||||
Under construction
|
|
||||||
</h1>
|
|
||||||
<div class="mt-4">
|
|
||||||
<p class="text-sm text-zinc-400 max-w-lg">
|
|
||||||
This page hasn't been implemented yet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { WrenchScrewdriverIcon } from "@heroicons/vue/20/solid";
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<iframe :src="convertedStoreUrl" class="grow w-full h-full" />
|
<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>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
ArrowTopRightOnSquareIcon,
|
ArrowTopRightOnSquareIcon,
|
||||||
BuildingStorefrontIcon,
|
BuildingStorefrontIcon,
|
||||||
} from "@heroicons/vue/20/solid";
|
} from "@heroicons/vue/20/solid";
|
||||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
const convertedStoreUrl = convertFileSrc("store", "server");
|
const storeUrl = await invoke<string>("gen_drop_url", { path: "/store" });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
8217
main/pnpm-lock.yaml
generated
8217
main/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
onlyBuiltDependencies:
|
|
||||||
- '@parcel/watcher'
|
|
||||||
- esbuild
|
|
||||||
@@ -27,7 +27,6 @@ export type AppState = {
|
|||||||
|
|
||||||
export type Game = {
|
export type Game = {
|
||||||
id: string;
|
id: string;
|
||||||
type: "Game" | "Executor" | "Redist";
|
|
||||||
mName: string;
|
mName: string;
|
||||||
mShortDescription: string;
|
mShortDescription: string;
|
||||||
mDescription: string;
|
mDescription: string;
|
||||||
@@ -38,14 +37,6 @@ export type Game = {
|
|||||||
mImageCarouselObjectIds: string[];
|
mImageCarouselObjectIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Collection = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
isDefault: boolean;
|
|
||||||
isTools?: boolean;
|
|
||||||
entries: Array<{ gameId: string; game: Game }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GameVersion = {
|
export type GameVersion = {
|
||||||
launchCommandTemplate: string;
|
launchCommandTemplate: string;
|
||||||
};
|
};
|
||||||
|
|||||||
8091
main/yarn.lock
Normal file
8091
main/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
GSK_RENDERER=ngl pnpm tauri dev
|
WEBKIT_DISABLE_DMABUF_RENDERER=1 yarn tauri dev
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
ARCH=$(uname -m)
|
ARCH=$(uname -m)
|
||||||
|
|
||||||
# build tauri apps
|
# build tauri apps
|
||||||
# NO_STRIP=true pnpm tauri build -- --verbose
|
# NO_STRIP=true yarn tauri build -- --verbose
|
||||||
|
|
||||||
# unpack appimage
|
# unpack appimage
|
||||||
APPIMAGE=$(ls ./src-tauri/target/release/bundle/appimage/*.AppImage)
|
APPIMAGE=$(ls ./src-tauri/target/release/bundle/appimage/*.AppImage)
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -7,11 +7,16 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.7.0",
|
||||||
|
"@tauri-apps/plugin-deep-link": "^2.4.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.3.2",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||||
|
"@tauri-apps/plugin-os": "^2.3.0",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.0",
|
||||||
"pino": "^9.7.0",
|
"pino": "^9.7.0",
|
||||||
"pino-pretty": "^13.1.1",
|
"pino-pretty": "^13.1.1"
|
||||||
"tauri": "^0.15.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.7.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5583
pnpm-lock.yaml
generated
5583
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
|||||||
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'
|
|
||||||
3239
src-tauri/Cargo.lock
generated
3239
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,150 +1,118 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "drop-app"
|
name = "drop-app"
|
||||||
version = "0.4.0"
|
version = "0.3.2-dl"
|
||||||
description = "The client application for the open-source, self-hosted game distribution platform Drop"
|
description = "The client application for the open-source, self-hosted game distribution platform Drop"
|
||||||
authors = ["Drop OSS"]
|
authors = ["Drop OSS"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[workspace]
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
resolver = "3"
|
|
||||||
members = [
|
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||||
"client",
|
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||||
"cloud_saves",
|
|
||||||
"database",
|
|
||||||
"download_manager",
|
|
||||||
"games",
|
|
||||||
"process",
|
|
||||||
"remote",
|
|
||||||
"utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib", "rlib", "staticlib"]
|
|
||||||
# The `_lib` suffix may seem redundant but it is necessary
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
name = "drop_app_lib"
|
name = "drop_app_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
rustflags = ["-C", "target-feature=+aes,+sse2"]
|
rustflags = ["-C", "target-feature=+aes,+sse2"]
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.0.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
atomic-instant-full = "0.1.0"
|
tauri-plugin-shell = "2.2.1"
|
||||||
bitcode = "0.6.6"
|
serde_json = "1"
|
||||||
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"
|
rayon = "1.10.0"
|
||||||
regex = "1.11.1"
|
webbrowser = "1.0.2"
|
||||||
remote = { path = "./remote", version = "0.1.0" } # remote
|
url = "2.5.2"
|
||||||
reqwest = { version = "0.12.28", default-features = false, features = [
|
tauri-plugin-deep-link = "2"
|
||||||
"blocking",
|
log = "0.4.22"
|
||||||
"http2",
|
hex = "0.4.3"
|
||||||
"json",
|
tauri-plugin-dialog = "2"
|
||||||
"native-tls-alpn",
|
http = "1.1.0"
|
||||||
"rustls-tls",
|
urlencoding = "2.1.3"
|
||||||
"rustls-tls-native-roots",
|
md5 = "0.7.0"
|
||||||
"stream",
|
chrono = "0.4.38"
|
||||||
] }
|
tauri-plugin-os = "2"
|
||||||
|
boxcar = "0.2.7"
|
||||||
|
umu-wrapper-lib = "0.1.0"
|
||||||
|
tauri-plugin-autostart = "2.0.0"
|
||||||
|
shared_child = "1.0.1"
|
||||||
|
serde_with = "3.12.0"
|
||||||
|
slice-deque = "0.3.0"
|
||||||
|
throttle_my_fn = "0.2.6"
|
||||||
|
parking_lot = "0.12.3"
|
||||||
|
atomic-instant-full = "0.1.0"
|
||||||
|
cacache = "13.1.0"
|
||||||
|
http-serde = "2.1.1"
|
||||||
reqwest-middleware = "0.4.0"
|
reqwest-middleware = "0.4.0"
|
||||||
reqwest-middleware-cache = "0.1.1"
|
reqwest-middleware-cache = "0.1.1"
|
||||||
reqwest-websocket = "0.5.0"
|
deranged = "=0.4.0"
|
||||||
schemars = "0.8.22"
|
droplet-rs = "0.7.3"
|
||||||
serde_json = "1"
|
gethostname = "1.0.1"
|
||||||
serde_with = "3.12.0"
|
|
||||||
sha1 = "0.10.6"
|
|
||||||
shared_child = "1.0.1"
|
|
||||||
slice-deque = "0.3.0"
|
|
||||||
sysinfo = "0.36.1"
|
|
||||||
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"
|
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"
|
||||||
|
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"]
|
||||||
|
|
||||||
[dependencies.log4rs]
|
[dependencies.log4rs]
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
features = ["console_appender", "file_appender"]
|
features = ["console_appender", "file_appender"]
|
||||||
|
|
||||||
[dependencies.rustbreak]
|
|
||||||
version = "2"
|
|
||||||
features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
|
|
||||||
|
|
||||||
[dependencies.rustix]
|
[dependencies.rustix]
|
||||||
version = "0.38.37"
|
version = "0.38.37"
|
||||||
features = ["fs"]
|
features = ["fs"]
|
||||||
|
|
||||||
|
[dependencies.uuid]
|
||||||
|
version = "1.10.0"
|
||||||
|
features = ["v4", "fast-rng", "macro-diagnostics"]
|
||||||
|
|
||||||
|
[dependencies.rustbreak]
|
||||||
|
version = "2"
|
||||||
|
features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
|
||||||
|
|
||||||
|
[dependencies.reqwest]
|
||||||
|
version = "0.12"
|
||||||
|
default-features = false
|
||||||
|
features = ["json", "http2", "blocking", "rustls-tls", "native-tls-alpn", "rustls-tls-webpki-roots"]
|
||||||
|
|
||||||
[dependencies.serde]
|
[dependencies.serde]
|
||||||
version = "1"
|
version = "1"
|
||||||
features = ["derive", "rc"]
|
features = ["derive", "rc"]
|
||||||
|
|
||||||
[dependencies.tauri]
|
|
||||||
version = "2.9.5"
|
|
||||||
features = ["protocol-asset", "tray-icon", "unstable"]
|
|
||||||
|
|
||||||
[dependencies.tokio]
|
|
||||||
version = "1.40.0"
|
|
||||||
features = ["rt", "signal", "tokio-macros"]
|
|
||||||
|
|
||||||
[dependencies.uuid]
|
|
||||||
version = "1.10.0"
|
|
||||||
features = ["fast-rng", "macro-diagnostics", "v4"]
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
tauri-build = { version = "*", features = [] }
|
|
||||||
|
|
||||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
|
||||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
panic = "abort"
|
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
panic = 'abort'
|
||||||
[profile.dev.package."*"]
|
|
||||||
# Set the default for dependencies in Development mode.
|
|
||||||
opt-level = 3
|
|
||||||
|
|
||||||
[profile.dev]
|
|
||||||
# Turn on a small amount of optimisation in Development mode.
|
|
||||||
opt-level = 1
|
|
||||||
|
|||||||
4862
src-tauri/client/Cargo.lock
generated
4862
src-tauri/client/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "client"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
bitcode = "*"
|
|
||||||
database = { version = "*", path = "../database" }
|
|
||||||
log = "*"
|
|
||||||
serde = { version = "*", features = ["derive"] }
|
|
||||||
tauri = "*"
|
|
||||||
tauri-plugin-autostart = "*"
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Serialize, Eq, PartialEq)]
|
|
||||||
pub enum AppStatus {
|
|
||||||
NotConfigured,
|
|
||||||
Offline,
|
|
||||||
ServerError,
|
|
||||||
SignedOut,
|
|
||||||
SignedIn,
|
|
||||||
SignedInNeedsReauth,
|
|
||||||
ServerUnavailable,
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
pub mod app_status;
|
|
||||||
pub mod autostart;
|
|
||||||
pub mod compat;
|
|
||||||
pub mod user;
|
|
||||||
pub mod app_state;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[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"
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
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,27 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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,19 +0,0 @@
|
|||||||
[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"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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"))
|
|
||||||
});
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#![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,
|
|
||||||
};
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
[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" }
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
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,33 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
#![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,28 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
[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" }
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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,531 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
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,29 +0,0 @@
|
|||||||
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"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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,8 +0,0 @@
|
|||||||
#![feature(iterator_try_collect)]
|
|
||||||
#![feature(lock_value_accessors)]
|
|
||||||
|
|
||||||
pub mod collections;
|
|
||||||
pub mod downloads;
|
|
||||||
pub mod library;
|
|
||||||
pub mod scan;
|
|
||||||
pub mod state;
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
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,20 +0,0 @@
|
|||||||
[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" }
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#![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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
use std::{
|
|
||||||
collections::HashMap,
|
|
||||||
fs::{OpenOptions, create_dir_all},
|
|
||||||
io,
|
|
||||||
path::PathBuf,
|
|
||||||
process::{Command, ExitStatus},
|
|
||||||
sync::Arc,
|
|
||||||
thread::spawn,
|
|
||||||
time::{Duration, SystemTime},
|
|
||||||
};
|
|
||||||
|
|
||||||
use database::{
|
|
||||||
ApplicationTransientStatus, Database, DownloadableMetadata, GameDownloadStatus, GameVersion,
|
|
||||||
borrow_db_checked, borrow_db_mut_checked, db::DATA_ROOT_DIR, platform::Platform,
|
|
||||||
};
|
|
||||||
use dynfmt::Format;
|
|
||||||
use dynfmt::SimpleCurlyFormat;
|
|
||||||
use games::{library::push_game_update, state::GameStatusManager};
|
|
||||||
use log::{debug, info, warn};
|
|
||||||
use serde::Serialize;
|
|
||||||
use shared_child::SharedChild;
|
|
||||||
use tauri::{AppHandle, Emitter as _};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
PROCESS_MANAGER,
|
|
||||||
error::ProcessError,
|
|
||||||
format::DropFormatArgs,
|
|
||||||
parser::{LaunchParameters, ParsedCommand},
|
|
||||||
process_handlers::{AsahiMuvmLauncher, NativeGameLauncher, UMULauncher},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct RunningProcess {
|
|
||||||
handle: Arc<SharedChild>,
|
|
||||||
start: SystemTime,
|
|
||||||
manually_killed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ProcessManager<'a> {
|
|
||||||
current_platform: Platform,
|
|
||||||
log_output_dir: PathBuf,
|
|
||||||
processes: HashMap<String, RunningProcess>,
|
|
||||||
game_launchers: Vec<(
|
|
||||||
(Platform, Platform),
|
|
||||||
&'a (dyn ProcessHandler + Sync + Send + 'static),
|
|
||||||
)>,
|
|
||||||
app_handle: AppHandle,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct LaunchOption {
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProcessManager<'_> {
|
|
||||||
pub fn new(app_handle: AppHandle) -> Self {
|
|
||||||
let log_output_dir = DATA_ROOT_DIR.join("logs");
|
|
||||||
|
|
||||||
ProcessManager {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
current_platform: Platform::Windows,
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
current_platform: Platform::macOS,
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
current_platform: Platform::Linux,
|
|
||||||
|
|
||||||
processes: HashMap::new(),
|
|
||||||
log_output_dir,
|
|
||||||
game_launchers: vec![
|
|
||||||
// Current platform to target platform
|
|
||||||
(
|
|
||||||
(Platform::Windows, Platform::Windows),
|
|
||||||
&NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
(Platform::Linux, Platform::Linux),
|
|
||||||
&UMULauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
(Platform::macOS, Platform::macOS),
|
|
||||||
&NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
(Platform::Linux, Platform::Windows),
|
|
||||||
&AsahiMuvmLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
(Platform::Linux, Platform::Windows),
|
|
||||||
&UMULauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
app_handle,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn kill_game(&mut self, game_id: String) -> Result<(), io::Error> {
|
|
||||||
match self.processes.get_mut(&game_id) {
|
|
||||||
Some(process) => {
|
|
||||||
process.manually_killed = true;
|
|
||||||
process.handle.kill()?;
|
|
||||||
let exit_status = process.handle.wait()?;
|
|
||||||
info!("exit status: {:?}", exit_status);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
None => Err(io::Error::new(
|
|
||||||
io::ErrorKind::NotFound,
|
|
||||||
"Game ID not running",
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_log_dir(&self, game_id: String) -> PathBuf {
|
|
||||||
self.log_output_dir.join(game_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_process_finish(
|
|
||||||
&mut self,
|
|
||||||
game_id: String,
|
|
||||||
result: Result<ExitStatus, std::io::Error>,
|
|
||||||
) -> Result<(), ProcessError> {
|
|
||||||
if !self.processes.contains_key(&game_id) {
|
|
||||||
warn!(
|
|
||||||
"process on_finish was called, but game_id is no longer valid. finished with result: {result:?}"
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("process for {:?} exited with {:?}", &game_id, result);
|
|
||||||
|
|
||||||
let process = match self.processes.remove(&game_id) {
|
|
||||||
Some(process) => process,
|
|
||||||
None => {
|
|
||||||
info!("Attempted to stop process {game_id} which didn't exist");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut db_handle = borrow_db_mut_checked();
|
|
||||||
let meta = db_handle
|
|
||||||
.applications
|
|
||||||
.installed_game_version
|
|
||||||
.get(&game_id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| panic!("Could not get installed version of {}", &game_id));
|
|
||||||
db_handle.applications.transient_statuses.remove(&meta);
|
|
||||||
|
|
||||||
let current_state = db_handle.applications.game_statuses.get(&game_id).cloned();
|
|
||||||
if let Some(GameDownloadStatus::SetupRequired {
|
|
||||||
version_name,
|
|
||||||
install_dir,
|
|
||||||
}) = current_state
|
|
||||||
&& let Ok(exit_code) = result
|
|
||||||
&& exit_code.success()
|
|
||||||
{
|
|
||||||
db_handle.applications.game_statuses.insert(
|
|
||||||
game_id.clone(),
|
|
||||||
GameDownloadStatus::Installed {
|
|
||||||
version_name: version_name.to_string(),
|
|
||||||
install_dir: install_dir.to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let elapsed = process.start.elapsed().unwrap_or(Duration::ZERO);
|
|
||||||
// If we started and ended really quickly, something might've gone wrong
|
|
||||||
// Or if the status isn't 0
|
|
||||||
// Or if it's an error
|
|
||||||
if !process.manually_killed
|
|
||||||
&& (elapsed.as_secs() <= 2 || result.map_or(true, |r| !r.success()))
|
|
||||||
{
|
|
||||||
warn!("drop detected that the game {game_id} may have failed to launch properly");
|
|
||||||
let _ = self.app_handle.emit("launch_external_error", &game_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let version_data = match db_handle.applications.game_versions.get(&meta.version) {
|
|
||||||
// This unwrap here should be resolved by just making the hashmap accept an option rather than just a String
|
|
||||||
Some(res) => res,
|
|
||||||
None => todo!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = GameStatusManager::fetch_state(&game_id, &db_handle);
|
|
||||||
|
|
||||||
push_game_update(
|
|
||||||
&self.app_handle,
|
|
||||||
&game_id,
|
|
||||||
Some(version_data.clone()),
|
|
||||||
status,
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch_process_handler(
|
|
||||||
&self,
|
|
||||||
db_lock: &Database,
|
|
||||||
target_platform: &Platform,
|
|
||||||
) -> Result<&(dyn ProcessHandler + Send + Sync), ProcessError> {
|
|
||||||
Ok(self
|
|
||||||
.game_launchers
|
|
||||||
.iter()
|
|
||||||
.find(|e| {
|
|
||||||
let (e_current, e_target) = e.0;
|
|
||||||
e_current == self.current_platform
|
|
||||||
&& e_target == *target_platform
|
|
||||||
&& e.1.valid_for_platform(db_lock, target_platform)
|
|
||||||
})
|
|
||||||
.ok_or(ProcessError::InvalidPlatform)?
|
|
||||||
.1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn valid_platform(&self, platform: &Platform) -> bool {
|
|
||||||
let db_lock = borrow_db_checked();
|
|
||||||
let process_handler = self.fetch_process_handler(&db_lock, platform);
|
|
||||||
process_handler.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_launch_options(game_id: String) -> Result<Vec<LaunchOption>, ProcessError> {
|
|
||||||
let db_lock = borrow_db_checked();
|
|
||||||
|
|
||||||
let meta = db_lock
|
|
||||||
.applications
|
|
||||||
.installed_game_version
|
|
||||||
.get(&game_id)
|
|
||||||
.cloned()
|
|
||||||
.ok_or(ProcessError::NotInstalled)?;
|
|
||||||
|
|
||||||
let game_version = db_lock
|
|
||||||
.applications
|
|
||||||
.game_versions
|
|
||||||
.get(&meta.version)
|
|
||||||
.ok_or(ProcessError::InvalidVersion)?;
|
|
||||||
|
|
||||||
let launch_options = game_version
|
|
||||||
.launches
|
|
||||||
.iter()
|
|
||||||
.filter(|v| v.platform == meta.target_platform)
|
|
||||||
.map(|v| LaunchOption {
|
|
||||||
name: v.name.clone(),
|
|
||||||
})
|
|
||||||
.collect::<Vec<LaunchOption>>();
|
|
||||||
|
|
||||||
Ok(launch_options)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn launch_process(
|
|
||||||
&mut self,
|
|
||||||
game_id: String,
|
|
||||||
launch_process_index: usize,
|
|
||||||
) -> Result<(), ProcessError> {
|
|
||||||
if self.processes.contains_key(&game_id) {
|
|
||||||
return Err(ProcessError::AlreadyRunning);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut db_lock = borrow_db_mut_checked();
|
|
||||||
|
|
||||||
let meta = db_lock
|
|
||||||
.applications
|
|
||||||
.installed_game_version
|
|
||||||
.get(&game_id)
|
|
||||||
.cloned()
|
|
||||||
.ok_or(ProcessError::NotInstalled)?;
|
|
||||||
|
|
||||||
let game_status = db_lock
|
|
||||||
.applications
|
|
||||||
.game_statuses
|
|
||||||
.get(&game_id)
|
|
||||||
.ok_or(ProcessError::NotInstalled)?;
|
|
||||||
|
|
||||||
let (version_name, install_dir) = match game_status {
|
|
||||||
GameDownloadStatus::Installed {
|
|
||||||
version_name,
|
|
||||||
install_dir,
|
|
||||||
} => (version_name, install_dir),
|
|
||||||
GameDownloadStatus::SetupRequired {
|
|
||||||
version_name,
|
|
||||||
install_dir,
|
|
||||||
} => (version_name, install_dir),
|
|
||||||
_ => return Err(ProcessError::NotInstalled),
|
|
||||||
};
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Launching process {:?} with version {:?}",
|
|
||||||
&game_id,
|
|
||||||
db_lock.applications.game_versions.get(&game_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
let game_version = db_lock
|
|
||||||
.applications
|
|
||||||
.game_versions
|
|
||||||
.get(version_name)
|
|
||||||
.ok_or(ProcessError::InvalidVersion)?;
|
|
||||||
|
|
||||||
// TODO: refactor this path with open_process_logs
|
|
||||||
let game_log_folder = &self.get_log_dir(game_id);
|
|
||||||
create_dir_all(game_log_folder)?;
|
|
||||||
|
|
||||||
let current_time = chrono::offset::Local::now();
|
|
||||||
let log_file = OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.truncate(true)
|
|
||||||
.read(true)
|
|
||||||
.create(true)
|
|
||||||
.open(game_log_folder.join(format!(
|
|
||||||
"{}-{}.log",
|
|
||||||
&meta.version,
|
|
||||||
current_time.timestamp()
|
|
||||||
)))?;
|
|
||||||
|
|
||||||
let error_file = OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.truncate(true)
|
|
||||||
.read(true)
|
|
||||||
.create(true)
|
|
||||||
.open(game_log_folder.join(format!(
|
|
||||||
"{}-{}-error.log",
|
|
||||||
&meta.version,
|
|
||||||
current_time.timestamp()
|
|
||||||
)))?;
|
|
||||||
|
|
||||||
let target_platform = meta.target_platform;
|
|
||||||
|
|
||||||
let process_handler = self.fetch_process_handler(&db_lock, &target_platform)?;
|
|
||||||
|
|
||||||
let (target_command, executor) = match game_status {
|
|
||||||
GameDownloadStatus::Installed {
|
|
||||||
version_name: _,
|
|
||||||
install_dir: _,
|
|
||||||
} => {
|
|
||||||
let (_, launch_config) = game_version
|
|
||||||
.launches
|
|
||||||
.iter()
|
|
||||||
.filter(|v| v.platform == target_platform)
|
|
||||||
.enumerate()
|
|
||||||
.find(|(i, _)| *i == launch_process_index)
|
|
||||||
.ok_or(ProcessError::NotInstalled)?;
|
|
||||||
(
|
|
||||||
launch_config.command.clone(),
|
|
||||||
launch_config.executor.as_ref(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GameDownloadStatus::SetupRequired {
|
|
||||||
version_name: _,
|
|
||||||
install_dir: _,
|
|
||||||
} => {
|
|
||||||
let setup_config = game_version
|
|
||||||
.setups
|
|
||||||
.iter()
|
|
||||||
.find(|v| v.platform == target_platform)
|
|
||||||
.ok_or(ProcessError::NotInstalled)?;
|
|
||||||
|
|
||||||
(setup_config.command.clone(), None)
|
|
||||||
}
|
|
||||||
_ => unreachable!("Game registered as 'Partially Installed'"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let target_command = ParsedCommand::parse(target_command)?;
|
|
||||||
|
|
||||||
let launch_parameters = if let Some(executor) = executor {
|
|
||||||
let err = ProcessError::RequiredDependency(
|
|
||||||
executor.game_id.clone(),
|
|
||||||
executor.version_id.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let executor_metadata = db_lock
|
|
||||||
.applications
|
|
||||||
.installed_game_version
|
|
||||||
.get(&executor.game_id)
|
|
||||||
.ok_or(err.clone())?;
|
|
||||||
|
|
||||||
let executor_game_status = db_lock
|
|
||||||
.applications
|
|
||||||
.game_statuses
|
|
||||||
.get(&executor.game_id)
|
|
||||||
.ok_or(err.clone())?;
|
|
||||||
|
|
||||||
let executor_install_dir = match executor_game_status {
|
|
||||||
GameDownloadStatus::Installed {
|
|
||||||
version_name: _,
|
|
||||||
install_dir,
|
|
||||||
} => Ok(install_dir),
|
|
||||||
GameDownloadStatus::SetupRequired {
|
|
||||||
version_name: _,
|
|
||||||
install_dir: _,
|
|
||||||
} => todo!(),
|
|
||||||
_ => Err(err.clone()),
|
|
||||||
}?;
|
|
||||||
|
|
||||||
let executor_game_version = db_lock
|
|
||||||
.applications
|
|
||||||
.game_versions
|
|
||||||
.get(&executor.version_id)
|
|
||||||
.ok_or(err.clone())?;
|
|
||||||
|
|
||||||
let executor_launch_config = executor_game_version
|
|
||||||
.launches
|
|
||||||
.iter()
|
|
||||||
.find(|v| v.launch_id == executor.launch_id)
|
|
||||||
.ok_or(err)?;
|
|
||||||
|
|
||||||
println!("{}", executor_launch_config.command);
|
|
||||||
let mut exe_command = ParsedCommand::parse(executor_launch_config.command.clone())?;
|
|
||||||
println!("{:?}", exe_command);
|
|
||||||
exe_command.env.extend(target_command.env);
|
|
||||||
exe_command.make_absolute(executor_install_dir.into());
|
|
||||||
|
|
||||||
exe_command.args.iter_mut().for_each(|v| {
|
|
||||||
*v = v.replace("{executor}", &target_command.command);
|
|
||||||
});
|
|
||||||
|
|
||||||
let executor_launch_string = process_handler.create_launch_process(
|
|
||||||
executor_metadata,
|
|
||||||
exe_command.reconstruct(),
|
|
||||||
executor_game_version,
|
|
||||||
install_dir,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
LaunchParameters(executor_launch_string, install_dir.into())
|
|
||||||
} else {
|
|
||||||
let target_launch_string = process_handler.create_launch_process(
|
|
||||||
&meta,
|
|
||||||
target_command.reconstruct(),
|
|
||||||
game_version,
|
|
||||||
install_dir,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let mut parsed_launch = ParsedCommand::parse(target_launch_string.clone())?;
|
|
||||||
let executable_name = parsed_launch.command.clone();
|
|
||||||
parsed_launch.make_absolute(install_dir.into());
|
|
||||||
|
|
||||||
let format_args = DropFormatArgs::new(
|
|
||||||
target_launch_string,
|
|
||||||
install_dir,
|
|
||||||
&executable_name,
|
|
||||||
parsed_launch.command,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let target_launch_string = SimpleCurlyFormat
|
|
||||||
.format(&game_version.launch_template, &format_args)
|
|
||||||
.map_err(|e| ProcessError::FormatError(e.to_string()))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let target_launch_string = SimpleCurlyFormat
|
|
||||||
.format(&target_launch_string, format_args)
|
|
||||||
.map_err(|e| ProcessError::FormatError(e.to_string()))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
LaunchParameters(target_launch_string, install_dir.into())
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let mut command = Command::new("cmd");
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
command.raw_arg(format!("/C \"{}\"", &launch_string));
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"launching (in {}): {}",
|
|
||||||
launch_parameters.1.to_string_lossy(),
|
|
||||||
launch_parameters.0
|
|
||||||
);
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
let mut command: Command = Command::new("sh");
|
|
||||||
#[cfg(unix)]
|
|
||||||
command.args(vec!["-c", &launch_parameters.0]);
|
|
||||||
|
|
||||||
command
|
|
||||||
.stderr(error_file)
|
|
||||||
.stdout(log_file)
|
|
||||||
.env_remove("RUST_LOG")
|
|
||||||
.current_dir(launch_parameters.1);
|
|
||||||
|
|
||||||
let child = command.spawn()?;
|
|
||||||
|
|
||||||
let launch_process_handle =
|
|
||||||
Arc::new(SharedChild::new(child)?);
|
|
||||||
|
|
||||||
db_lock
|
|
||||||
.applications
|
|
||||||
.transient_statuses
|
|
||||||
.insert(meta.clone(), ApplicationTransientStatus::Running {});
|
|
||||||
|
|
||||||
push_game_update(
|
|
||||||
&self.app_handle,
|
|
||||||
&meta.id,
|
|
||||||
None,
|
|
||||||
(None, Some(ApplicationTransientStatus::Running {})),
|
|
||||||
);
|
|
||||||
|
|
||||||
let wait_thread_handle = launch_process_handle.clone();
|
|
||||||
let wait_thread_game_id = meta.clone();
|
|
||||||
|
|
||||||
self.processes.insert(
|
|
||||||
meta.id,
|
|
||||||
RunningProcess {
|
|
||||||
handle: wait_thread_handle,
|
|
||||||
start: SystemTime::now(),
|
|
||||||
manually_killed: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
spawn(move || {
|
|
||||||
let result: Result<ExitStatus, std::io::Error> = launch_process_handle.wait();
|
|
||||||
|
|
||||||
PROCESS_MANAGER
|
|
||||||
.lock()
|
|
||||||
.on_process_finish(wait_thread_game_id.id, result)
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait ProcessHandler: Send + 'static {
|
|
||||||
fn create_launch_process(
|
|
||||||
&self,
|
|
||||||
meta: &DownloadableMetadata,
|
|
||||||
launch_command: String,
|
|
||||||
game_version: &GameVersion,
|
|
||||||
current_dir: &str,
|
|
||||||
) -> Result<String, ProcessError>;
|
|
||||||
|
|
||||||
fn valid_for_platform(&self, db: &Database, target: &Platform) -> bool;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
[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" }
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
use std::{collections::HashMap, env};
|
|
||||||
|
|
||||||
use chrono::Utc;
|
|
||||||
use client::{app_status::AppStatus, user::User};
|
|
||||||
use database::{DatabaseAuth, interface::borrow_db_checked};
|
|
||||||
use droplet_rs::ssl::sign_nonce;
|
|
||||||
use gethostname::gethostname;
|
|
||||||
use log::{error, warn};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::{DropServerError, RemoteAccessError},
|
|
||||||
requests::make_authenticated_get,
|
|
||||||
utils::DROP_CLIENT_SYNC,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
cache::{cache_object, get_cached_object},
|
|
||||||
requests::generate_url,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct CapabilityConfiguration {}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct InitiateRequestBody {
|
|
||||||
name: String,
|
|
||||||
platform: String,
|
|
||||||
capabilities: HashMap<String, CapabilityConfiguration>,
|
|
||||||
mode: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct HandshakeRequestBody {
|
|
||||||
client_id: String,
|
|
||||||
token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HandshakeRequestBody {
|
|
||||||
pub fn new(client_id: String, token: String) -> Self {
|
|
||||||
Self { client_id, token }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct HandshakeResponse {
|
|
||||||
private: String,
|
|
||||||
certificate: String,
|
|
||||||
id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<HandshakeResponse> for DatabaseAuth {
|
|
||||||
fn from(value: HandshakeResponse) -> Self {
|
|
||||||
DatabaseAuth::new(value.private, value.certificate, value.id, None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_authorization_header() -> String {
|
|
||||||
let certs = {
|
|
||||||
let db = borrow_db_checked();
|
|
||||||
db.auth.clone().expect("Authorisation not initialised")
|
|
||||||
};
|
|
||||||
|
|
||||||
let nonce = Utc::now().timestamp_millis().to_string();
|
|
||||||
|
|
||||||
let signature =
|
|
||||||
sign_nonce(certs.private, nonce.clone()).expect("Failed to generate authorisation header");
|
|
||||||
|
|
||||||
format!("Nonce {} {} {}", certs.client_id, nonce, signature)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn fetch_user() -> Result<User, RemoteAccessError> {
|
|
||||||
let response = make_authenticated_get(generate_url(&["/api/v1/client/user"], &[])?).await?;
|
|
||||||
if response.status() != 200 {
|
|
||||||
let err: DropServerError = response.json().await?;
|
|
||||||
warn!("{err:?}");
|
|
||||||
|
|
||||||
if err.message == "Nonce expired" {
|
|
||||||
return Err(RemoteAccessError::OutOfSync);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Err(RemoteAccessError::InvalidResponse(err));
|
|
||||||
}
|
|
||||||
|
|
||||||
response
|
|
||||||
.json::<User>()
|
|
||||||
.await
|
|
||||||
.map_err(std::convert::Into::into)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn auth_initiate_logic(mode: String) -> Result<String, RemoteAccessError> {
|
|
||||||
let base_url = {
|
|
||||||
let db_lock = borrow_db_checked();
|
|
||||||
Url::parse(&db_lock.base_url.clone())?
|
|
||||||
};
|
|
||||||
|
|
||||||
let hostname = gethostname();
|
|
||||||
|
|
||||||
let endpoint = base_url.join("/api/v1/client/auth/initiate")?;
|
|
||||||
let body = InitiateRequestBody {
|
|
||||||
name: format!("{} (Desktop)", hostname.display()),
|
|
||||||
platform: env::consts::OS.to_string(),
|
|
||||||
capabilities: HashMap::from([
|
|
||||||
("peerAPI".to_owned(), CapabilityConfiguration {}),
|
|
||||||
("cloudSaves".to_owned(), CapabilityConfiguration {}),
|
|
||||||
]),
|
|
||||||
mode,
|
|
||||||
};
|
|
||||||
|
|
||||||
let client = DROP_CLIENT_SYNC.clone();
|
|
||||||
let response = client.post(endpoint.to_string()).json(&body).send()?;
|
|
||||||
|
|
||||||
if response.status() != 200 {
|
|
||||||
let data: DropServerError = response.json()?;
|
|
||||||
error!("could not start handshake: {:?}", data);
|
|
||||||
|
|
||||||
return Err(RemoteAccessError::HandshakeFailed(data.message));
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = response.text()?;
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn setup() -> (AppStatus, Option<User>) {
|
|
||||||
let auth = {
|
|
||||||
let data = borrow_db_checked();
|
|
||||||
data.auth.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
if auth.is_some() {
|
|
||||||
let user_result = match fetch_user().await {
|
|
||||||
Ok(data) => data,
|
|
||||||
Err(RemoteAccessError::FetchError(_)) => {
|
|
||||||
let user = get_cached_object::<User>("user").ok();
|
|
||||||
return (AppStatus::Offline, user);
|
|
||||||
}
|
|
||||||
Err(_) => return (AppStatus::SignedInNeedsReauth, None),
|
|
||||||
};
|
|
||||||
if let Err(e) = cache_object("user", &user_result) {
|
|
||||||
warn!("Could not cache user object with error {e}");
|
|
||||||
}
|
|
||||||
return (AppStatus::SignedIn, Some(user_result));
|
|
||||||
}
|
|
||||||
|
|
||||||
(AppStatus::SignedOut, None)
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
use database::{DB};
|
|
||||||
use http::{Response, header::CONTENT_TYPE, response::Builder as ResponseBuilder};
|
|
||||||
use log::{debug, warn};
|
|
||||||
use tauri::UriSchemeResponder;
|
|
||||||
|
|
||||||
use crate::{error::CacheError, utils::DROP_CLIENT_ASYNC};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
auth::generate_authorization_header,
|
|
||||||
cache::{ObjectCache, cache_object, get_cached_object},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub async fn fetch_object_wrapper(request: http::Request<Vec<u8>>, responder: UriSchemeResponder) {
|
|
||||||
match fetch_object(request).await {
|
|
||||||
Ok(r) => responder.respond(r),
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Cache error: {e}");
|
|
||||||
responder.respond(
|
|
||||||
Response::builder()
|
|
||||||
.status(500)
|
|
||||||
.body(Vec::new())
|
|
||||||
.expect("Failed to build error response"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn fetch_object(
|
|
||||||
request: http::Request<Vec<u8>>,
|
|
||||||
) -> Result<Response<Vec<u8>>, CacheError> {
|
|
||||||
// Drop leading /
|
|
||||||
let object_id = &request.uri().path()[1..];
|
|
||||||
|
|
||||||
let cache_result = get_cached_object::<ObjectCache>(object_id);
|
|
||||||
if let Ok(cache_result) = &cache_result
|
|
||||||
&& !cache_result.has_expired()
|
|
||||||
{
|
|
||||||
return cache_result.try_into();
|
|
||||||
}
|
|
||||||
|
|
||||||
let header = generate_authorization_header();
|
|
||||||
let client = DROP_CLIENT_ASYNC.clone();
|
|
||||||
let url = format!("{}api/v1/client/object/{object_id}", DB.fetch_base_url());
|
|
||||||
let response = client.get(url).header("Authorization", header).send().await;
|
|
||||||
|
|
||||||
match response {
|
|
||||||
Ok(r) => {
|
|
||||||
let resp_builder = ResponseBuilder::new().header(
|
|
||||||
CONTENT_TYPE,
|
|
||||||
r.headers()
|
|
||||||
.get("Content-Type")
|
|
||||||
.expect("Failed get Content-Type header"),
|
|
||||||
);
|
|
||||||
let data = match r.bytes().await {
|
|
||||||
Ok(data) => Vec::from(data),
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Could not get data from cache object {object_id} with error {e}",);
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let resp = resp_builder
|
|
||||||
.body(data)
|
|
||||||
.expect("Failed to build object cache response body");
|
|
||||||
if cache_result.map_or(true, |x| x.has_expired()) {
|
|
||||||
cache_object::<ObjectCache>(object_id, &resp.clone().try_into()?)
|
|
||||||
.expect("Failed to create cached object");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(resp)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!("Object fetch failed with error {e}. Attempting to download from cache");
|
|
||||||
match cache_result {
|
|
||||||
Ok(cache_result) => cache_result.try_into(),
|
|
||||||
Err(e) => {
|
|
||||||
warn!("{e}");
|
|
||||||
Err(CacheError::Remote(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#![feature(slice_concat_trait)]
|
|
||||||
#![feature(sync_nonpoison)]
|
|
||||||
#![feature(nonpoison_mutex)]
|
|
||||||
|
|
||||||
pub mod auth;
|
|
||||||
#[macro_use]
|
|
||||||
pub mod cache;
|
|
||||||
pub mod error;
|
|
||||||
pub mod fetch_object;
|
|
||||||
pub mod requests;
|
|
||||||
pub mod server_proto;
|
|
||||||
pub mod utils;
|
|
||||||
|
|
||||||
pub use auth::setup;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
use database::{DB};
|
|
||||||
use reqwest_middleware::Error;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::generate_authorization_header, error::RemoteAccessError, utils::DROP_CLIENT_ASYNC,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn generate_url(
|
|
||||||
path_components: &[&str],
|
|
||||||
query: &[(&str, &str)],
|
|
||||||
) -> Result<Url, RemoteAccessError> {
|
|
||||||
let path_appended = path_components.join("/");
|
|
||||||
let mut base_url = DB.fetch_base_url().join(&path_appended)?;
|
|
||||||
{
|
|
||||||
let mut queries = base_url.query_pairs_mut();
|
|
||||||
for (param, val) in query {
|
|
||||||
queries.append_pair(param.as_ref(), val.as_ref());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(base_url)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn make_authenticated_get(url: Url) -> Result<reqwest::Response, Error> {
|
|
||||||
DROP_CLIENT_ASYNC
|
|
||||||
.get(url)
|
|
||||||
.header("Authorization", generate_authorization_header())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
use database::borrow_db_checked;
|
|
||||||
use http::{
|
|
||||||
HeaderMap, HeaderValue, Request, Response, StatusCode, Uri, header::USER_AGENT,
|
|
||||||
};
|
|
||||||
use log::{error, warn};
|
|
||||||
use tauri::UriSchemeResponder;
|
|
||||||
|
|
||||||
use crate::utils::DROP_CLIENT_ASYNC;
|
|
||||||
|
|
||||||
pub async fn handle_server_proto_offline_wrapper(
|
|
||||||
request: Request<Vec<u8>>,
|
|
||||||
responder: UriSchemeResponder,
|
|
||||||
) {
|
|
||||||
responder.respond(match handle_server_proto_offline(request).await {
|
|
||||||
Ok(res) => res,
|
|
||||||
Err(_) => unreachable!(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_server_proto_offline(
|
|
||||||
_request: Request<Vec<u8>>,
|
|
||||||
) -> Result<Response<Vec<u8>>, StatusCode> {
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::NOT_FOUND)
|
|
||||||
.body(Vec::new())
|
|
||||||
.expect("Failed to build error response for proto offline"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_server_proto_wrapper(request: Request<Vec<u8>>, responder: UriSchemeResponder) {
|
|
||||||
match handle_server_proto(request).await {
|
|
||||||
Ok(r) => responder.respond(r),
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Cache error: {e}");
|
|
||||||
responder.respond(
|
|
||||||
Response::builder()
|
|
||||||
.status(e)
|
|
||||||
.body(Vec::new())
|
|
||||||
.inspect_err(|v| warn!("{:?}", v))
|
|
||||||
.expect("Failed to build error response"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_server_proto(request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>, StatusCode> {
|
|
||||||
let (remote_uri, web_token) = {
|
|
||||||
let db_handle = borrow_db_checked();
|
|
||||||
let auth = match db_handle.auth.as_ref() {
|
|
||||||
Some(auth) => auth,
|
|
||||||
None => {
|
|
||||||
error!("Could not find auth in database");
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let web_token = match &auth.web_token {
|
|
||||||
Some(token) => token.clone(),
|
|
||||||
None => return Err(StatusCode::UNAUTHORIZED),
|
|
||||||
};
|
|
||||||
let remote_uri = db_handle
|
|
||||||
.base_url
|
|
||||||
.parse::<Uri>()
|
|
||||||
.inspect_err(|v| warn!("{:?}", v))
|
|
||||||
.expect("Failed to parse base url");
|
|
||||||
(remote_uri, web_token)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut new_uri = request.uri().clone().into_parts();
|
|
||||||
new_uri.authority = remote_uri.authority().cloned();
|
|
||||||
new_uri.scheme = remote_uri.scheme().cloned();
|
|
||||||
let err_msg = &format!("Failed to build new uri from parts {new_uri:?}");
|
|
||||||
let new_uri = Uri::from_parts(new_uri)
|
|
||||||
.inspect_err(|v| warn!("{:?}", v))
|
|
||||||
.expect(err_msg);
|
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
request.headers().clone_into(&mut headers);
|
|
||||||
headers.remove(USER_AGENT);
|
|
||||||
headers.append(USER_AGENT, HeaderValue::from_static("Drop Desktop Client"));
|
|
||||||
headers.append(
|
|
||||||
"Authorization",
|
|
||||||
HeaderValue::from_str(&format!("Bearer {web_token}")).unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = match DROP_CLIENT_ASYNC
|
|
||||||
.request(request.method().clone(), new_uri.to_string())
|
|
||||||
.headers(headers)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(response) => response,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Could not send response. Got {e} when sending");
|
|
||||||
return Err(e.status().unwrap_or(StatusCode::BAD_REQUEST));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let response_status = response.status();
|
|
||||||
let mut client_http_response = Response::builder()
|
|
||||||
.status(response_status)
|
|
||||||
.header("Access-Control-Allow-Origin", "*");
|
|
||||||
|
|
||||||
{
|
|
||||||
let client_response_headers = client_http_response.headers_mut().unwrap();
|
|
||||||
for (header, header_value) in response.headers() {
|
|
||||||
client_response_headers.insert(header, header_value.clone());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let response_body = match response.bytes().await {
|
|
||||||
Ok(bytes) => bytes,
|
|
||||||
Err(e) => return Err(e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)),
|
|
||||||
};
|
|
||||||
|
|
||||||
let client_http_response = client_http_response
|
|
||||||
.body(response_body.to_vec())
|
|
||||||
.inspect_err(|v| warn!("{:?}", v))
|
|
||||||
.expect("Failed to build server proto response");
|
|
||||||
|
|
||||||
Ok(client_http_response)
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
use std::{
|
|
||||||
fs::{self, File},
|
|
||||||
io::Read,
|
|
||||||
sync::LazyLock,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use client::{app_state::AppState, app_status::AppStatus};
|
|
||||||
use database::db::DATA_ROOT_DIR;
|
|
||||||
use http::Extensions;
|
|
||||||
use log::{debug, info, warn};
|
|
||||||
use reqwest::Certificate;
|
|
||||||
use reqwest_middleware::{
|
|
||||||
ClientBuilder, ClientWithMiddleware, Error, Middleware, Next, Result,
|
|
||||||
reqwest::{Request, Response},
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tauri::{AppHandle, Emitter, Manager, async_runtime::Mutex};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DropHealthcheck {
|
|
||||||
app_name: String,
|
|
||||||
}
|
|
||||||
impl DropHealthcheck {
|
|
||||||
pub fn app_name(&self) -> &String {
|
|
||||||
&self.app_name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static DROP_CERT_BUNDLE: LazyLock<Vec<Certificate>> = LazyLock::new(fetch_certificates);
|
|
||||||
pub static DROP_CLIENT_SYNC: LazyLock<reqwest::blocking::Client> = LazyLock::new(get_client_sync);
|
|
||||||
pub static DROP_CLIENT_ASYNC: LazyLock<ClientWithMiddleware> = LazyLock::new(get_client_async);
|
|
||||||
pub static DROP_CLIENT_WS_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(get_client_ws);
|
|
||||||
|
|
||||||
pub static DROP_APP_HANDLE: LazyLock<Mutex<Option<AppHandle>>> = LazyLock::new(|| Mutex::new(None));
|
|
||||||
|
|
||||||
struct AutoOfflineMiddleware;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Middleware for AutoOfflineMiddleware {
|
|
||||||
async fn handle(
|
|
||||||
&self,
|
|
||||||
req: Request,
|
|
||||||
extensions: &mut Extensions,
|
|
||||||
next: Next<'_>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
let res = next.run(req, extensions).await;
|
|
||||||
match res {
|
|
||||||
Ok(res) => {
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let lock = DROP_APP_HANDLE.lock().await;
|
|
||||||
if let Some(app_handle) = &*lock {
|
|
||||||
let state = app_handle.state::<std::sync::nonpoison::Mutex<AppState>>();
|
|
||||||
let mut state_lock = state.lock();
|
|
||||||
if state_lock.status == AppStatus::Offline {
|
|
||||||
state_lock.status = AppStatus::SignedIn;
|
|
||||||
app_handle
|
|
||||||
.emit("update_state", &*state_lock)
|
|
||||||
.expect("failed to emit state update");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(res)
|
|
||||||
}
|
|
||||||
Err(err) => match err {
|
|
||||||
Error::Middleware(error) => Err(Error::Middleware(error)),
|
|
||||||
Error::Reqwest(error) => {
|
|
||||||
if error.is_connect() {
|
|
||||||
// Spawn to defer this action - the state will most likely be locked
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let lock = DROP_APP_HANDLE.lock().await;
|
|
||||||
if let Some(app_handle) = &*lock {
|
|
||||||
let state =
|
|
||||||
app_handle.state::<std::sync::nonpoison::Mutex<AppState>>();
|
|
||||||
let mut state_lock = state.lock();
|
|
||||||
state_lock.status = AppStatus::Offline;
|
|
||||||
app_handle
|
|
||||||
.emit("update_state", &*state_lock)
|
|
||||||
.expect("failed to emit state update");
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
Err(Error::Reqwest(error))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch_certificates() -> Vec<Certificate> {
|
|
||||||
let certificate_dir = DATA_ROOT_DIR.join("certificates");
|
|
||||||
|
|
||||||
let mut certs = Vec::new();
|
|
||||||
match fs::read_dir(certificate_dir) {
|
|
||||||
Ok(c) => {
|
|
||||||
for entry in c {
|
|
||||||
match entry {
|
|
||||||
Ok(c) => {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
match File::open(c.path()) {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"Failed to open file at {} with error {}",
|
|
||||||
c.path().display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.read_to_end(&mut buf)
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"Failed to read to end of certificate file {} with error {}",
|
|
||||||
c.path().display(),
|
|
||||||
e
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
match Certificate::from_pem_bundle(&buf) {
|
|
||||||
Ok(certificates) => {
|
|
||||||
for cert in certificates {
|
|
||||||
certs.push(cert);
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
"added {} certificate(s) from {}",
|
|
||||||
certs.len(),
|
|
||||||
c.file_name().display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => warn!(
|
|
||||||
"Invalid certificate file {} with error {}",
|
|
||||||
c.path().display(),
|
|
||||||
e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => todo!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!("not loading certificates due to error: {e}");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
certs
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_client_sync() -> reqwest::blocking::Client {
|
|
||||||
let mut client = reqwest::blocking::ClientBuilder::new();
|
|
||||||
|
|
||||||
for cert in DROP_CERT_BUNDLE.iter() {
|
|
||||||
client = client.add_root_certificate(cert.clone());
|
|
||||||
}
|
|
||||||
client
|
|
||||||
.use_rustls_tls()
|
|
||||||
.user_agent("Drop Desktop Client")
|
|
||||||
.connect_timeout(Duration::from_millis(1500))
|
|
||||||
.build()
|
|
||||||
.expect("Failed to build synchronous client")
|
|
||||||
}
|
|
||||||
pub fn get_client_async() -> ClientWithMiddleware {
|
|
||||||
let mut client = reqwest::ClientBuilder::new();
|
|
||||||
|
|
||||||
for cert in DROP_CERT_BUNDLE.iter() {
|
|
||||||
client = client.add_root_certificate(cert.clone());
|
|
||||||
}
|
|
||||||
let normal_client = client
|
|
||||||
.use_rustls_tls()
|
|
||||||
.user_agent("Drop Desktop Client")
|
|
||||||
.build()
|
|
||||||
.expect("Failed to build asynchronous client");
|
|
||||||
|
|
||||||
ClientBuilder::new(normal_client)
|
|
||||||
.with(AutoOfflineMiddleware)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
pub fn get_client_ws() -> reqwest::Client {
|
|
||||||
let mut client = reqwest::ClientBuilder::new();
|
|
||||||
|
|
||||||
for cert in DROP_CERT_BUNDLE.iter() {
|
|
||||||
client = client.add_root_certificate(cert.clone());
|
|
||||||
}
|
|
||||||
client
|
|
||||||
.use_rustls_tls()
|
|
||||||
.user_agent("Drop Desktop Client")
|
|
||||||
.http1_only()
|
|
||||||
.build()
|
|
||||||
.expect("Failed to build websocket client")
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
use std::sync::nonpoison::Mutex;
|
|
||||||
|
|
||||||
use database::{borrow_db_checked, borrow_db_mut_checked};
|
|
||||||
use download_manager::DOWNLOAD_MANAGER;
|
|
||||||
use log::{debug, error};
|
|
||||||
use remote::requests::{generate_url, make_authenticated_get};
|
|
||||||
use tauri::AppHandle;
|
|
||||||
use tauri_plugin_autostart::ManagerExt;
|
|
||||||
use tauri_plugin_opener::OpenerExt;
|
|
||||||
|
|
||||||
use crate::AppState;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn fetch_state(state: tauri::State<'_, Mutex<AppState>>) -> Result<String, String> {
|
|
||||||
let guard = state.lock();
|
|
||||||
let cloned_state = serde_json::to_string(&guard.clone()).map_err(|e| e.to_string())?;
|
|
||||||
drop(guard);
|
|
||||||
Ok(cloned_state)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn quit(app: tauri::AppHandle) {
|
|
||||||
cleanup_and_exit(&app).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cleanup_and_exit(app: &AppHandle) {
|
|
||||||
debug!("cleaning up and exiting application");
|
|
||||||
match DOWNLOAD_MANAGER.ensure_terminated().await {
|
|
||||||
Ok(()) => debug!("download manager terminated correctly"),
|
|
||||||
Err(_) => error!("download manager failed to terminate correctly"),
|
|
||||||
}
|
|
||||||
|
|
||||||
app.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn toggle_autostart(app: AppHandle, enabled: bool) -> Result<(), String> {
|
|
||||||
let manager = app.autolaunch();
|
|
||||||
if enabled {
|
|
||||||
manager.enable().map_err(|e| e.to_string())?;
|
|
||||||
debug!("enabled autostart");
|
|
||||||
} else {
|
|
||||||
manager.disable().map_err(|e| e.to_string())?;
|
|
||||||
debug!("eisabled autostart");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the state in DB
|
|
||||||
let mut db_handle = borrow_db_mut_checked();
|
|
||||||
db_handle.settings.autostart = enabled;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_autostart_enabled(app: AppHandle) -> Result<bool, tauri_plugin_autostart::Error> {
|
|
||||||
let db_handle = borrow_db_checked();
|
|
||||||
let db_state = db_handle.settings.autostart;
|
|
||||||
drop(db_handle);
|
|
||||||
|
|
||||||
// Get actual system state
|
|
||||||
let manager = app.autolaunch();
|
|
||||||
let system_state = manager.is_enabled()?;
|
|
||||||
|
|
||||||
// If they don't match, sync to DB state
|
|
||||||
if db_state != system_state {
|
|
||||||
if db_state {
|
|
||||||
manager.enable()?;
|
|
||||||
} else {
|
|
||||||
manager.disable()?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(db_state)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn open_fs(path: String, app_handle: AppHandle) -> Result<(), tauri_plugin_opener::Error> {
|
|
||||||
app_handle.opener().open_path(path, None::<&str>)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn check_online() -> Result<bool, ()> {
|
|
||||||
let online = make_authenticated_get(generate_url(&["/api/v1/"], &[]).unwrap()).await.is_ok();
|
|
||||||
Ok(online)
|
|
||||||
}
|
|
||||||
75
src-tauri/src/client/autostart.rs
Normal file
75
src-tauri/src/client/autostart.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use crate::database::db::{borrow_db_checked, borrow_db_mut_checked};
|
||||||
|
use log::debug;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_autostart::ManagerExt;
|
||||||
|
|
||||||
|
pub fn toggle_autostart_logic(app: AppHandle, enabled: bool) -> Result<(), String> {
|
||||||
|
let manager = app.autolaunch();
|
||||||
|
if enabled {
|
||||||
|
manager.enable().map_err(|e| e.to_string())?;
|
||||||
|
debug!("enabled autostart");
|
||||||
|
} else {
|
||||||
|
manager.disable().map_err(|e| e.to_string())?;
|
||||||
|
debug!("eisabled autostart");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the state in DB
|
||||||
|
let mut db_handle = borrow_db_mut_checked();
|
||||||
|
db_handle.settings.autostart = enabled;
|
||||||
|
drop(db_handle);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_autostart_enabled_logic(app: AppHandle) -> Result<bool, tauri_plugin_autostart::Error> {
|
||||||
|
// First check DB state
|
||||||
|
let db_handle = borrow_db_checked();
|
||||||
|
let db_state = db_handle.settings.autostart;
|
||||||
|
drop(db_handle);
|
||||||
|
|
||||||
|
// Get actual system state
|
||||||
|
let manager = app.autolaunch();
|
||||||
|
let system_state = manager.is_enabled()?;
|
||||||
|
|
||||||
|
// If they don't match, sync to DB state
|
||||||
|
if db_state != system_state {
|
||||||
|
if db_state {
|
||||||
|
manager.enable()?;
|
||||||
|
} else {
|
||||||
|
manager.disable()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(db_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New function to sync state on startup
|
||||||
|
pub fn sync_autostart_on_startup(app: &AppHandle) -> Result<(), String> {
|
||||||
|
let db_handle = borrow_db_checked();
|
||||||
|
let should_be_enabled = db_handle.settings.autostart;
|
||||||
|
drop(db_handle);
|
||||||
|
|
||||||
|
let manager = app.autolaunch();
|
||||||
|
let current_state = manager.is_enabled().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if current_state != should_be_enabled {
|
||||||
|
if should_be_enabled {
|
||||||
|
manager.enable().map_err(|e| e.to_string())?;
|
||||||
|
debug!("synced autostart: enabled");
|
||||||
|
} else {
|
||||||
|
manager.disable().map_err(|e| e.to_string())?;
|
||||||
|
debug!("synced autostart: disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn toggle_autostart(app: AppHandle, enabled: bool) -> Result<(), String> {
|
||||||
|
toggle_autostart_logic(app, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_autostart_enabled(app: AppHandle) -> Result<bool, tauri_plugin_autostart::Error> {
|
||||||
|
get_autostart_enabled_logic(app)
|
||||||
|
}
|
||||||
23
src-tauri/src/client/cleanup.rs
Normal file
23
src-tauri/src/client/cleanup.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use log::{debug, error};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quit(app: tauri::AppHandle, state: tauri::State<'_, std::sync::Mutex<AppState<'_>>>) {
|
||||||
|
cleanup_and_exit(&app, &state);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cleanup_and_exit(app: &AppHandle, state: &tauri::State<'_, std::sync::Mutex<AppState<'_>>>) {
|
||||||
|
debug!("cleaning up and exiting application");
|
||||||
|
let download_manager = state.lock().unwrap().download_manager.clone();
|
||||||
|
match download_manager.ensure_terminated() {
|
||||||
|
Ok(res) => match res {
|
||||||
|
Ok(()) => debug!("download manager terminated correctly"),
|
||||||
|
Err(()) => error!("download manager failed to terminate correctly"),
|
||||||
|
},
|
||||||
|
Err(e) => panic!("{e:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
11
src-tauri/src/client/commands.rs
Normal file
11
src-tauri/src/client/commands.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn fetch_state(
|
||||||
|
state: tauri::State<'_, std::sync::Mutex<AppState<'_>>>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let guard = state.lock().unwrap();
|
||||||
|
let cloned_state = serde_json::to_string(&guard.clone()).map_err(|e| e.to_string())?;
|
||||||
|
drop(guard);
|
||||||
|
Ok(cloned_state)
|
||||||
|
}
|
||||||
3
src-tauri/src/client/mod.rs
Normal file
3
src-tauri/src/client/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod autostart;
|
||||||
|
pub mod cleanup;
|
||||||
|
pub mod commands;
|
||||||
102
src-tauri/src/cloud_saves/backup_manager.rs
Normal file
102
src-tauri/src/cloud_saves/backup_manager.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
use std::{collections::HashMap, path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
|
use log::warn;
|
||||||
|
|
||||||
|
use crate::{database::db::{GameVersion, DATA_ROOT_DIR}, error::backup_error::BackupError, process::process_manager::Platform};
|
||||||
|
|
||||||
|
use super::path::CommonPath;
|
||||||
|
|
||||||
|
pub struct BackupManager<'a> {
|
||||||
|
pub current_platform: Platform,
|
||||||
|
pub sources: HashMap<(Platform, Platform), &'a (dyn BackupHandler + Sync + Send)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackupManager<'_> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
BackupManager {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
current_platform: Platform::Windows,
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
current_platform: Platform::MacOs,
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
current_platform: Platform::Linux,
|
||||||
|
|
||||||
|
sources: HashMap::from([
|
||||||
|
// Current platform to target platform
|
||||||
|
(
|
||||||
|
(Platform::Windows, Platform::Windows),
|
||||||
|
&WindowsBackupManager {} as &(dyn BackupHandler + Sync + Send),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(Platform::Linux, Platform::Linux),
|
||||||
|
&LinuxBackupManager {} as &(dyn BackupHandler + Sync + Send),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(Platform::MacOs, Platform::MacOs),
|
||||||
|
&MacBackupManager {} as &(dyn BackupHandler + Sync + Send),
|
||||||
|
),
|
||||||
|
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait BackupHandler: Send + Sync {
|
||||||
|
fn root_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(DATA_ROOT_DIR.lock().unwrap().join("games")) }
|
||||||
|
fn game_translate(&self, _path: &PathBuf, game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(PathBuf::from_str(&game.game_id).unwrap()) }
|
||||||
|
fn base_translate(&self, path: &PathBuf, game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(self.root_translate(path, game)?.join(self.game_translate(path, game)?)) }
|
||||||
|
fn home_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { let c = CommonPath::Home.get().ok_or(BackupError::NotFound); println!("{:?}", c); c }
|
||||||
|
fn store_user_id_translate(&self, _path: &PathBuf, game: &GameVersion) -> Result<PathBuf, BackupError> { PathBuf::from_str(&game.game_id).map_err(|_| BackupError::ParseError) }
|
||||||
|
fn os_user_name_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(PathBuf::from_str(&whoami::username()).unwrap()) }
|
||||||
|
fn win_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winAppData>"); Err(BackupError::InvalidSystem) }
|
||||||
|
fn win_local_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winLocalAppData>"); Err(BackupError::InvalidSystem) }
|
||||||
|
fn win_local_app_data_low_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winLocalAppDataLow>"); Err(BackupError::InvalidSystem) }
|
||||||
|
fn win_documents_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winDocuments>"); Err(BackupError::InvalidSystem) }
|
||||||
|
fn win_public_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winPublic>"); Err(BackupError::InvalidSystem) }
|
||||||
|
fn win_program_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winProgramData>"); Err(BackupError::InvalidSystem) }
|
||||||
|
fn win_dir_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winDir>"); Err(BackupError::InvalidSystem) }
|
||||||
|
fn xdg_data_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected XDG Reference in Backup <xdgData>"); Err(BackupError::InvalidSystem) }
|
||||||
|
fn xdg_config_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected XDG Reference in Backup <xdgConfig>"); Err(BackupError::InvalidSystem) }
|
||||||
|
fn skip_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(PathBuf::new()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LinuxBackupManager {}
|
||||||
|
impl BackupHandler for LinuxBackupManager {
|
||||||
|
fn xdg_config_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> {
|
||||||
|
Ok(CommonPath::Data.get().ok_or(BackupError::NotFound)?)
|
||||||
|
}
|
||||||
|
fn xdg_data_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> {
|
||||||
|
Ok(CommonPath::Config.get().ok_or(BackupError::NotFound)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub struct WindowsBackupManager {}
|
||||||
|
impl BackupHandler for WindowsBackupManager {
|
||||||
|
fn win_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
|
||||||
|
Ok(CommonPath::Config.get().ok_or(BackupError::NotFound)?)
|
||||||
|
}
|
||||||
|
fn win_local_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
|
||||||
|
Ok(CommonPath::DataLocal.get().ok_or(BackupError::NotFound)?)
|
||||||
|
}
|
||||||
|
fn win_local_app_data_low_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
|
||||||
|
Ok(CommonPath::DataLocalLow.get().ok_or(BackupError::NotFound)?)
|
||||||
|
}
|
||||||
|
fn win_dir_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> {
|
||||||
|
Ok(PathBuf::from_str("C:/Windows").unwrap())
|
||||||
|
}
|
||||||
|
fn win_documents_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
|
||||||
|
Ok(CommonPath::Document.get().ok_or(BackupError::NotFound)?)
|
||||||
|
|
||||||
|
}
|
||||||
|
fn win_program_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
|
||||||
|
Ok(PathBuf::from_str("C:/ProgramData").unwrap())
|
||||||
|
}
|
||||||
|
fn win_public_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
|
||||||
|
Ok(CommonPath::Public.get().ok_or(BackupError::NotFound)?)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub struct MacBackupManager {}
|
||||||
|
impl BackupHandler for MacBackupManager {}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
use database::platform::Platform;
|
use crate::process::process_manager::Platform;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub enum Condition {
|
pub enum Condition {
|
||||||
Os(Platform),
|
Os(Platform)
|
||||||
Other
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use database::GameVersion;
|
use crate::database::db::GameVersion;
|
||||||
|
|
||||||
|
use super::conditions::{Condition};
|
||||||
|
|
||||||
use super::conditions::Condition;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct CloudSaveMetadata {
|
pub struct CloudSaveMetadata {
|
||||||
@@ -15,17 +16,15 @@ pub struct GameFile {
|
|||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
pub data_type: DataType,
|
pub data_type: DataType,
|
||||||
pub tags: Vec<Tag>,
|
pub tags: Vec<Tag>,
|
||||||
pub conditions: Vec<Condition>,
|
pub conditions: Vec<Condition>
|
||||||
}
|
}
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
||||||
pub enum DataType {
|
pub enum DataType {
|
||||||
Registry,
|
Registry,
|
||||||
File,
|
File,
|
||||||
Other,
|
Other
|
||||||
}
|
}
|
||||||
#[derive(
|
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
||||||
Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
|
||||||
)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum Tag {
|
pub enum Tag {
|
||||||
Config,
|
Config,
|
||||||
@@ -33,4 +32,4 @@ pub enum Tag {
|
|||||||
#[default]
|
#[default]
|
||||||
#[serde(other)]
|
#[serde(other)]
|
||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use database::platform::Platform;
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use crate::process::process_manager::Platform;
|
||||||
|
|
||||||
use super::placeholder::*;
|
use super::placeholder::*;
|
||||||
|
|
||||||
|
|
||||||
pub fn normalize(path: &str, os: Platform) -> String {
|
pub fn normalize(path: &str, os: Platform) -> String {
|
||||||
let mut path = path.trim().trim_end_matches(['/', '\\']).replace('\\', "/");
|
let mut path = path.trim().trim_end_matches(['/', '\\']).replace('\\', "/");
|
||||||
|
|
||||||
@@ -13,25 +14,18 @@ pub fn normalize(path: &str, os: Platform) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static CONSECUTIVE_SLASHES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"/{2,}").unwrap());
|
static CONSECUTIVE_SLASHES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"/{2,}").unwrap());
|
||||||
static UNNECESSARY_DOUBLE_STAR_1: LazyLock<Regex> =
|
static UNNECESSARY_DOUBLE_STAR_1: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"([^/*])\*{2,}").unwrap());
|
||||||
LazyLock::new(|| Regex::new(r"([^/*])\*{2,}").unwrap());
|
static UNNECESSARY_DOUBLE_STAR_2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*{2,}([^/*])").unwrap());
|
||||||
static UNNECESSARY_DOUBLE_STAR_2: LazyLock<Regex> =
|
|
||||||
LazyLock::new(|| Regex::new(r"\*{2,}([^/*])").unwrap());
|
|
||||||
static ENDING_WILDCARD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\*)+$").unwrap());
|
static ENDING_WILDCARD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\*)+$").unwrap());
|
||||||
static ENDING_DOT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\.)$").unwrap());
|
static ENDING_DOT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\.)$").unwrap());
|
||||||
static INTERMEDIATE_DOT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\./)").unwrap());
|
static INTERMEDIATE_DOT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\./)").unwrap());
|
||||||
static BLANK_SEGMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\s+/)").unwrap());
|
static BLANK_SEGMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(/\s+/)").unwrap());
|
||||||
static APP_DATA: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%appdata%").unwrap());
|
static APP_DATA: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%appdata%").unwrap());
|
||||||
static APP_DATA_ROAMING: LazyLock<Regex> =
|
static APP_DATA_ROAMING: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Roaming").unwrap());
|
||||||
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: LazyLock<Regex> =
|
static APP_DATA_LOCAL_2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Local/").unwrap());
|
||||||
LazyLock::new(|| Regex::new(r"(?i)%localappdata%").unwrap());
|
static USER_PROFILE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%").unwrap());
|
||||||
static APP_DATA_LOCAL_2: LazyLock<Regex> =
|
static DOCUMENTS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/Documents").unwrap());
|
||||||
LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Local/").unwrap());
|
|
||||||
static USER_PROFILE: LazyLock<Regex> =
|
|
||||||
LazyLock::new(|| Regex::new(r"(?i)%userprofile%").unwrap());
|
|
||||||
static DOCUMENTS: LazyLock<Regex> =
|
|
||||||
LazyLock::new(|| Regex::new(r"(?i)%userprofile%/Documents").unwrap());
|
|
||||||
|
|
||||||
for (pattern, replacement) in [
|
for (pattern, replacement) in [
|
||||||
(&CONSECUTIVE_SLASHES, "/"),
|
(&CONSECUTIVE_SLASHES, "/"),
|
||||||
@@ -72,9 +66,7 @@ pub fn normalize(path: &str, os: Platform) -> String {
|
|||||||
|
|
||||||
fn too_broad(path: &str) -> bool {
|
fn too_broad(path: &str) -> bool {
|
||||||
println!("Path: {}", path);
|
println!("Path: {}", path);
|
||||||
use {
|
use {BASE, HOME, ROOT, STORE_USER_ID, WIN_APP_DATA, WIN_DIR, WIN_DOCUMENTS, XDG_CONFIG, XDG_DATA};
|
||||||
BASE, HOME, ROOT, STORE_USER_ID, WIN_APP_DATA, WIN_DIR, WIN_DOCUMENTS, XDG_CONFIG, XDG_DATA,
|
|
||||||
};
|
|
||||||
|
|
||||||
let path_lower = path.to_lowercase();
|
let path_lower = path.to_lowercase();
|
||||||
|
|
||||||
@@ -85,9 +77,7 @@ fn too_broad(path: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for item in AVOID_WILDCARDS {
|
for item in AVOID_WILDCARDS {
|
||||||
if path.starts_with(&format!("{}/*", item))
|
if path.starts_with(&format!("{}/*", item)) || path.starts_with(&format!("{}/{}", item, STORE_USER_ID)) {
|
||||||
|| path.starts_with(&format!("{}/{}", item, STORE_USER_ID))
|
|
||||||
{
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +124,7 @@ fn too_broad(path: &str) -> bool {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Drive letters:
|
// Drive letters:
|
||||||
let drives: Regex = Regex::new(r"^[a-zA-Z]:$").unwrap();
|
let drives: Regex = Regex::new(r"^[a-zA-Z]:$").unwrap();
|
||||||
@@ -168,4 +159,4 @@ pub fn usable(path: &str) -> bool {
|
|||||||
&& !path.starts_with("../")
|
&& !path.starts_with("../")
|
||||||
&& !too_broad(path)
|
&& !too_broad(path)
|
||||||
&& !unprintable.is_match(path)
|
&& !unprintable.is_match(path)
|
||||||
}
|
}
|
||||||
@@ -13,12 +13,12 @@ pub enum CommonPath {
|
|||||||
|
|
||||||
impl CommonPath {
|
impl CommonPath {
|
||||||
pub fn get(&self) -> Option<PathBuf> {
|
pub fn get(&self) -> Option<PathBuf> {
|
||||||
static CONFIG: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::config_dir);
|
static CONFIG: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::config_dir());
|
||||||
static DATA: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::data_dir);
|
static DATA: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::data_dir());
|
||||||
static DATA_LOCAL: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::data_local_dir);
|
static DATA_LOCAL: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::data_local_dir());
|
||||||
static DOCUMENT: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::document_dir);
|
static DOCUMENT: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::document_dir());
|
||||||
static HOME: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::home_dir);
|
static HOME: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::home_dir());
|
||||||
static PUBLIC: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::public_dir);
|
static PUBLIC: LazyLock<Option<PathBuf>> = LazyLock::new(|| dirs::public_dir());
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
static DATA_LOCAL_LOW: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
|
static DATA_LOCAL_LOW: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
|
||||||
@@ -48,4 +48,4 @@ pub const XDG_DATA: &str = "<xdgData>"; // %WINDIR% on Windows
|
|||||||
pub const XDG_CONFIG: &str = "<xdgConfig>"; // $XDG_DATA_HOME on Linux
|
pub const XDG_CONFIG: &str = "<xdgConfig>"; // $XDG_DATA_HOME on Linux
|
||||||
pub const SKIP: &str = "<skip>"; // $XDG_CONFIG_HOME on Linux
|
pub const SKIP: &str = "<skip>"; // $XDG_CONFIG_HOME on Linux
|
||||||
|
|
||||||
pub static OS_USERNAME: LazyLock<String> = LazyLock::new(whoami::username);
|
pub static OS_USERNAME: LazyLock<String> = LazyLock::new(|| whoami::username());
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
use std::{
|
use std::{
|
||||||
fs::{self, File, create_dir_all},
|
fs::{self, create_dir_all, File},
|
||||||
io::{self, Read, Write},
|
io::{self, ErrorKind, Read, Write},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
thread::sleep,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::BackupError;
|
use super::{
|
||||||
|
backup_manager::BackupHandler, conditions::Condition, metadata::GameFile, placeholder::*,
|
||||||
use super::{backup_manager::BackupHandler, placeholder::*};
|
};
|
||||||
use database::GameVersion;
|
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
use rustix::path::Arg;
|
use rustix::path::Arg;
|
||||||
use tempfile::tempfile;
|
use tempfile::tempfile;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
database::db::GameVersion, error::backup_error::BackupError, process::process_manager::Platform,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{backup_manager::BackupManager, metadata::CloudSaveMetadata, normalise::normalize};
|
use super::{backup_manager::BackupManager, metadata::CloudSaveMetadata, normalise::normalize};
|
||||||
|
|
||||||
pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
|
pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
|
||||||
@@ -26,7 +31,7 @@ pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|p| match p {
|
.find_map(|p| match p {
|
||||||
super::conditions::Condition::Os(os) => Some(os),
|
super::conditions::Condition::Os(os) => Some(os),
|
||||||
_ => None
|
_ => None,
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
{
|
{
|
||||||
@@ -59,7 +64,7 @@ pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
|
|||||||
let binding = serde_json::to_string(meta).unwrap();
|
let binding = serde_json::to_string(meta).unwrap();
|
||||||
let serialized = binding.as_bytes();
|
let serialized = binding.as_bytes();
|
||||||
let mut file = tempfile().unwrap();
|
let mut file = tempfile().unwrap();
|
||||||
file.write_all(serialized).unwrap();
|
file.write(serialized).unwrap();
|
||||||
tarball.append_file("metadata", &mut file).unwrap();
|
tarball.append_file("metadata", &mut file).unwrap();
|
||||||
tarball.into_inner().unwrap().finish().unwrap()
|
tarball.into_inner().unwrap().finish().unwrap()
|
||||||
}
|
}
|
||||||
@@ -92,7 +97,7 @@ pub fn extract(file: PathBuf) -> Result<(), BackupError> {
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|p| match p {
|
.find_map(|p| match p {
|
||||||
super::conditions::Condition::Os(os) => Some(os),
|
super::conditions::Condition::Os(os) => Some(os),
|
||||||
_ => None
|
_ => None,
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
{
|
{
|
||||||
@@ -111,7 +116,7 @@ pub fn extract(file: PathBuf) -> Result<(), BackupError> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let new_path = parse_path(file.path.into(), handler, &manifest.game_version)?;
|
let new_path = parse_path(file.path.into(), handler, &manifest.game_version)?;
|
||||||
create_dir_all(new_path.parent().unwrap()).unwrap();
|
create_dir_all(&new_path.parent().unwrap()).unwrap();
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Current path {:?} copying to {:?}",
|
"Current path {:?} copying to {:?}",
|
||||||
@@ -128,22 +133,23 @@ pub fn copy_item<P: AsRef<Path>>(src: P, dest: P) -> io::Result<()> {
|
|||||||
let src_path = src.as_ref();
|
let src_path = src.as_ref();
|
||||||
let dest_path = dest.as_ref();
|
let dest_path = dest.as_ref();
|
||||||
|
|
||||||
let metadata = fs::metadata(src_path)?;
|
let metadata = fs::metadata(&src_path)?;
|
||||||
|
|
||||||
if metadata.is_file() {
|
if metadata.is_file() {
|
||||||
// Ensure the parent directory of the destination exists for a file copy
|
// Ensure the parent directory of the destination exists for a file copy
|
||||||
if let Some(parent) = dest_path.parent() {
|
if let Some(parent) = dest_path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
fs::copy(src_path, dest_path)?;
|
fs::copy(&src_path, &dest_path)?;
|
||||||
} else if metadata.is_dir() {
|
} else if metadata.is_dir() {
|
||||||
// For directories, we call the recursive helper function.
|
// For directories, we call the recursive helper function.
|
||||||
// The destination for the recursive copy is the `dest_path` itself.
|
// The destination for the recursive copy is the `dest_path` itself.
|
||||||
copy_dir_recursive(src_path, dest_path)?;
|
copy_dir_recursive(&src_path, &dest_path)?;
|
||||||
} else {
|
} else {
|
||||||
// Handle other file types like symlinks if necessary,
|
// Handle other file types like symlinks if necessary,
|
||||||
// for now, return an error or skip.
|
// for now, return an error or skip.
|
||||||
return Err(io::Error::other(
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::Other,
|
||||||
format!("Source {:?} is neither a file nor a directory", src_path),
|
format!("Source {:?} is neither a file nor a directory", src_path),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -152,7 +158,7 @@ pub fn copy_item<P: AsRef<Path>>(src: P, dest: P) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
|
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
|
||||||
fs::create_dir_all(dest)?;
|
fs::create_dir_all(&dest)?;
|
||||||
|
|
||||||
for entry in fs::read_dir(src)? {
|
for entry in fs::read_dir(src)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
@@ -214,3 +220,43 @@ pub fn parse_path(
|
|||||||
println!("Final line: {:?}", &s);
|
println!("Final line: {:?}", &s);
|
||||||
Ok(s)
|
Ok(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn test() {
|
||||||
|
let mut meta = CloudSaveMetadata {
|
||||||
|
files: vec![
|
||||||
|
GameFile {
|
||||||
|
path: String::from("<home>/favicon.png"),
|
||||||
|
id: None,
|
||||||
|
data_type: super::metadata::DataType::File,
|
||||||
|
tags: Vec::new(),
|
||||||
|
conditions: vec![Condition::Os(Platform::Linux)],
|
||||||
|
},
|
||||||
|
GameFile {
|
||||||
|
path: String::from("<home>/Documents/Pixel Art"),
|
||||||
|
id: None,
|
||||||
|
data_type: super::metadata::DataType::File,
|
||||||
|
tags: Vec::new(),
|
||||||
|
conditions: vec![Condition::Os(Platform::Linux)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
game_version: GameVersion {
|
||||||
|
game_id: String::new(),
|
||||||
|
version_name: String::new(),
|
||||||
|
platform: Platform::Linux,
|
||||||
|
launch_command: String::new(),
|
||||||
|
launch_args: Vec::new(),
|
||||||
|
launch_command_template: String::new(),
|
||||||
|
setup_command: String::new(),
|
||||||
|
setup_args: Vec::new(),
|
||||||
|
setup_command_template: String::new(),
|
||||||
|
only_setup: true,
|
||||||
|
version_index: 0,
|
||||||
|
delta: false,
|
||||||
|
umu_id_override: None,
|
||||||
|
},
|
||||||
|
save_id: String::from("aaaaaaa"),
|
||||||
|
};
|
||||||
|
//resolve(&mut meta);
|
||||||
|
|
||||||
|
extract("save".into()).unwrap();
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use std::sync::nonpoison::Mutex;
|
|
||||||
|
|
||||||
use client::app_state::AppState;
|
|
||||||
use database::{GameDownloadStatus, borrow_db_checked};
|
|
||||||
use games::collections::collection::Collections;
|
|
||||||
use remote::{
|
|
||||||
cache::{cache_object, get_cached_object},
|
|
||||||
error::RemoteAccessError,
|
|
||||||
offline,
|
|
||||||
requests::{generate_url, make_authenticated_get},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub async fn fetch_collections(
|
|
||||||
state: tauri::State<'_, Mutex<AppState>>,
|
|
||||||
hard_refresh: Option<bool>,
|
|
||||||
) -> Result<Collections, RemoteAccessError> {
|
|
||||||
offline!(
|
|
||||||
state,
|
|
||||||
fetch_collections_online,
|
|
||||||
fetch_collections_offline,
|
|
||||||
hard_refresh
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn fetch_collections_online(
|
|
||||||
hard_refresh: Option<bool>,
|
|
||||||
) -> Result<Collections, RemoteAccessError> {
|
|
||||||
let do_hard_refresh = hard_refresh.unwrap_or(false);
|
|
||||||
if !do_hard_refresh && let Ok(cached_response) = get_cached_object::<Collections>("collections")
|
|
||||||
{
|
|
||||||
return Ok(cached_response);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response =
|
|
||||||
make_authenticated_get(generate_url(&["/api/v1/client/collection"], &[])?).await?;
|
|
||||||
|
|
||||||
let collections: Collections = response.json().await?;
|
|
||||||
|
|
||||||
cache_object("collections", &collections)?;
|
|
||||||
|
|
||||||
Ok(collections)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn fetch_collections_offline(
|
|
||||||
_hard_refresh: Option<bool>,
|
|
||||||
) -> Result<Collections, RemoteAccessError> {
|
|
||||||
let mut cached = get_cached_object::<Collections>("collections")?;
|
|
||||||
|
|
||||||
let db_handle = borrow_db_checked();
|
|
||||||
|
|
||||||
for collection in cached.iter_mut() {
|
|
||||||
collection.entries.retain(|v| {
|
|
||||||
matches!(
|
|
||||||
&db_handle
|
|
||||||
.applications
|
|
||||||
.game_statuses
|
|
||||||
.get(&v.game_id)
|
|
||||||
.unwrap_or(&GameDownloadStatus::Remote {}),
|
|
||||||
GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. }
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(cached)
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,18 @@ use std::{
|
|||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use database::{
|
|
||||||
Settings, borrow_db_checked, borrow_db_mut_checked, db::DATA_ROOT_DIR, debug::SystemData,
|
|
||||||
};
|
|
||||||
use download_manager::error::DownloadManagerError;
|
|
||||||
use games::scan::scan_install_dirs;
|
|
||||||
use log::error;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
database::{db::borrow_db_mut_checked, scan::scan_install_dirs}, error::download_manager_error::DownloadManagerError,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
db::{borrow_db_checked, DATA_ROOT_DIR},
|
||||||
|
debug::SystemData,
|
||||||
|
models::data::Settings,
|
||||||
|
};
|
||||||
|
|
||||||
// Will, in future, return disk/remaining size
|
// Will, in future, return disk/remaining size
|
||||||
// Just returns the directories that have been set up
|
// Just returns the directories that have been set up
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -63,25 +67,11 @@ pub fn add_download_dir(new_dir: PathBuf) -> Result<(), DownloadManagerError<()>
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn update_settings(new_settings: Value) {
|
pub fn update_settings(new_settings: Value) {
|
||||||
let mut db_lock = borrow_db_mut_checked();
|
let mut db_lock = borrow_db_mut_checked();
|
||||||
let mut current_settings =
|
let mut current_settings = serde_json::to_value(db_lock.settings.clone()).unwrap();
|
||||||
serde_json::to_value(db_lock.settings.clone()).expect("Failed to parse existing settings");
|
for (key, value) in new_settings.as_object().unwrap() {
|
||||||
let values = match new_settings.as_object() {
|
|
||||||
Some(values) => values,
|
|
||||||
None => {
|
|
||||||
error!("Could not parse settings values into object");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for (key, value) in values {
|
|
||||||
current_settings[key] = value.clone();
|
current_settings[key] = value.clone();
|
||||||
}
|
}
|
||||||
let new_settings: Settings = match serde_json::from_value(current_settings) {
|
let new_settings: Settings = serde_json::from_value(current_settings).unwrap();
|
||||||
Ok(settings) => settings,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Could not parse settings with error {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
db_lock.settings = new_settings;
|
db_lock.settings = new_settings;
|
||||||
}
|
}
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user