feat: expand form

This commit is contained in:
Maarten van Heusden
2026-02-03 14:38:53 +01:00
parent 08148dca50
commit 62f2410618
8 changed files with 148 additions and 61 deletions

View File

@@ -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");
}
}

View File

@@ -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),

View File

@@ -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

View File

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

View File

@@ -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>
);

View File

@@ -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>

View File

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

View File

@@ -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>>({});