8 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
Maarten van Heusden
855d31f6df chore: set version to v3.1.0, disable maximizing 2026-02-02 09:50:00 +01:00
Maarten van Heusden
2995a28d5c feat: clear terminal button, cleanup styling 2026-02-02 09:44:33 +01:00
Maarten
f12000df52 feat: integrated terminal (#303)
* feat: integrated terminal

* fix: settings modal

* chore: make compatible for windows

* feat: block app when downloading

* docs: update README with new interface

* refactor: cleanup main.ts styling
2026-02-01 17:42:22 +01:00
28 changed files with 732 additions and 2912 deletions

View File

@@ -12,54 +12,34 @@
<a href="https://github.com/mmvanheusden/SteamDepotDownloaderGUI/releases/latest"><img src="https://img.shields.io/badge/Download -ffbd03?style=for-the-badge&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAABmJLR0QA/wD/AP+gvaeTAAABd0lEQVRoge2XwUrDQBCGPz3Ee++2UBSvdsGbeLHeFX0lPQhei+8g2AfwSVREK+LJ1oqHUGjRQ7Z0laRJNtldKPPBwoTM7D9/sks2IAiCsKrsAK/AC7DtSmTd1cTAKbAJtIAzVyIuDURGvOFKxKUBL4iB0IiB0IiB0IiB0IiB0IiB0IiB0IgBoA08Ak+AKpCvdO6AGn416zBwDGyRGLljuQmlc9pAEzipKl6HgVtgpOMGSYOdlLyOvtfQ1yPgpgb9WlDAEPjRYwj0jet+yv0iy80ru8AHiyazxiewF6jHXPJMeG3+CoiBy5J1/5dTlWVzAXwD5yXrAJho4YlFbdqesFnzsa6PLWr/PD0bFPAA3GO/YXN7WMspLpLnktweVvooMTPiKDPLHabmLCtpmYF3I96v3E55Doz4zWaCaxYb6Bno4udNRMCR1pzr92wmagFfZH+UfI0xycHPim5gE2Pg0Lb5OU2SVzgAph6anmqtHhWevCAIgh9+AdLMtu/CZhHJAAAAAElFTkSuQmCC" alt="Download latest release badge"></a>
<a href="https://github.com/mmvanheusden/SteamDepotDownloaderGUI/releases/latest"><img src="https://img.shields.io/github/downloads/mmvanheusden/SteamDepotDownloaderGUI/total?color=orange&label=downloads" alt="Download count badge"></a>
<img src="https://github.com/user-attachments/assets/2e1b1b8e-9560-4dde-86c0-b70384a54fbb" alt="Steam downgrader interface" style="max-width: 40%;"/>
<img src="https://github.com/user-attachments/assets/0a2debcc-617a-449d-971f-9e1cf1d5cb0b" alt="Steam downgrader interface" />
</div>
## Features
- **Cross-platform support**
| OS | Supported |
|---------|-----------|
| Windows | ✅ |
| Linux | ✅ |
| macOS | ✅ |
> ↓ *The downloader in action:*<br>
> <img src="https://github.com/user-attachments/assets/8739f06e-a258-48b2-a684-ca8fcf84dd7e" alt="Steam downgrader process" style="width: 25svw;"/>
- **Support for every major Linux terminal emulator**
<details><summary>List of supported terminals</summary>
### Cross-platform support
| OS | Supported |
|---------|-----------|
| Windows | ✅ |
| Linux | ✅ |
| macOS | ✅ |
* GNOME Terminal
* GNOME Console
* Konsole
* Xfce-terminal
* Alacritty
* XTerm
* Terminator
* cool-retro-term
* Kitty
* LXTerminal
* Deepin Terminal
* Terminology
* Tilix
</details>
- **Automatic download and extraction of DepotDownloader**
## How to download
## Installation
> [!CAUTION]
> This GitHub repository is the only official place to download this software.
> If you have paid for this software, or downloaded this from an untrusted place, **you are at risk** <sub><sup><sub><sup>and an idiot.<sub><sup><sub><sup>
> If you have paid for this software, or downloaded this from a different place than here, **you are an idiot and at risk**.
### Windows:
### Windows
Download the [latest Windows release](https://github.com/mmvanheusden/SteamDepotDownloaderGUI/releases/latest). There are multiple variants to choose from, but you are probably looking for the file that ends with **`.exe`**.
### Linux:
### Linux
You'll need at least one of the supported terminal emulators. You most likely already have one of these.
Download the [latest Linux release](https://github.com/mmvanheusden/SteamDepotDownloaderGUI/releases/latest). There are multiple options to choose from.
Download the [latest Linux release](https://github.com/mmvanheusden/SteamDepotDownloaderGUI/releases/latest). There are multiple formats to choose from.
## Tutorials
* https://www.youtube.com/watch?v=H2COwT5OUOo How to download older versions of Steam games tutorial
@@ -96,4 +76,3 @@ $ pnpm eslint --fix src/
<img src="https://github.com/mmvanheusden/SteamDepotDownloaderGUI/assets/50550545/83f5f3b2-2bf9-41aa-ab87-880466f785fe" height="40px">
</a>
</p>

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

@@ -1,7 +1,7 @@
{
"name": "vectum",
"private": true,
"version": "3.0.1",
"version": "3.1.0",
"type": "module",
"license": "GPL-3.0-only",
"scripts": {
@@ -11,16 +11,20 @@
"tauri": "tauri"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@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",
"jquery": "^4.0.0"
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.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",

1698
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

157
src-tauri/Cargo.lock generated
View File

@@ -473,6 +473,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -855,6 +861,12 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -902,7 +914,7 @@ dependencies = [
"rustc_version",
"toml 0.9.11+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -1020,6 +1032,17 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2258,6 +2281,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -2553,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"
@@ -2842,6 +2867,27 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg 0.10.1",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2962,7 +3008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.1",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
@@ -3003,7 +3049,7 @@ version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2",
@@ -3703,6 +3749,17 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "serial2"
version = "0.2.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c"
dependencies = [
"cfg-if",
"libc",
"winapi",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -3758,15 +3815,20 @@ dependencies = [
]
[[package]]
name = "shared_child"
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
"sigchld",
"windows-sys 0.60.2",
]
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
@@ -3774,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"
@@ -4282,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"
@@ -4901,10 +4921,11 @@ dependencies = [
[[package]]
name = "vectum"
version = "3.0.1"
version = "3.1.0"
dependencies = [
"derive-getters",
"fix-path-env",
"portable-pty",
"reqwest 0.13.1",
"serde",
"serde_json",
@@ -4912,7 +4933,6 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-shell",
"zip",
]
@@ -5641,6 +5661,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.55.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "vectum"
version = "3.0.1"
version = "3.1.0"
description = "Download older versions of Steam games with DepotDownloader"
authors = ["mmvanheusden"]
edition = "2021"
@@ -15,13 +15,13 @@ 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"
reqwest = { version = "0.13.1",features = ["blocking"] }
zip = "7.2.0"
tauri-plugin-opener = "2"
portable-pty = "0.9.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

@@ -6,46 +6,29 @@ mod steam;
mod terminal;
use crate::depotdownloader::{get_depotdownloader_url, DEPOTDOWNLOADER_VERSION};
use crate::terminal::Terminal;
use std::env;
use crate::terminal::{async_read_from_pty, async_resize_pty, async_write_to_pty};
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::OnceLock;
use std::sync::{Arc};
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_shell::ShellExt;
use std::{env, thread};
use tauri::async_runtime::Mutex;
use tauri::{AppHandle, Emitter, Manager, State};
/// The first terminal found. Used as default terminal.
static TERMINAL: OnceLock<Vec<Terminal>> = OnceLock::new(); // We create this variable now, and quickly populate it in preload_vectum(). we then later access the data in start_download()
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 TERMINAL.get().is_none() {
TERMINAL.set(terminal::get_installed_terminals(true, app.shell()).await).expect("Failed to set available terminals")
}
if WORKING_DIR.get().is_none() {
WORKING_DIR.set(Path::join(&app.path().local_data_dir().unwrap(), "SteamDepotDownloaderGUI")).expect("Failed to configure working directory")
}
// Send the default terminal name to the frontend.
app.emit(
"default-terminal",
Terminal::pretty_name(&TERMINAL.get().unwrap()[0]),
).unwrap();
struct AppState {
pty_pair: Arc<Mutex<PtyPair>>,
writer: Arc<Mutex<Box<dyn Write + Send>>>,
reader: Arc<Mutex<BufReader<Box<dyn Read + Send>>>>,
}
#[tauri::command]
async fn start_download(steam_download: steam::SteamDownload, app: AppHandle) {
let default_terminal = TERMINAL.get().unwrap();
let shell = app.shell();
let terminal_to_use = if steam_download.options().terminal().is_none() { default_terminal.first().unwrap() } else { &Terminal::from_index(&steam_download.options().terminal().unwrap()).unwrap() };
// Also change working directory
std::env::set_current_dir(&WORKING_DIR.get().unwrap()).unwrap();
async fn start_download(steam_download: steam::SteamDownload, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
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:");
@@ -55,23 +38,44 @@ async fn start_download(steam_download: steam::SteamDownload, app: AppHandle) {
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- Default terminal: {}", Terminal::pretty_name(&default_terminal[0]));
println!("\t- Working directory: {}", &WORKING_DIR.get().unwrap().display());
println!("\t- Terminal command: \n\t {:?}", terminal_to_use.create_command(&steam_download, shell, &WORKING_DIR.get().unwrap()));
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);
terminal_to_use.create_command(&steam_download, shell, &WORKING_DIR.get().unwrap()).spawn().ok();
// add the $TERM env variable so we can use clear and other commands
#[cfg(target_os = "windows")]
cmd.env("TERM", "cygwin");
#[cfg(not(target_os = "windows"))]
cmd.env("TERM", "xterm-256color");
let mut child = state
.pty_pair
.lock()
.await
.slave
.spawn_command(cmd)
.map_err(|err| err.to_string())?;
thread::spawn(move || {
let status = child.wait().unwrap();
println!("Command exited with status: {status}");
app.emit("command-exited", {}).unwrap();
// exit(status.exit_code() as i32)
});
Ok(())
}
/// 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 {
@@ -85,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.");
}
@@ -97,17 +101,6 @@ async fn internet_connection() -> bool {
client.get("https://connectivitycheck.android.com/generate_204").send().await.is_ok()
}
#[tauri::command]
async fn get_all_terminals(app: AppHandle) {
let terminals = terminal::get_installed_terminals(false, app.shell()).await;
terminals.iter().for_each(|terminal| {
println!("Terminal #{} ({}) is installed!", terminal.index().unwrap(), terminal.pretty_name());
// Sends: (terminal index aligned with dropdown; total terminals)
app.emit("working-terminal", (terminal.index(), Terminal::total())).unwrap();
});
}
pub fn get_os() -> &'static str {
match env::consts::OS {
@@ -118,10 +111,15 @@ 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 "/".
if get_os() == "macos" {
let _ = fix_path_env::fix(); // todo: does this actually do something useful
// todo: Is this still needed ??
/* if get_os() == "macos" {
let _ = fix_path_env::fix();
// let documents_dir = format!(
// "{}/Documents/SteamDepotDownloaderGUI",
// std::env::var_os("HOME").unwrap().to_str().unwrap()
@@ -131,14 +129,39 @@ fn main() {
// std::fs::create_dir_all(documents_dir).unwrap();
// env::set_current_dir(documents_dir).unwrap();
}
}*/
/* Initialize the pty system */
let pty_system = native_pty_system();
let pty_pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let reader = pty_pair.master.try_clone_reader().unwrap();
let writer = pty_pair.master.take_writer().unwrap();
println!();
tauri::Builder::default().plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init()).plugin(tauri_plugin_shell::init()).invoke_handler(tauri::generate_handler![
tauri::Builder::default()
.manage(AppState {
pty_pair: Arc::new(Mutex::new(pty_pair)),
writer: Arc::new(Mutex::new(writer)),
reader: Arc::new(Mutex::new(BufReader::new(reader))),
})
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
start_download,
download_depotdownloader,
internet_connection,
preload_vectum,
get_all_terminals
]).run(tauri::generate_context!()).expect("error while running tauri application");
}
async_write_to_pty,
async_read_from_pty,
async_resize_pty,
]).run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -5,20 +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 {
terminal: Option<u8>,
output_directory: Option<PathBuf>,
directory_name: Option<String>
output_location: Option<PathBuf>,
output_directory_name: Option<String>
}
@@ -31,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

@@ -1,309 +1,79 @@
use crate::get_os;
use crate::steam::SteamDownload;
use std::fs;
use std::io::BufRead;
use std::path::PathBuf;
use tauri::Wry;
use tauri_plugin_shell::process::Command;
use tauri_plugin_shell::Shell;
use portable_pty::{CommandBuilder, PtySize};
use tauri::State;
use crate::AppState;
use crate::steam::SteamDownload;
/// Represents a terminal that can be used to run commands.
/// **Should be in sync with the terminal dropdown in the frontend.**
#[derive(Debug, PartialEq)]
pub enum Terminal {
GNOMETerminal,
Alacritty,
Konsole,
GNOMEConsole,
Xfce4Terminal,
DeepinTerminal,
Terminator,
Kitty,
LXTerminal,
Tilix,
XTerm,
CMD,
Terminal
/* Parts of this file are derived from https://github.com/cablehead/tauri-xtermjs-nushell/blob/0bdd4a27ee2874de12e99bccd6c91d6ec5d28fbc/src-tauri/src/main.rs */
#[tauri::command]
pub async fn async_write_to_pty(data: &str, state: State<'_, AppState>) -> Result<(), ()> {
write!(state.writer.lock().await, "{}", data).map_err(|_| ())
}
#[tauri::command]
pub async fn async_read_from_pty(state: State<'_, AppState>) -> Result<Option<String>, ()> {
let mut reader = state.reader.lock().await;
let data = {
// Read all available text
let data = reader.fill_buf().map_err(|_| ())?;
impl Terminal {
/// Iterates through each terminal
pub fn iter() -> impl Iterator<Item=Terminal> {
use self::Terminal::*;
vec![
GNOMETerminal, Alacritty, Konsole, GNOMEConsole, Xfce4Terminal, DeepinTerminal, Terminator, Kitty, LXTerminal, Tilix, XTerm, CMD, Terminal
].into_iter()
}
/// Get terminal from index in order of the [`Terminal`] enum
pub fn from_index(index: &u8) -> Option<Terminal> {
Terminal::iter().nth(*index as usize)
}
/// Get the index of a terminal in the order of the [`Terminal`] enum
/// Returns `None` if the terminal is not found.
pub fn index(&self) -> Option<u8> {
Terminal::iter().position(|x| x == *self).map(|x| x as u8)
}
/// Get total number of terminals **possible** depending on the OS
pub fn total() -> u8 {
if get_os() == "windows" || get_os() == "macos" {
return 1;
// Send the data to the webview if necessary
if !data.is_empty() {
std::str::from_utf8(data)
.map(|v| Some(v.to_string()))
.map_err(|_| ())?
} else {
None
}
Terminal::iter().count() as u8 - 1 // -1 because cmd is not available on linux
}
/// Get the pretty name of a terminal
pub fn pretty_name(&self) -> &str {
match self {
Terminal::GNOMETerminal => "GNOME Terminal",
Terminal::GNOMEConsole => "GNOME Console",
Terminal::Konsole => "Konsole",
Terminal::Xfce4Terminal => "Xfce Terminal",
Terminal::Terminator => "Terminator",
Terminal::XTerm => "XTerm",
Terminal::Kitty => "Kitty",
Terminal::LXTerminal => "LXTerminal",
Terminal::Tilix => "Tilix",
Terminal::DeepinTerminal => "Deepin Terminal",
Terminal::Alacritty => "Alacritty",
Terminal::CMD => "cmd",
Terminal::Terminal => "Terminal"
}
}
//region Probing a terminal
/// Checks if a [`Terminal`] is installed.
/// **See:** [`get_installed_terminals`]
pub async fn installed(&self, shell: &Shell<Wry>) -> bool {
match self {
Terminal::CMD => get_os() == "windows",
Terminal::GNOMETerminal => shell.command("gnome-terminal").arg("--version").status().await.is_ok(),
Terminal::GNOMEConsole => shell.command("kgx").arg("--version").status().await.is_ok(),
Terminal::Konsole => shell.command("konsole").arg("--version").status().await.is_ok(),
Terminal::Xfce4Terminal => shell.command("xfce4-terminal").arg("--version").status().await.is_ok(),
Terminal::Terminator => shell.command("terminator").arg("--version").status().await.is_ok(),
Terminal::XTerm => shell.command("xterm").arg("-v").status().await.is_ok(),
Terminal::Kitty => shell.command("kitty").arg("--version").status().await.is_ok(),
Terminal::LXTerminal => shell.command("lxterminal").arg("--version").status().await.is_ok(),
Terminal::Tilix => shell.command("tilix").arg("--version").status().await.is_ok(),
Terminal::DeepinTerminal => shell.command("deepin-terminal").arg("--version").status().await.is_ok(),
Terminal::Alacritty => shell.command("alacritty").arg("--version").status().await.is_ok(),
Terminal::Terminal => get_os() == "macos",
}
}
//endregion
//region Running a command in the terminal
/**
Returns a [`Command`] that, when executed should open the terminal and run the command.
## Commands
`{command}` = `{command};echo Command finished with code $?;sleep infinity`
| Terminal | Command to open terminal |
|------------------|--------------------------------------------------------------------------|
| cmd | `start cmd.exe /k {command}` |
| GNOMETerminal | `gnome-terminal -- /usr/bin/env sh -c {command}` |
| GNOMEConsole | `kgx -e /usr/bin/env sh -c {command}` |
| Konsole | `konsole -e /usr/bin/env sh -c {command}` |
| Xfce4Terminal | `xfce4-terminal -x /usr/bin/env sh -c {command}` |
| Terminator | `terminator -T "Downloading depot..." -e {command}` |
| XTerm | `xterm -hold -T "Downloading depot..." -e /usr/bin/env sh -c {command}` |
| Kitty | `kitty /usr/bin/env sh -c {command}` |
| LXTerminal | `lxterminal -e /usr/bin/env sh -c {command}` |
| Tilix | `tilix -e /usr/bin/env sh -c {command}` |
| DeepinTerminal | `deepin-terminal -e /usr/bin/env sh -c {command}` |
| Alacritty | `alacritty -e /usr/bin/env sh -c {command}` |
| Terminal (macOS) | We create a bash script and run that using `open`. |
*/
pub fn create_command(&self, steam_download: &SteamDownload, shell: &Shell<Wry>, working_dir: &PathBuf) -> Command {
let command = create_depotdownloader_command(steam_download);
match self {
Terminal::CMD => {
return shell.command("cmd.exe").args(&["/c", "start", "PowerShell.exe", "-NoExit", "-Command"]).args(command);
/* let mut cmd = std::process::Command::new("cmd.exe");
cmd.args(&["/c", "start", "PowerShell.exe", "-NoExit", "-Command"]).args(command);
return cmd*/
}
Terminal::GNOMETerminal => {
shell.command("gnome-terminal")
.args(&["--", "/usr/bin/env", "sh", "-c"])
.args(command)
.current_dir(working_dir.as_path())
}
Terminal::GNOMEConsole => {
shell.command("kgx")
.args(&["-e", "/usr/bin/env", "sh", "-c"])
.args(command)
.current_dir(working_dir.as_path())
}
Terminal::Konsole => {
shell.command("konsole")
.args(&["-e", "/usr/bin/env", "sh", "-c"])
.args(command)
.current_dir(working_dir.as_path())
}
Terminal::Xfce4Terminal => {
shell.command("xfce4-terminal")
.args(&["-x", "/usr/bin/env", "sh", "-c"])
.args(command)
.current_dir(working_dir.as_path())
}
Terminal::Terminator => {
shell.command("terminator")
.args(&["-T", "Downloading depot...", "-e"])
.args(command)
.current_dir(working_dir.as_path())
}
Terminal::XTerm => {
shell.command("xterm")
.args(&["-hold", "-T", "Downloading depot...", "-e", "/usr/bin/env", "sh", "-c"])
.args(command)
.current_dir(working_dir.as_path())
}
Terminal::Kitty => {
shell.command("kitty")
.args(&["/usr/bin/env", "sh", "-c"])
.args(command)
.current_dir(working_dir.as_path())
}
Terminal::LXTerminal => {
shell.command("lxterminal")
.args(&["-e", "/usr/bin/env", "sh", "-c"])
.args(command)
.current_dir(working_dir.as_path())
}
Terminal::Tilix => {
shell.command("tilix")
.args(&["-e", "/usr/bin/env", "sh", "-c"])
.args(command)
.current_dir(working_dir.as_path())
}
Terminal::DeepinTerminal => {
shell.command("deepin-terminal")
.args(&["-e", "/usr/bin/env", "sh", "-c"])
.args(command)
.current_dir(working_dir.as_path())
}
Terminal::Alacritty => {
shell.command("alacritty")
.args(&["-e", "/usr/bin/env", "sh", "-c"])
.args(command)
.current_dir(working_dir.as_path())
}
Terminal::Terminal => {
// Create a bash script and run that. Not very secure but it makes this easier.
let download_script = format!("#!/bin/bash\ncd {}\n{}",working_dir.to_str().unwrap().replace(" ", "\\ "), command[0]);
fs::write("./script.sh", download_script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions("./script.sh", fs::Permissions::from_mode(0o755)).unwrap(); // Won't run without executable permission
}
shell.command("/usr/bin/open")
.args(&["-a", "Terminal", "./script.sh"])
.current_dir(working_dir.as_path())
}
}
}
//endregion
}
/**
Checks if terminals are installed by checking if they respond to commands.
## How it works
Probes a list of popular terminals and checks if they return an error when calling their `--version` or similar command line flag.
## Options
* `return_immediately`: [`bool`]: Return as soon as one terminal is found.
## Returns
A vector containing a list of terminals that should work.
## Commands
| Terminal | Command to check if installed |
|----------------|-------------------------------|
| cmd | `cmd /?` |
| GNOMETerminal | `gnome-terminal --version` |
| GNOMEConsole | `kgx --version` |
| Konsole | `konsole --version` |
| Xfce4Terminal | `xfce4-terminal --version` |
| Terminator | `terminator --version` |
| XTerm | `xterm -v` |
| Kitty | `kitty --version` |
| LXTerminal | `lxterminal --version` |
| Tilix | `tilix --version` |
| DeepinTerminal | `deepin-terminal --version` |
| Alacritty | `alacritty --version` |
*/
pub async fn get_installed_terminals(return_immediately: bool, shell: &Shell<Wry>) -> Vec<Terminal> {
match get_os() {
"windows" => { return vec!(Terminal::CMD); }
"macos" => { return vec!(Terminal::Terminal); }
_ => {}
}
let mut available_terminals: Vec<Terminal> = Vec::new();
for terminal in Terminal::iter() {
// Probe terminal. If it doesn't raise an error, it is probably installed.
if terminal.installed(shell).await {
if return_immediately {
return vec![terminal];
}
available_terminals.push(terminal);
}
}
if available_terminals.is_empty() {
eprintln!("No terminals were detected. Try installing one.");
}
available_terminals
}
/// Creates the DepotDownloader command necessary to download the requested manifest.
fn create_depotdownloader_command(steam_download: &SteamDownload) -> Vec<String> {
let output_dir = if get_os() == "windows" {
// In PowerShell, spaces can be escaped with a backtick.
steam_download.output_path().replace(" ", "` ")
} else {
// In bash, spaces can be escaped with a backslash.
steam_download.output_path().replace(" ", "\\ ")
};
if cfg!(not(windows)) {
if steam_download.is_anonymous() {
vec![format!(r#"./DepotDownloader -app {} -depot {} -manifest {} -dir {};echo Done!;sleep infinity"#, steam_download.app_id(), steam_download.depot_id(), steam_download.manifest_id(), output_dir)]
} else {
vec![format!(r#"./DepotDownloader -username {} -password {} -app {} -depot {} -manifest {} -dir {};echo Done!;sleep infinity"#, steam_download.username().clone().unwrap(), steam_download.password().clone().unwrap(), steam_download.app_id(), steam_download.depot_id(), steam_download.manifest_id(), output_dir)]
}
} else {
if steam_download.is_anonymous() {
vec![format!(r#".\DepotDownloader.exe -app {} -depot {} -manifest {} -dir {}"#, steam_download.app_id(), steam_download.depot_id(), steam_download.manifest_id(), output_dir)]
} else {
vec![format!(r#".\DepotDownloader.exe -username {} -password {} -app {} -depot {} -manifest {} -dir {}"#, steam_download.username().clone().unwrap(), steam_download.password().clone().unwrap(), steam_download.app_id(), steam_download.depot_id(), steam_download.manifest_id(), output_dir)]
}
if let Some(data) = &data {
reader.consume(data.len());
}
Ok(data)
}
#[tauri::command]
pub async fn async_resize_pty(rows: u16, cols: u16, state: State<'_, AppState>) -> Result<(), ()> {
state
.pty_pair
.lock()
.await
.master
.resize(PtySize {
rows,
cols,
..Default::default()
})
.map_err(|_| ())
}
/// Creates the DepotDownloader command necessary to download the requested manifest.
pub fn create_depotdownloader_command(steam_download: &SteamDownload, cwd: &PathBuf) -> CommandBuilder {
let depotdownloader_binary = if cfg!(windows) {
"DepotDownloader.exe"
} else {
"DepotDownloader"
};
let program = cwd.join(depotdownloader_binary);
let mut command = CommandBuilder::new(program);
command.cwd(cwd);
if !steam_download.is_anonymous() {
command.args(["-username", &steam_download.username().clone().unwrap()]);
command.args(["-password", &steam_download.password().clone().unwrap()]);
}
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

@@ -1,6 +1,6 @@
{
"productName": "SteamDepotDownloaderGUI",
"version": "3.0.1",
"version": "3.1.0",
"identifier": "net.oopium.depotdownloader",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -13,9 +13,10 @@
"windows": [
{
"title": "SteamDepotDownloaderGUI",
"width": 445,
"width": 890,
"height": 650,
"resizable": false
"resizable": false,
"maximizable": false
}
],
"security": {

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,135 +0,0 @@
@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;
}
/* 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: 5%;
padding: 25px;
border: 1.5px solid white;
width: 90vw; /* 90vw -> 90% */
height: 90vh; /* 90vh -> 90% */
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,286 +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>
</head>
<body>
<div class="mx-auto">
<div>
<div class="f1-light text-center">Steam Depot Downloader</div>
<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 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 aria-disabled="true" 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 aria-label="Join the Discord server for rapid support." class="btn btn-sm ml-3 tooltipped tooltipped-ne mb-1"
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 ml-2 tooltipped tooltipped-n mb-1" 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 ml-2 tooltipped tooltipped-n mb-1" 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 ml-2 tooltipped tooltipped-nw mb-1" 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 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 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="settings-surrounding" id="settings-surrounding">
<div class="settings-content mx-auto" 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>
<hr>
<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>
<hr>
<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>
<hr>
<h4><b>Debugging</b></h4>
<div class="form-group">
<div class="form-group-header">
<label for="terminal-dropdown">Linux only: Force a terminal</label>
</div>
<div class="form-group-body">
<div class="mb-2">
<select class="form-select" id="terminal-dropdown">
<!-- "(not installed)" part is sliced later. -->
<option disabled>GNOME Terminal (not installed)</option>
<option disabled>Alacritty (not installed)</option>
<option disabled>Konsole (not installed)</option>
<option disabled>GNOME Console (not installed)</option>
<option disabled>Xfce Terminal (not installed)</option>
<option disabled>Deepin Terminal (not installed)</option>
<option disabled>Terminator (not installed)</option>
<option disabled>Kitty (not installed)</option>
<option disabled>LXTerminal (not installed)</option>
<option disabled>Tilix (not installed)</option>
<option disabled>XTerm (not installed)</option>
<option disabled>CMD (not installed)</option>
<option disabled>macOS Terminal (not installed)</option>
<option selected="selected">Auto</option>
</select>
<br>
found: <span class="Counter"><code id="terminals-found">none</code></span>
<br>default: <span class="Counter"><code id="default-terminal">none</code></span>
</div>
</div>
</div>
<hr>
</form>
</div>
</div>
<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,223 +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 {listen} from "@tauri-apps/api/event";
function setLoader(state: boolean) {
$("#busy").prop("hidden", !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")) {
if (element.closest("#settings-content")) 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;
};
$(async () => {
let terminalsCollected = false;
let downloadDirectory: string | null;
// Startup logic
setLoadingState(true);
await invoke("preload_vectum");
setLoadingState(false);
// Collect the rest of the terminals in the background.
if (!terminalsCollected) {
setLoader(true);
// @ts-ignore
const terminals = await invoke("get_all_terminals") as string[];
for (const terminal in terminals) {
console.log(terminal);
}
// Allow opening settings now that it is ready to be shown.
$("#settings-button").prop("ariaDisabled", false);
terminalsCollected = true;
setLoader(false);
}
$("#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 terminalChoice = (document.getElementById("terminal-dropdown") as HTMLSelectElement).selectedIndex;
const directoryNameChoice = $("#folder-name-custom-input").val();
// Output path w/ directories chosen is: {downloadDirectory}/{directoryNameChoice}
const vectumOptions = {
terminal: terminalChoice == 13 ? null : terminalChoice,
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...");
await invoke("start_download", {steamDownload: steamDownload});
console.log("Send frontend data over to backend. Ready for next download.");
});
$("#settings-button").on("click", async () => {
if (terminalsCollected) $("#settings-surrounding").css("display", "block");
});
$("#settings-surrounding").on("click", (event) => {
if (event.target === document.getElementById("settings-surrounding")) {
$("#settings-surrounding").css("display", "none");
}
});
$("#opium-btn").on("click", () => {
openUrl("https://l.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);
}
});
});
let a = 0;
// Each terminal that is installed gets received from rust with this event.
listen<[number, number]>("working-terminal", (event) => {
a++;
console.log(
`Terminal #${event.payload[0]} is installed. a = ${a}`
);
const terminalSelection = (document.getElementById("terminal-dropdown") as HTMLSelectElement);
// Enable the <option> of the terminal because we know it is available. Ignore null check because we know it is valid.
// @ts-ignore
terminalSelection.options.item(event.payload[0]).disabled = false;
// @ts-ignore 16
terminalSelection.options.item(event.payload[0]).text = terminalSelection.options.item(event.payload[0]).text.slice(0,-16);
$("#terminals-found").text(`${a}/${event.payload[1]}`);
});
listen<string>("default-terminal", (event) => {
console.log(
`Default terminal is ${event.payload}.`
);
$("#default-terminal").text(event.payload);
});

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,46 +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);
}

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,6 @@
import {defineConfig} from "vite";
import preact from "@preact/preset-vite";
import tailwindcss from '@tailwindcss/vite'
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
@@ -31,5 +33,9 @@ export default defineConfig(async () => ({
build: {
outDir: '../dist',
emptyOutDir: true,
}
},
plugins: [
tailwindcss(),
preact(),
]
}));