Compare commits

...

5 Commits

Author SHA1 Message Date
11e20f3ca9 dix: QueueMetadata needs varialbe "m_name" instead of "name"
Signed-off-by: quexeky <git@quexeky.dev>
2025-06-05 18:26:06 +10:00
ae6935554f fix: Make download_type for url downloads use DownloadType::Tool
Signed-off-by: quexeky <git@quexeky.dev>
2025-06-05 18:06:07 +10:00
69c71de5cd Merge remote-tracking branch 'origin/develop' into 42-feature-request-url-downloader 2025-06-05 17:47:12 +10:00
9b68ebc910 refactor: Converting useGame to DownloadableMetadata
Signed-off-by: quexeky <git@quexeky.dev>
2025-06-05 12:59:45 +10:00
beea0505d1 feat: Added basic url downloading
Signed-off-by: quexeky <git@quexeky.dev>
2025-06-03 16:52:48 +10:00
16 changed files with 362 additions and 116 deletions

View File

@ -1,19 +1,19 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Game, GameStatus, GameStatusEnum, GameVersion } from "~/types";
import { type Game, type GameStatus as DownloadStatus, type GameStatusEnum as DownloadStatusEnum, type GameVersion, type DownloadableMetadata, DownloadableType } from "~/types";
const gameRegistry: { [key: string]: { game: Game; version?: GameVersion } } =
{};
const gameStatusRegistry: { [key: string]: Ref<GameStatus> } = {};
const downloadStatusRegistry: Map<DownloadableMetadata, Ref<DownloadStatus>> = new Map();
type OptionGameStatus = { [key in GameStatusEnum]: { version_name?: string } };
export type SerializedGameStatus = [
{ type: GameStatusEnum },
OptionGameStatus | null
type OptionDownloadStatus = { [key in DownloadStatusEnum]: { version_name?: string } };
export type SerializedDownloadStatus = [
{ type: DownloadStatusEnum },
OptionDownloadStatus | null
];
export const parseStatus = (status: SerializedGameStatus): GameStatus => {
export const parseStatus = (status: SerializedDownloadStatus): DownloadStatus => {
console.log(status);
if (status[0]) {
return {
@ -22,7 +22,7 @@ export const parseStatus = (status: SerializedGameStatus): GameStatus => {
} else if (status[1]) {
const [[gameStatus, options]] = Object.entries(status[1]);
return {
type: gameStatus as GameStatusEnum,
type: gameStatus as DownloadStatusEnum,
...options,
};
} else {
@ -30,43 +30,54 @@ export const parseStatus = (status: SerializedGameStatus): GameStatus => {
}
};
export const useGame = async (gameId: string) => {
if (!gameRegistry[gameId]) {
const data: {
game: Game;
status: SerializedGameStatus;
version?: GameVersion;
} = await invoke("fetch_game", {
gameId,
});
gameRegistry[gameId] = { game: data.game, version: data.version };
if (!gameStatusRegistry[gameId]) {
gameStatusRegistry[gameId] = ref(parseStatus(data.status));
export const useStatus = (meta: DownloadableMetadata) => {
return downloadStatusRegistry.get(meta)
}
listen(`update_game/${gameId}`, (event) => {
const payload: {
status: SerializedGameStatus;
version?: GameVersion;
} = event.payload as any;
console.log(payload.status);
gameStatusRegistry[gameId].value = parseStatus(payload.status);
/**
* I am not super happy about this.
*
* This will mean that we will still have a version assigned if we have a game installed then uninstall it.
* It is necessary because a flag to check if we should overwrite seems excessive, and this function gets called
* on transient state updates.
*/
if (payload.version) {
gameRegistry[gameId].version = payload.version;
}
});
}
export const useGame = async (gameId: string) => {
const data: {
game: Game;
status: SerializedDownloadStatus;
version?: GameVersion;
} = await invoke("fetch_game", {
gameId,
});
const meta = {
id: gameId,
version: data.version?.versionName,
downloadType: DownloadableType.Game
} satisfies DownloadableMetadata;
if (!gameRegistry[gameId]) {
gameRegistry[gameId] = { game: data.game, version: data.version };
}
if (!downloadStatusRegistry.has(meta)) {
downloadStatusRegistry.set(meta, ref(parseStatus(data.status)));
listen(`update_game/${gameId}`, (event) => {
const payload: {
status: SerializedDownloadStatus;
version?: GameVersion;
} = event.payload as any;
downloadStatusRegistry.get(meta)!.value = parseStatus(payload.status);
/**
* I am not super happy about this.
*
* This will mean that we will still have a version assigned if we have a game installed then uninstall it.
* It is necessary because a flag to check if we should overwrite seems excessive, and this function gets called
* on transient state updates.
*/
if (payload.version) {
gameRegistry[gameId].version = payload.version;
}
});
}
const game = gameRegistry[gameId];
const status = gameStatusRegistry[gameId];
const status = downloadStatusRegistry.get(meta)!;
return { ...game, status };
};

View File

@ -471,6 +471,7 @@ const router = useRouter();
const id = route.params.id.toString();
const { game: rawGame, status } = await useGame(id);
console.log("status: ", status);
const game = ref(rawGame);
const remoteUrl: string = await invoke("gen_drop_url", {

View File

@ -1,46 +1,32 @@
<template>
<div class="bg-zinc-950 p-4 min-h-full space-y-4">
<div
class="h-16 overflow-hidden relative rounded-xl flex flex-row border border-zinc-900"
>
<div class="h-16 overflow-hidden relative rounded-xl flex flex-row border border-zinc-900">
<div
class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 text-blue-400 font-display items-left justify-center pl-2"
>
class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 text-blue-400 font-display items-left justify-center pl-2">
<span class="font-semibold">{{ formatKilobytes(stats.speed) }}/s</span>
<span v-if="stats.time > 0" class="text-sm"
>{{ formatTime(stats.time) }} left</span
>
<span v-if="stats.time > 0" class="text-sm">{{ formatTime(stats.time) }} left</span>
</div>
<div class="absolute inset-0 h-full flex flex-row items-end justify-end">
<div
v-for="bar in speedHistory"
:style="{ height: `${(bar / speedMax) * 100}%` }"
class="w-[8px] bg-blue-600/40"
/>
<div v-for="bar in speedHistory" :style="{ height: `${(bar / speedMax) * 100}%` }"
class="w-[8px] bg-blue-600/40" />
</div>
</div>
<draggable v-model="queue.queue" @end="onEnd">
<template #item="{ element }: { element: (typeof queue.value.queue)[0] }">
<li
v-if="games[element.meta.id]"
:key="element.meta.id"
class="mb-4 bg-zinc-900 rounded-lg flex flex-row justify-between gap-x-6 py-5 px-4"
>
<li v-if="downloads.has(element.meta)" :key="element.meta.id"
class="mb-4 bg-zinc-900 rounded-lg flex flex-row justify-between gap-x-6 py-5 px-4">
<div class="w-full flex items-center max-w-md gap-x-4 relative">
<img
class="size-24 flex-none bg-zinc-800 object-cover rounded"
:src="games[element.meta.id].cover"
alt=""
/>
<img class="size-24 flex-none bg-zinc-800 object-cover rounded" :src="downloads.get(element.meta)!.queueMeta.cover"
alt="" />
<div class="min-w-0 flex-auto">
<p class="text-xl font-semibold text-zinc-100">
<NuxtLink :href="`/library/${element.meta.id}`" class="">
<span class="absolute inset-x-0 -top-px bottom-0" />
{{ games[element.meta.id].game.mName }}
{{ downloads.get(element.meta)!.queueMeta.mName }}
</NuxtLink>
</p>
<p class="mt-1 flex text-xs/5 text-gray-500">
{{ games[element.meta.id].game.mShortDescription }}
{{ downloads.get(element.meta)!.queueMeta.mShortDescription }}
</p>
</div>
</div>
@ -49,40 +35,28 @@
<p class="text-md text-zinc-500 uppercase font-display font-bold">
{{ element.status }}
</p>
<div
v-if="element.progress"
class="mt-1 w-96 bg-zinc-800 rounded-lg overflow-hidden"
>
<div
class="h-2 bg-blue-600"
:style="{ width: `${element.progress * 100}%` }"
/>
<div v-if="element.progress" class="mt-1 w-96 bg-zinc-800 rounded-lg overflow-hidden">
<div class="h-2 bg-blue-600" :style="{ width: `${element.progress * 100}%` }" />
</div>
<span
class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display"
><span class="text-zinc-300">{{
formatKilobytes(element.current / 1000)
}}</span>
<span class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display"><span
class="text-zinc-300">{{
formatKilobytes(element.current / 1000)
}}</span>
/
<span class="">{{ formatKilobytes(element.max / 1000) }}</span
><ServerIcon class="size-5"
/></span>
<span class="">{{ formatKilobytes(element.max / 1000) }}</span>
<ServerIcon class="size-5" />
</span>
</div>
<button @click="() => cancelGame(element.meta)" class="group">
<XMarkIcon
class="transition size-8 flex-none text-zinc-600 group-hover:text-zinc-300"
aria-hidden="true"
/>
<XMarkIcon class="transition size-8 flex-none text-zinc-600 group-hover:text-zinc-300"
aria-hidden="true" />
</button>
</div>
</li>
<p v-else>Loading...</p>
</template>
</draggable>
<div
class="text-zinc-600 uppercase font-semibold font-display w-full text-center"
v-if="queue.queue.length == 0"
>
<div class="text-zinc-600 uppercase font-semibold font-display w-full text-center" v-if="queue.queue.length == 0">
No items in the queue
</div>
</div>
@ -91,7 +65,8 @@
<script setup lang="ts">
import { ServerIcon, XMarkIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import type { DownloadableMetadata, Game, GameStatus } from "~/types";
import { useStatus } from "~/composables/game";
import type { DownloadableMetadata, Game, GameStatus, QueueMetadata } from "~/types";
const windowWidth = ref(window.innerWidth);
window.addEventListener("resize", (event) => {
@ -107,9 +82,7 @@ const speedMax = computed(
);
const previousGameId = ref<string | undefined>();
const games: Ref<{
[key: string]: { game: Game; status: Ref<GameStatus>; cover: string };
}> = ref({});
const downloads: Ref<Map<DownloadableMetadata, { queueMeta: QueueMetadata, status: Ref<GameStatus> }>> = ref(new Map());
function resetHistoryGraph() {
speedHistory.value = [];
@ -153,13 +126,14 @@ watch(stats, (v) => {
function loadGamesForQueue(v: typeof queue.value) {
for (const {
meta: { id },
meta,
} of v.queue) {
if (games.value[id]) return;
if (downloads.value.get(meta)) return;
(async () => {
const gameData = await useGame(id);
const cover = await useObject(gameData.game.mCoverObjectId);
games.value[id] = { ...gameData, cover };
const queueMeta: QueueMetadata = await invoke("get_queue_metadata", {meta});
const status = useStatus(meta)!;
const cover = await useObject(queueMeta.cover);
downloads.value.set(meta, { queueMeta: { ...queueMeta, cover }, status });
})();
}
}

View File

@ -68,7 +68,7 @@
Open Data Directory
</button>
<button
@click="() => openLogFile()"
@click="() => queue_url_download()"
type="button"
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
@ -115,6 +115,15 @@ dataDir.value = systemData.dataDir;
const currentPlatform = await platform();
platformInfo.value = currentPlatform;
async function queue_url_download() {
try {
await invoke("queue_url_download", { url: "https://codeload.github.com/Drop-OSS/drop-app/zip/refs/heads/develop"});
}
catch (e) {
console.error(e);
}
}
async function openDataDir() {
if (!dataDir.value) return;
try {

View File

@ -1,4 +1,8 @@
use crate::AppState;
use std::sync::Arc;
use crate::{
client::url_downloader::URLDownloader, download_manager::{download_manager::DownloadManagerSignal, downloadable::Downloadable}, error::download_manager_error::DownloadManagerError, AppState
};
#[tauri::command]
pub fn fetch_state(
@ -9,3 +13,22 @@ pub fn fetch_state(
drop(guard);
Ok(cloned_state)
}
#[tauri::command]
pub fn queue_url_download(
state: tauri::State<'_, std::sync::Mutex<AppState<'_>>>,
url: String
) -> Result<(), DownloadManagerError<DownloadManagerSignal>> {
let sender = state.lock().unwrap().download_manager.get_sender();
let game_download_agent = Arc::new(Box::new(URLDownloader::new(
String::from("Test URL Download"),
"/home/quexeky/Downloads/test_url_download",
sender,
url,
)) as Box<dyn Downloadable + Send + Sync>);
Ok(state
.lock()
.unwrap()
.download_manager
.queue_download(game_download_agent)?)
}

View File

@ -1,3 +1,4 @@
pub mod autostart;
pub mod cleanup;
pub mod commands;
pub mod commands;
pub mod url_downloader;

View File

@ -0,0 +1,173 @@
use std::{
path::{Path, PathBuf},
sync::{mpsc::Sender, Arc, Mutex},
usize,
};
use log::{debug, error};
use reqwest::redirect::Policy;
use tauri::{AppHandle, Emitter};
use crate::{
database::
models::data::{DownloadType, DownloadableMetadata}
,
download_manager::{
download_manager::{DownloadManagerSignal, DownloadStatus},
downloadable::Downloadable,
util::{
download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag},
progress_object::{ProgressHandle, ProgressObject},
},
},
error::application_download_error::ApplicationDownloadError,
games::downloads::download_logic::{DropDownloadPipeline, DropWriter},
DB,
};
pub struct URLDownloader {
id: String,
version: String,
url: String,
control_flag: DownloadThreadControl,
progress: Arc<ProgressObject>,
target: PathBuf,
sender: Sender<DownloadManagerSignal>,
status: Mutex<DownloadStatus>,
}
struct URLDownloaderManager {
current_offset: usize,
}
impl URLDownloader {
pub fn new<S: Into<String>, P: AsRef<Path>>(
id: String,
target: P,
sender: Sender<DownloadManagerSignal>,
url: S,
) -> Self {
// Don't run by default
let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop);
Self {
id,
version: String::new(),
control_flag,
target: target.as_ref().into(),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
status: Mutex::new(DownloadStatus::Queued),
url: url.into(),
}
}
fn download(&self, _app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
// TODO: Fix these unwraps and implement From<io::Error> for ApplicationDownloadError
let client = reqwest::blocking::Client::builder()
.redirect(Policy::default())
.build()
.unwrap();
let response = client.head(&self.url).send().unwrap();
let content_length = response
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.map(|x| x.to_str().unwrap().parse().unwrap())
.unwrap_or(usize::MAX);
let response = client.get(&self.url).send().unwrap();
self.set_progress_object_params(content_length);
let progress = self.progress.get(0);
let progress_handle = ProgressHandle::new(progress, self.progress.clone());
let mut pipeline = DropDownloadPipeline::new(
response,
DropWriter::new(&self.target),
&self.control_flag,
progress_handle,
content_length,
);
let completed = pipeline
.copy()
.map_err(|e| ApplicationDownloadError::IoError(e.kind()))?;
if !completed {
return Ok(false);
};
Ok(true)
}
fn set_progress_object_params(&self, max: usize) {
// Avoid re-setting it
if self.progress.get_max() != 0 {
return;
}
self.progress.set_max(max);
self.progress.set_size(1);
self.progress.set_time_now();
}
}
impl Downloadable for URLDownloader {
fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
*self.status.lock().unwrap() = DownloadStatus::Downloading;
self.download(app_handle)
}
fn progress(&self) -> Arc<ProgressObject> {
self.progress.clone()
}
fn control_flag(&self) -> DownloadThreadControl {
self.control_flag.clone()
}
fn metadata(&self) -> DownloadableMetadata {
DownloadableMetadata {
id: self.id.clone(),
version: Some(self.version.clone()),
download_type: DownloadType::Tool,
}
}
fn on_initialised(&self, _app_handle: &tauri::AppHandle) {
*self.status.lock().unwrap() = DownloadStatus::Queued;
}
fn on_error(&self, app_handle: &tauri::AppHandle, error: &ApplicationDownloadError) {
*self.status.lock().unwrap() = DownloadStatus::Error;
app_handle
.emit("download_error", error.to_string())
.unwrap();
error!("error while managing download: {}", error);
let mut handle = DB.borrow_data_mut().unwrap();
handle
.applications
.transient_statuses
.remove(&self.metadata());
}
fn on_complete(&self, _app_handle: &tauri::AppHandle) {
debug!("Completed url download");
}
// TODO: fix this function. It doesn't restart the download properly, nor does it reset the state properly
fn on_incomplete(&self, _app_handle: &tauri::AppHandle) {
debug!("Incomplete url download");
}
fn on_cancelled(&self, _app_handle: &tauri::AppHandle) {
debug!("Cancelled url download");
}
fn status(&self) -> DownloadStatus {
self.status.lock().unwrap().clone()
}
}

View File

@ -1,6 +1,10 @@
use std::sync::Mutex;
use crate::{database::models::data::DownloadableMetadata, AppState};
use crate::{
database::models::data::{DownloadType, DownloadableMetadata},
download_manager::download_manager::QueueMetadata,
AppState,
};
#[tauri::command]
pub fn pause_downloads(state: tauri::State<'_, Mutex<AppState>>) {
@ -29,3 +33,28 @@ pub fn move_download_in_queue(
pub fn cancel_game(state: tauri::State<'_, Mutex<AppState>>, meta: DownloadableMetadata) {
state.lock().unwrap().download_manager.cancel(meta)
}
#[tauri::command]
pub fn get_queue_metadata(
state: tauri::State<'_, Mutex<AppState>>,
meta: DownloadableMetadata,
) -> Option<QueueMetadata> {
match meta.download_type {
DownloadType::Game => {
let state = state.lock().unwrap();
let game = state.games.get(&meta.id).unwrap();
Some(QueueMetadata {
cover: game.m_cover_object_id.clone(),
m_short_description: game.m_short_description.clone(),
m_name: game.m_name.clone(),
})
}
DownloadType::Tool => Some(QueueMetadata {
cover: "IDK Man".to_string(),
m_short_description: "This is a tool".to_string(),
m_name: "Download".to_string(),
}),
DownloadType::DLC => unimplemented!(),
DownloadType::Mod => unimplemented!(),
}
}

View File

@ -74,6 +74,14 @@ pub enum DownloadStatus {
Error,
}
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct QueueMetadata {
pub cover: String,
pub m_short_description: String,
pub m_name: String
}
/// Accessible front-end for the DownloadManager
///
/// The system works entirely through signals, both internally and externally,

View File

@ -4,7 +4,7 @@ use tauri::AppHandle;
use crate::{
database::models::data::DownloadableMetadata,
error::application_download_error::ApplicationDownloadError,
error::{application_download_error::ApplicationDownloadError, remote_access_error::RemoteAccessError},
};
use super::{
@ -23,3 +23,4 @@ pub trait Downloadable: Send + Sync {
fn on_incomplete(&self, app_handle: &AppHandle);
fn on_cancelled(&self, app_handle: &AppHandle);
}

View File

@ -10,7 +10,8 @@ use crate::download_manager::util::progress_object::{ProgressHandle, ProgressObj
use crate::error::application_download_error::ApplicationDownloadError;
use crate::error::remote_access_error::RemoteAccessError;
use crate::games::downloads::manifest::{DropDownloadContext, DropManifest};
use crate::games::library::{on_game_complete, push_game_update, GameUpdateEvent};
use crate::games::library::{on_game_complete, push_game_update, Game, GameUpdateEvent};
use crate::remote::cache::get_cached_object;
use crate::remote::requests::make_request;
use crate::DB;
use log::{debug, error, info};

View File

@ -11,6 +11,7 @@ use std::fs::{set_permissions, Permissions};
use std::io::{ErrorKind, Read};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::{
fs::{File, OpenOptions},
io::{self, BufWriter, Seek, SeekFrom, Write},
@ -22,14 +23,14 @@ pub struct DropWriter<W: Write> {
destination: W,
}
impl DropWriter<File> {
fn new(path: PathBuf) -> Self {
pub fn new<P: AsRef<Path>>(path: P) -> Self {
Self {
destination: OpenOptions::new().write(true).open(path).unwrap(),
destination: OpenOptions::new().create(true).write(true).open(path).unwrap(),
hasher: Context::new(),
}
}
fn finish(mut self) -> io::Result<Digest> {
pub fn finish(mut self) -> io::Result<Digest> {
self.flush().unwrap();
Ok(self.hasher.compute())
}
@ -66,7 +67,7 @@ pub struct DropDownloadPipeline<'a, R: Read, W: Write> {
pub size: usize,
}
impl<'a> DropDownloadPipeline<'a, Response, File> {
fn new(
pub fn new(
source: Response,
destination: DropWriter<File>,
control_flag: &'a DownloadThreadControl,
@ -82,7 +83,7 @@ impl<'a> DropDownloadPipeline<'a, Response, File> {
}
}
fn copy(&mut self) -> Result<bool, io::Error> {
pub fn copy(&mut self) -> Result<bool, io::Error> {
let copy_buf_size = 512;
let mut copy_buf = vec![0; copy_buf_size];
let mut buf_writer = BufWriter::with_capacity(1024 * 1024, &mut self.destination);
@ -102,6 +103,10 @@ impl<'a> DropDownloadPipeline<'a, Response, File> {
if current_size == self.size {
break;
}
if bytes_read == 0 {
println!("Terminated stream");
break;
}
}
Ok(true)

View File

@ -1,5 +1,5 @@
pub mod commands;
pub mod download_agent;
mod download_logic;
pub mod download_logic;
mod manifest;
mod stored_manifest;

View File

@ -31,14 +31,14 @@ pub struct FetchGameStruct {
#[serde(rename_all = "camelCase")]
pub struct Game {
id: String,
m_name: String,
m_short_description: String,
pub m_name: String,
pub m_short_description: String,
m_description: String,
// mDevelopers
// mPublishers
m_icon_object_id: String,
m_banner_object_id: String,
m_cover_object_id: String,
pub m_cover_object_id: String,
m_image_library_object_ids: Vec<String>,
m_image_carousel_object_ids: Vec<String>,
}

View File

@ -7,7 +7,7 @@ mod error;
mod process;
mod remote;
use crate::database::db::DatabaseImpls;
use crate::{client::commands::queue_url_download, database::db::DatabaseImpls, download_manager::commands::get_queue_metadata};
use client::{
autostart::{get_autostart_enabled, sync_autostart_on_startup, toggle_autostart},
cleanup::{cleanup_and_exit, quit},
@ -264,6 +264,8 @@ pub fn run() {
resume_downloads,
cancel_game,
uninstall_game,
queue_url_download,
get_queue_metadata,
// Processes
launch_game,
kill_game,

View File

@ -38,6 +38,7 @@ export type Game = {
};
export type GameVersion = {
versionName: string;
launchCommandTemplate: string;
};
@ -75,7 +76,7 @@ export enum DownloadableType {
export type DownloadableMetadata = {
id: string;
version: string;
version?: string;
downloadType: DownloadableType;
};
@ -84,3 +85,10 @@ export type Settings = {
maxDownloadThreads: number;
forceOffline: boolean;
};
export type QueueMetadata = {
mName: string;
cover: string;
mShortDescription: string;
}