5 Commits

Author SHA1 Message Date
Maarten van Heusden
832b49d2c7 feat: implement settings menu 2026-02-03 21:46:46 +01:00
Maarten van Heusden
5cfd57b03e feat: implement terminal 2026-02-03 16:23:05 +01:00
Maarten van Heusden
62f2410618 feat: expand form 2026-02-03 14:38:53 +01:00
Maarten van Heusden
08148dca50 fix: eslint 2026-02-03 12:11:23 +01:00
Maarten van Heusden
897ef0c820 feat!: migrate to preact & tailwind 2026-02-03 12:05:17 +01:00
26 changed files with 476 additions and 2956 deletions

View File

@@ -1,20 +1,15 @@
// @ts-check
import eslint from '@eslint/js';
import {defineConfig} from 'eslint/config';
import tseslint from 'typescript-eslint';
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parserOptions: {
project: 'tsconfig.json',
},
project: 'tsconfig.json',
},
},
},
{
files: ["src/**"],
rules: {
"semi": ["error", "always"], // semicolons

View File

@@ -15,16 +15,16 @@
"@tauri-apps/api": "2.9.1",
"@tauri-apps/plugin-dialog": "2.6.0",
"@tauri-apps/plugin-opener": "~2.5.3",
"@tauri-apps/plugin-shell": "2.3.4",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"jquery": "^4.0.0",
"preact": "^10.25.1",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@iconify-icon/react": "^3.0.3",
"@preact/preset-vite": "^2.9.3",
"@tauri-apps/cli": "2.9.6",
"@types/jquery": "^3.5.33",
"eslint": "^9.39.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",

2106
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

64
src-tauri/Cargo.lock generated
View File

@@ -2588,16 +2588,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3824,17 +3814,6 @@ dependencies = [
"digest",
]
[[package]]
name = "shared_child"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
"sigchld",
"windows-sys 0.60.2",
]
[[package]]
name = "shared_library"
version = "0.1.9"
@@ -3857,27 +3836,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "sigchld"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
dependencies = [
"libc",
"os_pipe",
"signal-hook",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -4365,27 +4323,6 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39b76f884a3937e04b631ffdc3be506088fa979369d25147361352f2f352e5ed"
dependencies = [
"encoding_rs",
"log",
"open",
"os_pipe",
"regex",
"schemars 0.8.22",
"serde",
"serde_json",
"shared_child",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "tauri-runtime"
version = "2.9.2"
@@ -4996,7 +4933,6 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-shell",
"zip",
]

View File

@@ -15,7 +15,6 @@ tauri-build = { version = "2.4.1", features = [] }
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", rev = "c4c45d503ea115a839aae718d02f79e7c7f0f673" }
serde_json = "1.0.143"
tauri = { version = "2.8.5", features = [] }
tauri-plugin-shell = "2.3.1"
tauri-plugin-dialog = "2.4.0"
serde = { version = "1.0.219", features = ["derive"] }
derive-getters = "0.5.0"

View File

@@ -7,10 +7,7 @@
],
"permissions": [
"core:default",
"shell:allow-open",
"dialog:default",
"shell:allow-execute",
"shell:allow-spawn",
{
"identifier": "opener:allow-open-path",
"allow": [

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 ??
@@ -162,15 +155,13 @@ fn main() {
})
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
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,19 +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
}
#[derive(Debug, Deserialize, Getters)]
pub struct VectumOptions {
output_directory: Option<PathBuf>,
directory_name: Option<String>
output_location: Option<PathBuf>,
output_directory_name: Option<String>
}
@@ -30,7 +26,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

90
src/App.tsx Normal file
View File

@@ -0,0 +1,90 @@
import {useState} from "preact/hooks";
import "./css/App.css";
import {DownloaderOutput} from "./components/DownloaderOutput.tsx";
import {DownloaderForm} from "./components/DownloaderForm.tsx";
import {AppContext, AppSettings} from "./context.ts";
import {invoke} from "@tauri-apps/api/core";
import { Settings } from "./components/Settings.tsx";
function App() {
const username = useState<string>();
const password = useState<string>();
const appId = useState<string>();
const depotId = useState<string>();
const manifestId = useState<string>();
const outputLocation = useState<string>();
const outputFolderName = useState<string>();
const downloading = useState<boolean>();
const showSettings = useState<boolean>();
const appSettings = useState<AppSettings>({
// Settings defaults are defined here.
outputDirectoryMode: "Manifest ID"
});
return (
<AppContext.Provider
value={{
username,
password,
appId,
depotId,
manifestId,
outputLocation,
downloading,
showSettings,
outputFolderName,
appSettings,
}}
>
<main class="bg-[#0d1117] left-0 top-0 bottom-0 absolute right-0 select-none p-px">
{showSettings[0]
?<Settings />
: <>
<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 />
</div>
<div class="w-full max-w-1/2 pr-3">
<DownloaderOutput />
</div>
</div>
</>
}
</main>
</AppContext.Provider>
);
}
export default App;
export async function startDownload(options: {
username?: string;
password?: string;
appId: string;
depotId: string;
manifestId: string;
outputLocation?: string;
outputDirectoryName?: string;
}) {
await invoke("download_depotdownloader"); // First make backend download DepotDownloader
// BLOCK INTERFACE & CLEARING TERMINAL
await invoke("start_download", {
steamDownload: {
...options,
outputDirectoryName: options.outputDirectoryName == "" ? null : options.outputDirectoryName, // empty string becomes null.
}
}); // First make backend download DepotDownloader
// UNBLOCK INTERFACE & CLEARING TERMINAL
}

Binary file not shown.

View File

@@ -0,0 +1,105 @@
import {useContext} from "preact/hooks";
import "../css/App.css";
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 context = useContext(AppContext);
return (
<>
<form>
<div class="flex flex-col gap-0.5 mb-2">
<TextInput id="username" label="Username" valueState={context.username!} />
<TextInput id="password" label="Password" valueState={context.password!} password={true} />
<br />
<NumberInput id="appId" label="App ID" valueState={context.appId!} required={true} />
<NumberInput id="depotId" label="Depot ID" valueState={context.depotId!} required={true} />
<NumberInput id="manifestId" label="Manifest ID" valueState={context.manifestId!} required={true} />
<FileInput required={true} pathState={context.outputLocation!} />
<br />
<DownloadButton disabled={context.downloading![0]} downloadingState={context.downloading!} />
</div>
</form>
<div class="flex justify-between gap-1">
<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>
<span>{context.appId}</span>
</>
);
}
function DownloadButton(
{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")!;
e.preventDefault(); // Block refreshing the page
form.reportValidity(); // Display native form validation
if (!form.checkValidity()) {
console.warn("Form invalid!");
return;
}
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],
outputDirectoryName: context.outputFolderName![0],
}).catch((e) => console.error(e));
// setDownloading(false)
};
return (
<div class="flex">
<button disabled={disabled} onClick={onClick} type="submit" class="text-white border-black border-2 border-r-0 w-full bg-green-500 rounded-l-md py-1 font-bold text-2xl 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-between">
{downloading
? <>
<div class="absolute flex ml-2">
<Icon icon="line-md:downloading-loop" width="35" height="35" />
</div>
<span class="w-full">Downloading...</span>
</> :
<>
<div class="absolute flex ml-2">
<Icon icon="material-symbols:downloading-rounded" width="35" height="35" />
</div>
<span class="w-full">Download</span>
</>
}
</button>
<button onClick={() => context.showSettings![1](s => !s)} type="button" class="group text-white border-black w-15 bg-green-500 rounded-r-md border-2 ring-l-gray-800 py-1 font-bold text-2xl hover:bg-green-600 active:bg-green-700 transition disabled:bg-red-500/70 disabled:pointer-events-none inline-flex items-center text-center justify-center">
<Icon icon="heroicons:cog" width="30" height="30" class="animate-spin [animation-play-state:paused] group-hover:[animation-play-state:running]"/>
</button>
</div>
);
}
function InternetButton(
{title, icon, href, disabled}: {title: string, icon: string, href?: string, disabled?: boolean}
) {
const onClick = () => {
if (href) openUrl(href).catch((e) => console.error(e));
};
return (
<button disabled={disabled} onClick={onClick} type="button" class="text-white border-black grow gap-px px-1 bg-blue-500 rounded-md border py-0.5 font-semibold text-md hover:bg-blue-400 active:bg-blue-300 active:scale-103 transition-transform disabled:bg-red-500/70 disabled:pointer-events-none inline-flex items-center justify-center">
<Icon icon={icon} height="20"/>{title}
</button>
);
}

