mirror of
https://github.com/mmvanheusden/SteamDepotDownloaderGUI.git
synced 2026-02-04 05:31:19 +01:00
feat: expand form
This commit is contained in:
@@ -11,7 +11,7 @@ use portable_pty::{native_pty_system, PtyPair, PtySize};
|
||||
use std::io::ErrorKind::AlreadyExists;
|
||||
use std::io::{BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::{Arc};
|
||||
use std::time::Duration;
|
||||
use std::{env, thread};
|
||||
use tauri::async_runtime::Mutex;
|
||||
@@ -23,24 +23,12 @@ struct AppState {
|
||||
reader: Arc<Mutex<BufReader<Box<dyn Read + Send>>>>,
|
||||
}
|
||||
|
||||
|
||||
/// The first terminal found. Used as default terminal.
|
||||
static WORKING_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// This function is called every time the app is reloaded/started. It quickly populates the [`TERMINAL`] variable with a working terminal.
|
||||
#[tauri::command]
|
||||
async fn preload_vectum(app: AppHandle) {
|
||||
// Only fill these variables once.
|
||||
|
||||
if WORKING_DIR.get().is_none() {
|
||||
WORKING_DIR.set(Path::join(&app.path().local_data_dir().unwrap(), "SteamDepotDownloaderGUI")).expect("Failed to configure working directory")
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_download(steam_download: steam::SteamDownload, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||
// Also change working directory
|
||||
let working_dir: PathBuf = get_working_dir(&app);
|
||||
|
||||
// std::env::set_current_dir(&WORKING_DIR.get().unwrap()).unwrap();
|
||||
dbg!(&steam_download);
|
||||
|
||||
println!("\n-------------------------DEBUG INFO------------------------");
|
||||
println!("received these values from frontend:");
|
||||
@@ -50,11 +38,11 @@ async fn start_download(steam_download: steam::SteamDownload, app: AppHandle, st
|
||||
println!("\t- Depot ID: {}", steam_download.depot_id());
|
||||
println!("\t- Manifest ID: {}", steam_download.manifest_id());
|
||||
println!("\t- Output Path: {}", steam_download.output_path());
|
||||
println!("\t- Working directory: {}", &WORKING_DIR.get().unwrap().display());
|
||||
println!("\t- Working directory: {}", &working_dir.display());
|
||||
println!("----------------------------------------------------------\n");
|
||||
|
||||
/* Build the command and spawn it in our terminal */
|
||||
let mut cmd = terminal::create_depotdownloader_command(&steam_download, WORKING_DIR.get().unwrap());
|
||||
let mut cmd = terminal::create_depotdownloader_command(&steam_download, &working_dir);
|
||||
|
||||
// add the $TERM env variable so we can use clear and other commands
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -81,12 +69,13 @@ async fn start_download(steam_download: steam::SteamDownload, app: AppHandle, st
|
||||
|
||||
/// Downloads the DepotDownloader zip file from the internet based on the OS.
|
||||
#[tauri::command]
|
||||
async fn download_depotdownloader() {
|
||||
async fn download_depotdownloader(app: AppHandle) {
|
||||
let working_dir: PathBuf = get_working_dir(&app);
|
||||
let url = get_depotdownloader_url();
|
||||
|
||||
// Where we store the DepotDownloader zip.
|
||||
let zip_filename = format!("DepotDownloader-v{}-{}.zip", DEPOTDOWNLOADER_VERSION, env::consts::OS);
|
||||
let depotdownloader_zip = Path::join(WORKING_DIR.get().unwrap(), Path::new(&zip_filename));
|
||||
let depotdownloader_zip = Path::join(&working_dir, Path::new(&zip_filename));
|
||||
|
||||
|
||||
if let Err(e) = depotdownloader::download_file(url.as_str(), depotdownloader_zip.as_path()).await {
|
||||
@@ -100,7 +89,7 @@ async fn download_depotdownloader() {
|
||||
println!("Downloaded DepotDownloader for {} to {}", env::consts::OS, depotdownloader_zip.display());
|
||||
}
|
||||
|
||||
depotdownloader::unzip(depotdownloader_zip.as_path(), WORKING_DIR.get().unwrap()).unwrap();
|
||||
depotdownloader::unzip(depotdownloader_zip.as_path(), &working_dir).unwrap();
|
||||
println!("Succesfully extracted DepotDownloader zip.");
|
||||
}
|
||||
|
||||
@@ -122,6 +111,10 @@ pub fn get_os() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_working_dir(app: &AppHandle) -> PathBuf {
|
||||
Path::join(&app.path().local_data_dir().unwrap(), "SteamDepotDownloaderGUI")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// macOS: change dir to documents because upon opening, our current dir by default is "/".
|
||||
// todo: Is this still needed ??
|
||||
@@ -167,10 +160,9 @@ fn main() {
|
||||
start_download,
|
||||
download_depotdownloader,
|
||||
internet_connection,
|
||||
preload_vectum,
|
||||
async_write_to_pty,
|
||||
async_read_from_pty,
|
||||
async_resize_pty,
|
||||
]).run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,15 @@ use std::path::PathBuf;
|
||||
|
||||
/// Represents the data required to download a Steam depot.
|
||||
#[derive(Deserialize, Debug, Getters)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SteamDownload {
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
app_id: String,
|
||||
depot_id: String,
|
||||
manifest_id: String,
|
||||
options: VectumOptions
|
||||
app_id: u32,
|
||||
depot_id: u32,
|
||||
manifest_id: u32,
|
||||
output_location: Option<PathBuf>,
|
||||
output_directory_name: Option<String>
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Getters)]
|
||||
@@ -30,7 +32,7 @@ impl SteamDownload {
|
||||
/// The directory where the download should happen
|
||||
pub fn output_path(&self) -> String {
|
||||
let sep = std::path::MAIN_SEPARATOR.to_string();
|
||||
match (&self.options.output_directory, &self.options.directory_name) {
|
||||
match (&self.output_location, &self.output_directory_name) {
|
||||
(Some(output_dir), Some(dir_name)) => format!("{}{}{}", output_dir.display(), sep, dir_name),
|
||||
(Some(output_dir), None) => format!("{}{}{}", output_dir.display(), sep, &self.manifest_id),
|
||||
(None, Some(dir_name)) => format!(".{}{}", sep, dir_name),
|
||||
|
||||
@@ -70,9 +70,9 @@ pub fn create_depotdownloader_command(steam_download: &SteamDownload, cwd: &Path
|
||||
command.args(["-password", &steam_download.password().clone().unwrap()]);
|
||||
}
|
||||
|
||||
command.args(["-app", steam_download.app_id()]);
|
||||
command.args(["-depot", steam_download.depot_id()]);
|
||||
command.args(["-manifest", steam_download.manifest_id()]);
|
||||
command.args(["-app", &steam_download.app_id().to_string()]);
|
||||
command.args(["-depot", &steam_download.depot_id().to_string()]);
|
||||
command.args(["-manifest", &steam_download.manifest_id().to_string()]);
|
||||
command.args(["-dir", &steam_download.output_path()]);
|
||||
|
||||
command
|
||||
|
||||
38
src/App.tsx
38
src/App.tsx
@@ -3,6 +3,7 @@ import "./css/App.css";
|
||||
import {DownloaderOutput} from "./components/DownloaderOutput.tsx";
|
||||
import {DownloaderForm} from "./components/DownloaderForm.tsx";
|
||||
import {AppContext} from "./context.ts";
|
||||
import {invoke} from "@tauri-apps/api/core";
|
||||
|
||||
function App() {
|
||||
const username = useState<string>();
|
||||
@@ -10,19 +11,25 @@ function App() {
|
||||
const appId = useState<number>();
|
||||
const depotId = useState<number>();
|
||||
const manifestId = useState<number>();
|
||||
|
||||
const outputLocation = useState<string>();
|
||||
|
||||
|
||||
return (
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
username,password,appId,depotId,manifestId
|
||||
username,
|
||||
password,
|
||||
appId,
|
||||
depotId,
|
||||
manifestId,
|
||||
outputLocation,
|
||||
}}
|
||||
>
|
||||
<main class="bg-gray-800 left-0 top-0 bottom-0 absolute right-0 select-none p-px">
|
||||
<div class="text-white font-bold text-5xl text-center mb-1 font-['Hubot_Sans']">
|
||||
<div class="text-white font-bold text-4xl text-center mb-1 font-['Hubot_Sans']">
|
||||
Steam Depot Downloader
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex justify-between gap-5">
|
||||
<div class="w-full max-w-1/2 pl-3">
|
||||
<DownloaderForm />
|
||||
@@ -38,6 +45,25 @@ function App() {
|
||||
|
||||
export default App;
|
||||
|
||||
export function startDownload() {
|
||||
export async function startDownload(options: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
appId: number;
|
||||
depotId: number;
|
||||
manifestId: number;
|
||||
outputLocation?: string;
|
||||
outputDirectoryName?: string;
|
||||
}) {
|
||||
|
||||
}
|
||||
await invoke("download_depotdownloader"); // First make backend download DepotDownloader
|
||||
|
||||
// BLOCK INTERFACE & CLEARING TERMINAL
|
||||
|
||||
await invoke("start_download", {
|
||||
steamDownload: options
|
||||
}); // First make backend download DepotDownloader
|
||||
|
||||
|
||||
// UNBLOCK INTERFACE & CLEARING TERMINAL
|
||||
|
||||
}
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
import {useContext} from "preact/hooks";
|
||||
import {useContext, useState} from "preact/hooks";
|
||||
import "../css/App.css";
|
||||
import {NumberInput, TextInput} from "./FormInput";
|
||||
import {BooleanUseState, FileInput, NumberInput, TextInput} from "./FormInput";
|
||||
import {startDownload} from "../App";
|
||||
import {Icon} from "@iconify-icon/react";
|
||||
import {AppContext} from "../context";
|
||||
import {openUrl} from "@tauri-apps/plugin-opener";
|
||||
|
||||
export function DownloaderForm() {
|
||||
const downloading = useState<boolean>();
|
||||
const context = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form>
|
||||
<div class="gap-1">
|
||||
<div class="flex flex-col gap-0.5 mb-2">
|
||||
<TextInput label="Username" valueState={context.username!} />
|
||||
<TextInput label="Password" valueState={context.password!} password={true} />
|
||||
<br />
|
||||
<NumberInput label="App ID" valueState={context.appId!} required={true} />
|
||||
<NumberInput label="Depot ID" valueState={context.depotId!} required={true} />
|
||||
<NumberInput label="Manifest ID" valueState={context.manifestId!} required={true} />
|
||||
<FileInput label="Manifest ID" valueState={context.manifestId!} required={true} pathState={context.outputLocation!} />
|
||||
<br />
|
||||
<DownloadButton disabled={false} downloading={false} />
|
||||
<br />
|
||||
<DownloadButton disabled={downloading[0]} downloadingState={downloading} />
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex justify-between gap-1">
|
||||
<InternetButton icon={"ic:sharp-discord"} title="Discord" />
|
||||
<InternetButton icon={"simple-icons:steamdb"} title="SteamDB" href="https://steamdb.info/instantsearch/" />
|
||||
<InternetButton icon={"mdi:youtube"} title="Tutorials" href=""/>
|
||||
<InternetButton icon={"bx:donate-heart"} title="Donate" href=""/>
|
||||
<InternetButton icon={"ic:sharp-discord"} title="Discord" href="https://discord.com/invite/3qCt4DT5qe" />
|
||||
<InternetButton icon={"simple-icons:steamdb"} title="SteamDB" href="https://steamdb.info/instantsearch" />
|
||||
<InternetButton icon={"mdi:youtube"} title="Tutorials" href="https://youtube.com/playlist?list=PLRAjc5plLScj967hnsYX-I3Vjw9C1v7Ca"/>
|
||||
<InternetButton icon={"bx:donate-heart"} title="Donate" href="https://paypal.me/onderkin"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -34,8 +37,9 @@ export function DownloaderForm() {
|
||||
|
||||
|
||||
function DownloadButton(
|
||||
{disabled, downloading}: {disabled?: boolean, downloading?: boolean}
|
||||
{disabled, downloadingState}: {disabled?: boolean, downloadingState: BooleanUseState}
|
||||
) {
|
||||
const [downloading, setDownloading] = downloadingState;
|
||||
const context = useContext(AppContext);
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const form: HTMLFormElement = (e.target as HTMLButtonElement).closest("form")!;
|
||||
@@ -48,13 +52,35 @@ function DownloadButton(
|
||||
console.warn("Form invalid!");
|
||||
return;
|
||||
}
|
||||
console.trace(context);
|
||||
startDownload();
|
||||
setDownloading(true)
|
||||
startDownload({
|
||||
username: context.username![0],
|
||||
password: context.password![0],
|
||||
appId: context.appId![0]!,
|
||||
depotId: context.depotId![0]!,
|
||||
manifestId: context.manifestId![0]!,
|
||||
outputLocation: context.outputLocation![0]!,
|
||||
}).catch((e) => console.error(e));
|
||||
// setDownloading(false)
|
||||
};
|
||||
|
||||
return (
|
||||
<button disabled={disabled} onClick={onClick} type="submit" class="w-full bg-green-500 rounded-md border py-1.5 font-semibold text-xl hover:bg-green-600 active:bg-green-700 active:scale-103 transition-transform disabled:bg-red-500/70 disabled:pointer-events-none inline-flex items-center justify-center">
|
||||
{downloading ? "Downloading..." : <><Icon icon="material-symbols:download" />Download</>}
|
||||
<button disabled={disabled} onClick={onClick} type="submit" class="w-full bg-green-500 rounded-md border py-1.5 font-semibold text-xl hover:bg-green-600 active:bg-green-700 active:scale-103 transition disabled:bg-red-500/70 disabled:pointer-events-none inline-flex items-center justify-start">
|
||||
{downloading
|
||||
? <>
|
||||
<div class="justify-start ml-5 items-center flex">
|
||||
<Icon icon="line-md:downloading-loop" width="35" height="35" />
|
||||
</div>
|
||||
<span class="w-full">Downloading...</span>
|
||||
|
||||
</> :
|
||||
<>
|
||||
<div class="justify-start ml-5 items-center flex">
|
||||
<Icon icon="material-symbols:downloading-rounded" width="35" height="35" />
|
||||
</div>
|
||||
<span class="w-full">Download</span>
|
||||
</>
|
||||
}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -62,12 +88,12 @@ function DownloadButton(
|
||||
function InternetButton(
|
||||
{title, icon, href, disabled}: {title: string, icon: string, href?: string, disabled?: boolean}
|
||||
) {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
// go to url
|
||||
const onClick = () => {
|
||||
if (href) openUrl(href).catch((e) => console.error(e));
|
||||
};
|
||||
|
||||
return (
|
||||
<button disabled={disabled} onClick={onClick} class="grow px-0 bg-gray-500 rounded-md border py-0.5 font-semibold text-md hover:bg-gray-400 active:bg-gray-300 active:scale-103 transition-transform disabled:bg-red-500/70 disabled:pointer-events-none inline-flex items-center justify-center">
|
||||
<button disabled={disabled} onClick={onClick} type="button" class="grow gap-px px-1 bg-gray-500 rounded-md border py-0.5 font-semibold text-md hover:bg-gray-400 active:bg-gray-300 active:scale-103 transition-transform disabled:bg-red-500/70 disabled:pointer-events-none inline-flex items-center justify-center">
|
||||
<Icon icon={icon} />{title}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ export function DownloaderOutput() {
|
||||
<div class="border border-gray-300 rounded-md bg-gray-900 text-white shadow shadow-blue-200">
|
||||
<div class="text-md font-semibold w-full inline-flex my-px items-center">
|
||||
<span class="text-center w-full">Download output</span>
|
||||
<button id="clear-terminal" class="disabled:pointer-events-none disabled:line-through disabled:text-gray-300 ml-auto py-px px-2 border-2 rounded-xs border-red-500/75 font-normal enabled:hover:bg-red-200/30 enabled:active:bg-red-200/50">
|
||||
<button type="button" id="clear-terminal" class="disabled:pointer-events-none disabled:line-through disabled:text-gray-300 ml-auto py-px px-2 border-2 rounded-xs border-red-500/75 font-normal enabled:hover:bg-red-200/30 enabled:active:bg-red-200/50">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {Icon} from "@iconify-icon/react";
|
||||
import {useState} from "preact/hooks";
|
||||
import {open as openDialog} from "@tauri-apps/plugin-dialog";
|
||||
import {openPath} from "@tauri-apps/plugin-opener";
|
||||
|
||||
export type StringUseState = ReturnType<typeof useState<string>>;
|
||||
export type NumberUseState = ReturnType<typeof useState<number>>;
|
||||
export type BooleanUseState = ReturnType<typeof useState<boolean>>;
|
||||
|
||||
|
||||
export function TextInput({ label, placeholder, valueState, required, password }: { label: string, placeholder?: string, valueState: StringUseState, required?: boolean, password?: bool }) {
|
||||
@@ -27,7 +31,43 @@ export function NumberInput({ label, placeholder, valueState, required, min, max
|
||||
<label class={`text-md font-medium text-white ${required && "after:content-['*'] after:ml-1 after:text-xl after:text-red-500"}`}>
|
||||
{label}
|
||||
</label>
|
||||
<input required={required} value={value} onInput={onInput} min={min} max={max} step={step} placeholder={placeholder} type={"number"} class="border text-sm rounded-lg block w-full bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:border-blue-500 focus:shadow-[0px_0px_29px_-14px_rgba(59,130,246,0.5)] px-3 py-2 transition duration-300" />
|
||||
<input required={required} value={value} onInput={onInput} min={min ?? 1} max={max} step={step} placeholder={placeholder} type={"number"} class="border text-sm rounded-lg block w-full bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:border-blue-500 focus:shadow-[0px_0px_29px_-14px_rgba(59,130,246,0.5)] px-3 py-2 transition duration-300" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileInput({ pathState }: { label: string, placeholder?: string, valueState: NumberUseState, required?: boolean, pathState: StringUseState }) {
|
||||
const [path, setPath] = pathState;
|
||||
const selectPath = () => {
|
||||
openDialog({
|
||||
title: "Choose where to save the game download.",
|
||||
multiple: false,
|
||||
directory: true,
|
||||
canCreateDirectories: true,
|
||||
})
|
||||
.then((selectedPath) => {
|
||||
if (!selectedPath) {
|
||||
console.warn("Nothing selected, doing nothing.");
|
||||
return;
|
||||
}
|
||||
setPath(selectedPath)
|
||||
})
|
||||
.catch((e) => console.error(e));
|
||||
}
|
||||
const previewPath = () => {
|
||||
openPath(path!).catch((e) => console.error(e));
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div class="flex justify-between gap-2">
|
||||
<button type="button" onClick={selectPath} class="grow px-px bg-gray-500 rounded-md border py-1 font-semibold text-md hover:bg-gray-400 active:bg-gray-300 active:scale-103 transition-transform disabled:bg-red-500/70 disabled:pointer-events-none inline-flex items-center justify-center gap-1">
|
||||
<Icon icon="subway:folder-2" />Choose output directory
|
||||
</button>
|
||||
<input required type="text" hidden value={path}></input> {/* A hidden text input which holds the path useState value, so the form will be invalid when no path is selected. */}
|
||||
<button type="button" disabled={!path} onClick={previewPath} class="grow px-px bg-gray-500 rounded-md border py-1 font-semibold text-md hover:bg-gray-400 active:bg-gray-300 active:scale-103 transition-transform disabled:bg-red-500/70 disabled:pointer-events-none inline-flex items-center justify-center gap-1">
|
||||
<Icon icon="material-symbols:folder-eye" />Preview output directory
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import {createContext} from "preact";
|
||||
import {NumberUseState, StringUseState} from "./components/FormInput";
|
||||
|
||||
interface AppContext {
|
||||
username: StringUseState;
|
||||
password: StringUseState;
|
||||
appId: NumberUseState;
|
||||
depotId: NumberUseState;
|
||||
manifestId: NumberUseState;
|
||||
username: StringUseState;
|
||||
password: StringUseState;
|
||||
appId: NumberUseState;
|
||||
depotId: NumberUseState;
|
||||
manifestId: NumberUseState;
|
||||
outputLocation: StringUseState;
|
||||
}
|
||||
|
||||
export const AppContext = createContext<Partial<AppContext>>({});
|
||||
Reference in New Issue
Block a user