View File

@@ -0,0 +1,98 @@
import { invoke } from "@tauri-apps/api/core";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { useContext, useEffect, useRef, useState } from "preact/hooks";
import "@xterm/xterm/css/xterm.css";
import { AppContext } from "../context";
import { listen } from "@tauri-apps/api/event";
/* Parts of this file are derived from https://github.com/cablehead/tauri-xtermjs-nushell/blob/0bdd4a27ee2874de12e99bccd6c91d6ec5d28fbc/src/main.ts */
export function DownloaderOutput() {
const context = useContext(AppContext);
const [terminal, setTerminal] = useState<Terminal | undefined>();
// Hook on the "command-exited" Tauri emitter, and wait for it to emit, so we can flip the downloading state.
listen("command-exited", () => {
context.downloading?.[1]?.(false);
}).catch(console.error);
const terminalWindowRef = useRef(null);
useEffect(() => {
setTerminal(registerTerminal(terminalWindowRef.current!));
}, []);
return (
<div class="mt-2 h-full w-full mx-auto">
<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>
{terminal &&
<button onClick={() => { if (!context.downloading![0]) terminal.reset(); }} type="button" disabled={context.downloading![0] ?? false} class="disabled:cursor-not-allowed disabled:line-through disabled:text-gray-300 ml-auto mr-2 my-1 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>
<div ref={terminalWindowRef} class="max-h-[70vh]"></div>
</div>
</div>
);
}
const registerTerminal: (terminalElement: HTMLElement) => Terminal = (terminalElement: HTMLElement) => {
/* eslint-disable @typescript-eslint/no-misused-promises */
const fitAddon = new FitAddon();
const term = new Terminal({
fontSize: 10,
cursorBlink: true,
rows: 100,
cols: 100,
theme: {
background: "rgb(30,33,46)",
},
});
term.loadAddon(fitAddon);
term.open(terminalElement);
function fitTerminal() {
fitAddon.fit();
void invoke<string>("async_resize_pty", {
rows: term.rows,
cols: term.cols,
});
}
// Write data from pty into the terminal
function writeToTerminal(data: string) {
return new Promise<void>((r) => {
term.write(data, () => r());
});
}
// Write data from the terminal to the pty
function writeToPty(data: string) {
void invoke("async_write_to_pty", {
data,
});
}
term.onData(writeToPty);
addEventListener("resize", fitTerminal);
fitTerminal();
async function readFromPty() {
const data = await invoke<string>("async_read_from_pty");
if (data) {
await writeToTerminal(data);
}
window.requestAnimationFrame(readFromPty);
}
window.requestAnimationFrame(readFromPty);
return term;
};

View File

@@ -0,0 +1,76 @@
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";
import "../css/App.css";
export type StringUseState = ReturnType<typeof useState<string>>;
export type NumberUseState = ReturnType<typeof useState<number>>;
export type BooleanUseState = ReturnType<typeof useState<boolean>>;
export function TextInput({ id, label, placeholder, valueState, required, password, disabled }: { id: string, label?: string, placeholder?: string, valueState: StringUseState, required?: boolean, password?: boolean, disabled?: boolean }) {
const [value, setValue] = valueState;
const onInput = (e: InputEvent) => setValue((e.currentTarget as HTMLInputElement).value);
return (
<>
{label &&
<label for={id} class={`text-md font-medium text-white ${required && "after:content-['*'] after:ml-1 after:text-xl after:text-red-500"}`}>
{label}
</label>
}
<input disabled={disabled} id={id} required={required} value={value} onInput={onInput} placeholder={placeholder} type={password ? "password": "text"} class="border text-sm rounded-lg block w-full bg-[#161b22] border-gray-600 placeholder-gray-400 text-white focus:border-blue-500 focus:shadow-[0px_0px_29px_-10px_rgba(59,130,246,0.5)] px-3 py-2 transition duration-300 disabled:bg-gray-700 disabled:placeholder-white disabled:line-through" />
</>
);
}
export function NumberInput({ id, label, placeholder, valueState, required, min, max, step }: { id: string, label: string, placeholder?: string, valueState: StringUseState, required?: boolean, min?: number, max?: number, step?: number }) {
const [value, setValue] = valueState;
const onInput = (e: InputEvent) => setValue((e.currentTarget as HTMLInputElement).value);
return (
<>
<label for={id} class={`text-md font-medium text-white ${required && "after:content-['*'] after:ml-1 after:text-xl after:text-red-500"}`}>
{label}
</label>
<input id={id} 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-[#161b22] border-gray-600 placeholder-gray-400 text-white focus:border-blue-500 focus:shadow-[0px_0px_29px_-10px_rgba(59,130,246,0.5)] px-3 py-2 transition duration-300" />
</>
);
}
export function FileInput({ required, pathState }: { 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="text-white border-black 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={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=" text-white border-black grow px-px bg-gray-500 rounded-md border py-1 font-semibold text-md hover:bg-gray-400 active:bg-gray-300 enabled:active:scale-103 transition-transform disabled:bg-red-500/70 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1">
<Icon icon="material-symbols:folder-eye" />Preview output directory
</button>
</div>
);
}

31
src/context.ts Normal file
View File

@@ -0,0 +1,31 @@
import {createContext} from "preact";
import {BooleanUseState, StringUseState} from "./components/FormInput";
import { Dispatch, useState } from "preact/hooks";
import { SetStateAction } from "preact/compat";
// Source: https://stackoverflow.com/a/75420688
type NoUndefinedState<T> =
T extends [infer S | undefined, Dispatch<SetStateAction<infer S | undefined>>]
? [S, Dispatch<SetStateAction<S>>]
: never;
export interface AppSettings {
outputDirectoryMode: "Manifest ID" | "Custom"
}
interface AppContext {
username: StringUseState;
password: StringUseState;
appId: StringUseState;
depotId: StringUseState;
manifestId: StringUseState;
outputLocation: StringUseState;
outputFolderName: StringUseState;
downloading: BooleanUseState;
showSettings: BooleanUseState;
appSettings: NoUndefinedState<ReturnType<typeof useState<AppSettings>>>
}
export const AppContext = createContext<Partial<AppContext>>({});

16
src/css/App.css Normal file
View File

@@ -0,0 +1,16 @@
@import "tailwindcss";
@font-face {
font-family: 'Hubot Sans';
src: url('../assets/Hubot-Sans.woff2') format('woff2 supports variations'),
url('../assets/Hubot-Sans.woff2') format('woff2-variations');
font-weight: 700;
font-stretch: expanded;
}
/* Disable + and - buttons for number input */
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}

View File

@@ -1,139 +0,0 @@
@import "tailwindcss";
@font-face {
font-family: 'Hubot Sans';
src: url('../assets/Hubot-Sans.woff2') format('woff2 supports variations'),
url('../assets/Hubot-Sans.woff2') format('woff2-variations');
font-weight: 700;
font-stretch: expanded;
}
@font-face {
font-family: 'Windows';
src: url('../assets/Windows.woff') format('woff2 supports variations'),
url('../assets/Windows.woff') format('woff2-variations');
font-weight: 700;
font-stretch: expanded;
}
.f1-light {
font-family: 'Hubot Sans', sans-serif;
overflow: hidden;
white-space: nowrap;
}
.f2-light {
font-family: 'Hubot Sans', sans-serif;
}
/* The grey part */
.settings-surrounding {
display: none;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: rgba(0, 0, 0, 0.33);
}
.settings-content {
position: relative;
border-radius: 10px;
overflow: auto;
/*noinspection CssUnresolvedCustomProperty*/
background-color: var(--bgColor-default, var(--color-canvas-default));
margin: 1%;
padding: 25px;
border: 1.5px solid white;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1), 0 6px 20px rgba(0, 0, 0, 0.1);
}
[data-color-mode="light"] .settings-content {
border: 1.5px solid black;
}
@media (prefers-color-scheme: light) {
[data-color-mode="auto"] .settings-content {
border: 1.5px solid black;
}
}
.hide {
display: none;
}
hr {
border: 0;
height: 1px;
background: black linear-gradient(to right, #0c1016, #ccc, #0c1016);
}
[data-color-mode="light"] hr {
filter: invert(1);
}
@media (prefers-color-scheme: light) {
[data-color-mode="auto"] hr {
filter: invert(1);
}
}
.version-info {
position: absolute;
bottom: 0;
right: 0;
font-size: 0.9em;
padding: 5px 10px;
font-family: monospace;
cursor: pointer;
}
.AnimatedEllipsis {
display: inline-block;
overflow: hidden;
vertical-align: bottom
}
.AnimatedEllipsis::after {
display: inline-block;
content: "...";
animation: AnimatedEllipsis-keyframes 1s steps(4, jump-none) infinite
}
@keyframes AnimatedEllipsis-keyframes {
0% {
transform: translateX(-100%)
}
}
.opium-button {
position: absolute;
bottom: 0;
left: 0;
cursor: pointer;
margin-left: 5px;
margin-bottom: 4px;
border: 1px solid #000;
background: linear-gradient(180deg, #8C8C8C 25%, #434343 75%);
display: inline-block;
font: 16px "Windows", monospace;
padding: 2px 5px;
color: darkred;
text-decoration: none;
}
.opium-button:hover {
cursor: zoom-in;
background: linear-gradient(180deg, #b0b0b0 25%, #504f4f 75%);
}
.opium-button:active {
cursor: crosshair;
border: 1px inset black;
background: linear-gradient(180deg, #333232 25%, #504f4f 75%);
}

View File

@@ -1,268 +1,13 @@
<!DOCTYPE html>
<html data-color-mode="auto" data-dark-theme="dark" data-light-theme="light" id="theme" lang="en">
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1" name="viewport"/>
<title>SteamDepotDownloaderGUI</title>
<link href="https://unpkg.com/@primer/css@21.3.6/dist/primer.css" rel="stylesheet"/>
<link href="css/style.css" rel="stylesheet">
<script defer src="./ts/preload.ts" type="module"></script>
<script src="./ts/main.ts" type="module"></script>
<script src="./ts/settings.ts" type="module"></script>
<script src="../node_modules/@xterm/xterm/lib/xterm.js"></script>
<link rel="stylesheet" href="../node_modules/@xterm/xterm/css/xterm.css" />
</head>
<body class="select-none">
<div class="f1-light text-center mb-1">Steam Depot Downloader</div>
<div class="flex justify-between gap-2 w-svw flex-row">
<div class="w-1/2 h-full" id="left-side">
<div class="mx-auto">
<form id="theform">
<div class="form-group mx-3 mt-1">
<div class="form-group-header">
<label for="username">Username</label>
</div>
<input spellcheck="false" class="form-control input-block" id="username" placeholder="Leave empty for anonymous download"
type="text"/>
</div>
<div class="form-group mx-3 mt-1">
<div class="form-group-header">
<label for="password">Password</label>
</div>
<input class="form-control input-block" id="password" placeholder="Leave empty for anonymous download"
type="password"/>
</div>
<div class="form-group mx-3 mt-1 required">
<div class="form-group-header">
<label for="appid">App ID</label>
</div>
<input class="form-control input-block" id="appid" type="number"/>
</div>
<div class="form-group mx-3 mt-1 required">
<div class="form-group-header">
<label for="depotid">Depot ID</label>
</div>
<input class="form-control input-block" id="depotid" type="number"/>
</div>
<div class="form-group mx-3 mt-1 required">
<div class="form-group-header">
<label for="manifestid">Manifest ID</label>
</div>
<input class="form-control input-block" id="manifestid" type="number"/>
</div>
<div class="mx-3 mt-1 required">
<div class="form-group-header">
<label>Download Location</label>
</div>
<div aria-label="Pick the path/location where the game will be downloaded to."
class="form-control btn btn-sm tooltipped tooltipped-ne" id="pickpath">
Set location
</div>
<div aria-disabled="true" aria-label="Check the location that has been selected."
class="form-control btn btn-sm ml-2 tooltipped tooltipped-ne" id="checkpath">
Open location
</div>
<span class="Label mt-1 ml-3 Label--warning" id="busy">
<span aria-label="Application is executing a task. Please be patient."
class="tooltipped tooltipped-n">Busy<span class="AnimatedEllipsis"></span>
</span>
</span>
</div>
</form>
<div class="mt-2" id="warning-banners">
<div hidden id="dotnetwarning">
<div class="flash flash-error mx-2 mt-2 color-shadow-medium" id="dotnetalert">
<svg class="octicon" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm6.5-.25A.75.75 0 017.25 7h1a.75.75 0 01.75.75v2.75h.25a.75.75 0 010 1.5h-2a.75.75 0 010-1.5h.25v-2h-.25a.75.75 0 01-.75-.75zM8 6a1 1 0 100-2 1 1 0 000 2z"
fill-rule="evenodd"></path>
</svg>
<code><span class="text-italic">dotnet</span></code> was not found.
<button class="btn btn-sm flash-action" id="dotnetalertbtn">
<svg class="octicon" height="16" viewBox="0 0 16 16" width="16"
xmlns="http://www.w3.org/2000/svg">
<path d="M7.47 10.78a.75.75 0 001.06 0l3.75-3.75a.75.75 0 00-1.06-1.06L8.75 8.44V1.75a.75.75 0 00-1.5
0v6.69L4.78 5.97a.75.75 0 00-1.06 1.06l3.75 3.75zM3.75 13a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5z"
fill-rule="evenodd"></path>
</svg>
<span class="text-bold">Download</span>
</button>
</div>
</div>
<div hidden id="emptywarning">
<div class="flash flash-warn mx-2 mt-2 color-shadow-medium" id="emptyalert">
<svg class="octicon" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm6.5-.25A.75.75 0 017.25 7h1a.75.75 0 01.75.75v2.75h.25a.75.75 0 010 1.5h-2a.75.75 0 010-1.5h.25v-2h-.25a.75.75 0 01-.75-.75zM8 6a1 1 0 100-2 1 1 0 000 2z"
fill-rule="evenodd"></path>
</svg>
Please fill in all required fields.
</div>
</div>
<div id="internet-btns">
<div class="form-group mt-3 ml-3 mr-3">
<div class="BtnGroup d-flex">
<button class="BtnGroup-item btn btn-block btn-primary flex-1" id="downloadbtn">
<svg class="octicon filter-red" height="16"
style="display: inline-block; user-select: none; vertical-align: text-bottom;"
viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path
d="M7.47 10.78a.75.75 0 001.06 0l3.75-3.75a.75.75 0 00-1.06-1.06L8.75 8.44V1.75a.75.75 0 00-1.5 0v6.69L4.78 5.97a.75.75 0 00-1.06 1.06l3.75 3.75zM3.75 13a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5z"
fill-rule="evenodd"></path>
</svg>
Download
</button>
<button class="BtnGroup-item btn flex-0" id="settings-button">
<svg fill="#8B949E" height="16"
style="display: inline-block; user-select: none; vertical-align: text-bottom;"
viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z">
</path>
</svg>
</button>
</div>
</div>
</div>
<div hidden id="nopathwarning">
<div class="flash flash-warn mx-2 mt-2 color-shadow-medium" id="emptyalert">
<svg class="octicon" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm6.5-.25A.75.75 0 017.25 7h1a.75.75 0 01.75.75v2.75h.25a.75.75 0 010 1.5h-2a.75.75 0 010-1.5h.25v-2h-.25a.75.75 0 01-.75-.75zM8 6a1 1 0 100-2 1 1 0 000 2z"
fill-rule="evenodd"></path>
</svg>
Please choose a download location.
</div>
</div>
<div class="flash mx-2 mt-2 color-shadow-medium" hidden id="downloadingnotice">
<svg class="octicon" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm6.5-.25A.75.75 0 017.25 7h1a.75.75 0 01.75.75v2.75h.25a.75.75 0 010 1.5h-2a.75.75 0 010-1.5h.25v-2h-.25a.75.75 0 01-.75-.75zM8 6a1 1 0 100-2 1 1 0 000 2z"
fill-rule="evenodd"></path>
</svg>
Downloading and extracting DepotDownloader<span class="AnimatedEllipsis"></span>
</div>
</div>
</div>
</div>
<div class="w-1/2 h-full px-2" id="right-side">
<div class="mt-2 h-full w-full mx-auto">
<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">
Clear
</button>
</div>
<div class="max-h-[70vh]" id="xtermjs"></div>
</div>
<div class="mt-3 justify-between flex flex-row gap-3">
<div aria-label="Join the Discord server for rapid support." class="btn btn-sm tooltipped tooltipped-ne mb-1 w-full text-center items-center"
id="smbtn1">
<svg fill="#8B949E" height="16" style="display: inline-block; vertical-align: text-bottom;" viewBox="0 0 16 16">
<path d="M13.545 2.907a13.2 13.2 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.2 12.2 0 0 0-3.658 0 8 8 0 0 0-.412-.833.05.05 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.04.04 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032q.003.022.021.037a13.3 13.3 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019q.463-.63.818-1.329a.05.05 0 0 0-.01-.059l-.018-.011a9 9 0 0 1-1.248-.595.05.05 0 0 1-.02-.066l.015-.019q.127-.095.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.05.05 0 0 1 .053.007q.121.1.248.195a.05.05 0 0 1-.004.085 8 8 0 0 1-1.249.594.05.05 0 0 0-.03.03.05.05 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.2 13.2 0 0 0 4.001-2.02.05.05 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.03.03 0 0 0-.02-.019m-8.198 7.307c-.789 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612m5.316 0c-.788 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612"/>
</svg>
Discord
</div>
<div aria-label="Visit the SteamDB instant search website."
class="btn btn-sm tooltipped tooltipped-n mb-1 w-full text-center items-center" id="smbtn2">
<svg aria-hidden="true" class="octicon" fill="#8B949E" height="16" viewBox="0 0 128 128" width="16"
xmlns="http://www.w3.org/2000/svg">
<path
d="M63.9 0C30.5 0 3.1 11.9.1 27.1l35.6 6.7c2.9-.9 6.2-1.3 9.6-1.3l16.7-10c-.2-2.5 1.3-5.1 4.7-7.2 4.8-3.1 12.3-4.8 19.9-4.8 5.2-.1 10.5.7 15 2.2 11.2 3.8 13.7 11.1 5.7 16.3-5.1 3.3-13.3 5-21.4 4.8l-22 7.9c-.2 1.6-1.3 3.1-3.4 4.5-5.9 3.8-17.4 4.7-25.6 1.9-3.6-1.2-6-3-7-4.8L2.5 38.4c2.3 3.6 6 6.9 10.8 9.8C5 53 0 59 0 65.5c0 6.4 4.8 12.3 12.9 17.1C4.8 87.3 0 93.2 0 99.6 0 115.3 28.6 128 64 128c35.3 0 64-12.7 64-28.4 0-6.4-4.8-12.3-12.9-17 8.1-4.8 12.9-10.7 12.9-17.1 0-6.5-5-12.6-13.4-17.4 8.3-5.1 13.3-11.4 13.3-18.2 0-16.5-28.7-29.9-64-29.9zm22.8 14.2c-5.2.1-10.2 1.2-13.4 3.3-5.5 3.6-3.8 8.5 3.8 11.1 7.6 2.6 18.1 1.8 23.6-1.8s3.8-8.5-3.8-11c-3.1-1-6.7-1.5-10.2-1.5zm.3 1.7c7.4 0 13.3 2.8 13.3 6.2 0 3.4-5.9 6.2-13.3 6.2s-13.3-2.8-13.3-6.2c0-3.4 5.9-6.2 13.3-6.2zM45.3 34.4c-1.6.1-3.1.2-4.6.4l9.1 1.7a10.8 5 0 1 1-8.1 9.3l-8.9-1.7c1 .9 2.4 1.7 4.3 2.4 6.4 2.2 15.4 1.5 20-1.5s3.2-7.2-3.2-9.3c-2.6-.9-5.7-1.3-8.6-1.3zM109 51v9.3c0 11-20.2 19.9-45 19.9-24.9 0-45-8.9-45-19.9v-9.2c11.5 5.3 27.4 8.6 44.9 8.6 17.6 0 33.6-3.3 45.2-8.7zm0 34.6v8.8c0 11-20.2 19.9-45 19.9-24.9 0-45-8.9-45-19.9v-8.8c11.6 5.1 27.4 8.2 45 8.2s33.5-3.1 45-8.2z"
fill-rule="evenodd"></path>
</svg>
SteamDB
</div>
<div aria-label="Donate to the author of SteamDepotDownloaderGUI."
class="btn btn-sm tooltipped tooltipped-n mb-1 w-full text-center items-center" id="smbtn3">
<svg fill="#8B949E" height="16" style="display: inline-block; vertical-align: text-bottom;"
viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 2.75A2.75 2.75 0 0 1 4.75 0c.983 0 1.873.42 2.57 1.232.268.318.497.668.68 1.042.183-.375.411-.725.68-1.044C9.376.42 10.266 0 11.25 0a2.75 2.75 0 0 1 2.45 4h.55c.966 0 1.75.784 1.75 1.75v2c0 .698-.409 1.301-1 1.582v4.918A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25V9.332C.409 9.05 0 8.448 0 7.75v-2C0 4.784.784 4 1.75 4h.55c-.192-.375-.3-.8-.3-1.25ZM7.25 9.5H2.5v4.75c0 .138.112.25.25.25h4.5Zm1.5 0v5h4.5a.25.25 0 0 0 .25-.25V9.5Zm0-4V8h5.5a.25.25 0 0 0 .25-.25v-2a.25.25 0 0 0-.25-.25Zm-7 0a.25.25 0 0 0-.25.25v2c0 .138.112.25.25.25h5.5V5.5h-5.5Zm3-4a1.25 1.25 0 0 0 0 2.5h2.309c-.233-.818-.542-1.401-.878-1.793-.43-.502-.915-.707-1.431-.707ZM8.941 4h2.309a1.25 1.25 0 0 0 0-2.5c-.516 0-1 .205-1.43.707-.337.392-.646.975-.879 1.793Z"
fill-rule="evenodd"></path>
</svg>
Donate
</div>
<div aria-label="View the official SteamDepotDownloaderGUI tutorials."
class="btn btn-sm tooltipped tooltipped-nw mb-1 w-full text-center items-center" id="smbtn4">
<svg fill="#8B949E" style="display: inline-block; vertical-align: text-bottom;" height="16" width="16">
<path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25Z"></path>
<path d="M6 10.559V5.442a.25.25 0 0 1 .379-.215l4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559Z"></path>
</svg>
Tutorial
</div>
</div>
</div>
</div>
</div>
<div class="settings-surrounding" id="settings-surrounding">
<div class="settings-content mx-auto max-w-2/3 h-[85vh] mt-4" id="settings-content">
<label class="version-info" id="version-info">UNKNOWN</label>
<button class="opium-button" id="opium-btn">aphex</button>
<h2><b>Settings</b></h2>
<h4><b>Appearance</b></h4>
<div class="form-group">
<div class="form-group-header">
<label>Theme</label>
</div>
<div class="form-group-body">
<div class="BtnGroup">
<button aria-selected="true" class="BtnGroup-item btn btn-sm" id="theme-auto" type="button">
Auto
</button>
<button class="BtnGroup-item btn btn-sm" id="theme-light" type="button">
Light
</button>
<button class="BtnGroup-item btn btn-sm" id="theme-dark" type="button">
Dark
</button>
</div>
</div>
</div>
<h4><b>Output</b></h4>
<form>
<div class="form-group">
<div class="form-group-header">
<label for="folder-name-custom-input">Game directory name</label>
</div>
<div class="form-group-body">
<div class="BtnGroup">
<button aria-selected="true" class="BtnGroup-item btn btn-sm" id="folder-name-appid"
type="button">
Manifest ID
</button>
<button class="BtnGroup-item btn btn-sm" id="folder-name-custom" type="button">
Custom
</button>
</div>
<br>
<input class="form-control input-block mt-2" disabled
id="folder-name-custom-input" placeholder="DepotDownloader output directory name"
type="text">
</div>
</div>
</form>
</div>
</div>
<body>
<div id="root"></div>
<script src="main.tsx" type="module"></script>
</body>
</html>

4
src/main.tsx Normal file
View File

@@ -0,0 +1,4 @@
import {render} from "preact";
import App from "./App";
render(<App />, document.getElementById("root")!);

View File

@@ -1,245 +0,0 @@
import $ from "jquery";
import {invoke} from "@tauri-apps/api/core";
import {open as openDialog} from "@tauri-apps/plugin-dialog";
import {openPath, openUrl} from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css";
import {Terminal} from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { listen } from "@tauri-apps/api/event";
/* Parts of this file are derived from https://github.com/cablehead/tauri-xtermjs-nushell/blob/0bdd4a27ee2874de12e99bccd6c91d6ec5d28fbc/src/main.ts */
function blockTerminalClearButton(state: boolean) {
$("#clear-terminal").prop( "disabled", state );
}
function setLoadingState(state: boolean) {
$("#busy").prop("hidden", !state);
// loop through all buttons and input fields and disable them
for (const element of document.querySelectorAll("button, input, div[role='button']")) {
if (element.closest("#settings-content")) continue;
if (element.closest("#right-side")) continue;
(element as any).disabled = state;
}
// These elements need additional properties to be properly disabled
$("#pickpath").prop("ariaDisabled", state);
$("#downloadbtn").prop("ariaDisabled", state);
// disable internet buttons
for (const element of document.querySelectorAll("#internet-btns div")) {
element.ariaDisabled = String(state);
}
}
/// Returns list of IDs of invalid form fields
const invalidFields = () => {
const form = document.forms[0];
const invalidFields: string[] = [];
for (const input of form) {
const inputElement = input as HTMLInputElement;
const valid = !(inputElement.value === "" && inputElement?.parentElement?.classList.contains("required"));
if (!valid) {
invalidFields.push(inputElement.id);
}
}
// console.debug(`[${invalidFields.join(", ")}] fields invalid/empty`);
return invalidFields;
};
const registerTerminal: (terminalElement: HTMLElement) => Promise<Terminal> = async (terminalElement: HTMLElement) => {
const fitAddon = new FitAddon();
const term = new Terminal({
fontSize: 10,
cursorBlink: true,
rows: 100,
cols: 100,
theme: {
background: "rgb(33, 33, 33)",
},
});
term.loadAddon(fitAddon);
term.open(terminalElement);
async function fitTerminal() {
fitAddon.fit();
void invoke<string>("async_resize_pty", {
rows: term.rows,
cols: term.cols,
});
}
// Write data from pty into the terminal
function writeToTerminal(data: string) {
return new Promise<void>((r) => {
term.write(data, () => r());
});
}
// Write data from the terminal to the pty
function writeToPty(data: string) {
void invoke("async_write_to_pty", {
data,
});
}
term.onData(writeToPty);
addEventListener("resize", fitTerminal);
await fitTerminal();
async function readFromPty() {
const data = await invoke<string>("async_read_from_pty");
if (data) {
await writeToTerminal(data);
}
window.requestAnimationFrame(readFromPty);
}
window.requestAnimationFrame(readFromPty);
return term;
};
$(async () => {
let terminal = await registerTerminal($("#xtermjs")[0]);
let downloadDirectory: string | null;
// Startup logic
setLoadingState(true);
await invoke("preload_vectum");
setLoadingState(false);
$("#clear-terminal").on("click", async () => {
terminal.reset()
})
$("#pickpath").on("click", async () => {
// Open a dialog
downloadDirectory = await openDialog({
title: "Choose where to save the game download.",
multiple: false,
directory: true,
canCreateDirectories: true
});
if (downloadDirectory == null) {
// user cancelled
$("#checkpath").prop("ariaDisabled", true);
$("#checkpath").prop("disabled", true);
return;
}
$("#checkpath").prop("ariaDisabled", false);
$("#checkpath").prop("disabled", false);
$("#downloadbtn").prop("ariaDisabled", false);
$("#nopathwarning").prop("hidden", true);
console.log(downloadDirectory);
});
$("#checkpath").on("click", async () => {
console.log(`Checking path: ${downloadDirectory}`);
if (downloadDirectory != null) {
await openPath(downloadDirectory);
} else {
$("#checkpath").prop("ariaDisabled", true);
}
});
$("#downloadbtn").on("click", async () => {
console.log("download button clicked");
if (invalidFields().length > 0) {
// Loop through invalid fields. If there are any, make those "errored" and block the download button.
for (const id of invalidFields()) {
document.getElementById(id)?.parentElement?.classList.toggle("errored", true);
}
$("#emptywarning").prop("hidden", false);
$("#downloadbtn").prop("ariaDisabled", true);
return;
}
if (downloadDirectory == null) {
$("#nopathwarning").prop("hidden", false);
$("#downloadbtn").prop("ariaDisabled", true);
return;
}
setLoadingState(true);
$("#downloadingnotice").prop("hidden", false);
$("#busy").prop("hidden", true); // Don't show the loader this time.
const directoryNameChoice = $("#folder-name-custom-input").val();
// Output path w/ directories chosen is: {downloadDirectory}/{directoryNameChoice}
const vectumOptions = {
output_directory: downloadDirectory || null, // if not specified let backend choose a path.
directory_name: directoryNameChoice || null,
};
const steamDownload = {
// String || null translate to Some(String) || None
username: String($("#username").val()).trim() || null,
password: String($("#password").val()).trim() || null,
app_id: $("#appid").val(),
depot_id: $("#depotid").val(),
manifest_id: $("#manifestid").val(),
options: vectumOptions
};
// console.debug(steamDownload);
await invoke("download_depotdownloader");
$("#downloadingnotice").prop("hidden", true);
setLoadingState(false);
console.debug("DepotDownloader download process completed. Starting game download...");
setLoadingState(true);
await invoke("start_download", {steamDownload: steamDownload});
// Block clear terminal button (to avoid clearing ongoing download logs)
blockTerminalClearButton(true);
console.log("Send frontend data over to backend. Ready for next download.");
});
$("#settings-button").on("click", async () => {
$("#settings-surrounding").toggle();
});
$("#settings-surrounding").on("click", (event) => {
if (event.target === document.getElementById("settings-surrounding")) {
$("#settings-surrounding").toggle();
}
});
$("#opium-btn").on("click", () => {
openUrl("https://aphex.cc/index.html");
});
document.forms[0].addEventListener("input", (event) => {
// Remove errored class. This is a bad way to do it, but it works for now.
const target = event.target as HTMLElement;
target?.parentElement?.classList.toggle("errored", false);
// If there are no more invalid fields, hide the warning and enable the download button again
if (invalidFields().length === 0) {
$("#emptywarning").prop("hidden", true);
$("#downloadbtn").prop("ariaDisabled", false);
}
});
});
listen<string>("command-exited", () => {
setLoadingState(false);
blockTerminalClearButton(false);
});

View File

@@ -1,34 +0,0 @@
import {message} from "@tauri-apps/plugin-dialog";
import {invoke} from "@tauri-apps/api/core";
import $ from "jquery";
import {openUrl} from "@tauri-apps/plugin-opener";
$(async () => {
/* eslint-disable indent */
switch (await invoke("internet_connection")) {
case false: {
await message("No internet connection! Can't proceed.", {
title: "SteamDepotDownloaderGUI", kind: "error", okLabel: "Close"
});
}
}
/* eslint-enable indent */
// discord
$("#smbtn1").on("click", () => {
openUrl("https://discord.com/invite/3qCt4DT5qe");
});
// steamdb
$("#smbtn2").on("click", () => {
openUrl("https://steamdb.info/instantsearch");
});
// donate
$("#smbtn3").on("click", () => {
openUrl("https://paypal.me/onderkin");
});
// tutorial
$("#smbtn4").on("click", () => {
openUrl("https://youtube.com/playlist?list=PLRAjc5plLScj967hnsYX-I3Vjw9C1v7Ca");
});
});

View File

@@ -1,47 +0,0 @@
import {getVersion} from "@tauri-apps/api/app";
import $ from "jquery";
import {openUrl} from "@tauri-apps/plugin-opener";
$(async () => {
$("#version-info").text(`v${await getVersion()}`);
$("#theme-auto").on("click", () => {
setTheme("auto");
});
$("#theme-light").on("click", () => {
setTheme("light");
});
$("#theme-dark").on("click", () => {
setTheme("dark");
});
$("#folder-name-appid").on("click", () => {
$("#folder-name-custom").attr("aria-selected", "false");
$("#folder-name-appid").attr("aria-selected", "true");
$("#folder-name-custom-input").prop("disabled", true);
$("#folder-name-custom-input").val("");
});
// todo: fix folder-name-custom-input not disabled on untouched app state
$("#folder-name-custom").on("click", () => {
$("#folder-name-appid").attr("aria-selected", "false");
$("#folder-name-custom").attr("aria-selected", "true");
$("#folder-name-custom-input").prop("disabled", false);
});
console.log(await getVersion());
$("#version-info").on("click", async () => {
await openUrl(`https://github.com/mmvanheusden/SteamDepotDownloaderGUI/releases/v${await getVersion()}`);
});
});
function setTheme(theme: string) {
$("#theme-auto").attr("aria-selected", String(theme === "auto"));
$("#theme-light").attr("aria-selected", String(theme === "light"));
$("#theme-dark").attr("aria-selected", String(theme === "dark"));
$("#theme").attr("data-color-mode", theme);
localStorage.theme = theme;
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -3,25 +3,24 @@
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
]
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,4 +1,5 @@
import {defineConfig} from "vite";
import preact from "@preact/preset-vite";
import tailwindcss from '@tailwindcss/vite'
// @ts-expect-error process is a nodejs global
@@ -35,5 +36,6 @@ export default defineConfig(async () => ({
},
plugins: [
tailwindcss(),
preact(),
]
}));