diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index b937df4..e6c5046 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -5,14 +5,11 @@ on: - cron: '0 2 * * *' # 2 AM UTC daily workflow_dispatch: -env: - ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true - jobs: windows: permissions: contents: write - runs-on: windows-2022 + runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -44,106 +41,11 @@ jobs: with: python-version: '3.11' - - name: Update items database - run: | - curl -fsSL "https://raw.githubusercontent.com/CrunchyRL/RLUPKTools/refs/heads/main/items.json" -o python/items.json - - - name: Patch rl_upk_editor tkinter import - shell: python - run: | - with open('python/rl_upk_editor.py', encoding='utf-8') as f: - src = f.read() - old = ( - 'try:\n' - ' import tkinter as tk\n' - ' from tkinter import filedialog, messagebox, simpledialog, ttk\n' - ' HAS_GUI = True\n' - 'except ImportError:\n' - ' HAS_GUI = False' - ) - new = ( - 'HAS_GUI = False\n' - 'class _TkStub:\n' - ' def __getattr__(self, n): return self\n' - ' def __call__(self, *a, **kw): return self\n' - ' def __bool__(self): return False\n' - ' def __iter__(self): return iter([])\n' - ' def pack(self, *a, **kw): return self\n' - ' def grid(self, *a, **kw): return self\n' - ' def configure(self, *a, **kw): return self\n' - 'tk = filedialog = messagebox = simpledialog = ttk = _TkStub()' - ) - if old in src: - src = src.replace(old, new, 1) - print("Patched tkinter block") - else: - print("WARNING: tkinter block not found, attempting broad patch") - import re - src = re.sub( - r'try:\s+import tkinter.*?(?:except \w+:\s+HAS_GUI = False)', - new, - src, - count=1, - flags=re.DOTALL - ) - with open('python/rl_upk_editor.py', 'w', encoding='utf-8') as f: - f.write(src) - - - name: Patch rl_asset_swapper keys search path - shell: python - run: | - with open('python/rl_asset_swapper.py', encoding='utf-8') as f: - src = f.read() - old = ( - ' candidates = [\n' - ' here / "keys.txt",\n' - ' here / "keys(1).txt",\n' - ' here.parent / "python" / "keys.txt",\n' - ' here.parent / "python" / "keys(1).txt",\n' - ' Path.cwd() / "keys.txt",\n' - ' Path.cwd() / "python" / "keys.txt",\n' - ' args.donor_dir / "keys.txt" if args.donor_dir else None,\n' - ' ]' - ) - new = ( - ' candidates = [\n' - ' Path(sys._MEIPASS) / "keys.txt" if getattr(sys, "_MEIPASS", None) else None,\n' - ' here / "keys.txt",\n' - ' here / "keys(1).txt",\n' - ' here.parent / "python" / "keys.txt",\n' - ' here.parent / "python" / "keys(1).txt",\n' - ' Path.cwd() / "keys.txt",\n' - ' Path.cwd() / "python" / "keys.txt",\n' - ' args.donor_dir / "keys.txt" if args.donor_dir else None,\n' - ' ]' - ) - if old in src: - src = src.replace(old, new, 1) - print("Patched keys search path") - else: - print("WARNING: keys candidates block not found, skipping patch") - with open('python/rl_asset_swapper.py', 'w', encoding='utf-8') as f: - f.write(src) - - name: Build Python sidecar run: | pip install pyinstaller cryptography mkdir -p src-tauri/bin - pyinstaller --onefile --distpath src-tauri/bin --name velocity-engine-x86_64-pc-windows-msvc ` - --add-data "python/rl_upk_editor.py;." ` - --add-data "python/keys.txt;." ` - --add-data "python/items.json;." ` - python/rl_asset_swapper.py - - - name: Build standalone CLI - run: | - pyinstaller --onefile --name velocityrl ` - --add-data "python/rl_asset_swapper.py;." ` - --add-data "python/rl_upk_editor.py;." ` - --add-data "python/items.json;." ` - --add-data "python/keys.txt;." ` - python/cli.py - move dist\velocityrl.exe velocityrl.exe + pyinstaller --onefile --distpath src-tauri/bin --name velocity-engine-x86_64-pc-windows-msvc --add-data "python/rl_upk_editor.py;." --add-data "python/keys.txt;." --add-data "python/items.json;." python/rl_asset_swapper.py - name: Generate engine checksum shell: pwsh @@ -161,7 +63,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Build Tauri app + - name: Build and publish Tauri app uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -196,12 +98,11 @@ jobs: } } - - name: Upload CLI and checksums to release + - name: Upload checksums to release uses: softprops/action-gh-release@v2 with: tag_name: nightly files: | - velocityrl.exe src-tauri/target/release/bundle/msi/*.sha256 src-tauri/target/release/bundle/nsis/*.sha256 python/engine.sha256 @@ -254,96 +155,11 @@ jobs: with: python-version: '3.11' - - name: Update items database - run: | - curl -fsSL "https://raw.githubusercontent.com/CrunchyRL/RLUPKTools/refs/heads/main/items.json" -o python/items.json - - - name: Patch rl_upk_editor tkinter import - shell: python - run: | - with open('python/rl_upk_editor.py', encoding='utf-8') as f: - src = f.read() - old = ( - 'try:\n' - ' import tkinter as tk\n' - ' from tkinter import filedialog, messagebox, simpledialog, ttk\n' - ' HAS_GUI = True\n' - 'except ImportError:\n' - ' HAS_GUI = False' - ) - new = ( - 'HAS_GUI = False\n' - 'class _TkStub:\n' - ' def __getattr__(self, n): return self\n' - ' def __call__(self, *a, **kw): return self\n' - ' def __bool__(self): return False\n' - ' def __iter__(self): return iter([])\n' - ' def pack(self, *a, **kw): return self\n' - ' def grid(self, *a, **kw): return self\n' - ' def configure(self, *a, **kw): return self\n' - 'tk = filedialog = messagebox = simpledialog = ttk = _TkStub()' - ) - if old in src: - src = src.replace(old, new, 1) - print("Patched tkinter block") - else: - print("WARNING: tkinter block not found, attempting broad patch") - import re - src = re.sub( - r'try:\s+import tkinter.*?(?:except \w+:\s+HAS_GUI = False)', - new, - src, - count=1, - flags=re.DOTALL - ) - with open('python/rl_upk_editor.py', 'w', encoding='utf-8') as f: - f.write(src) - - - name: Patch rl_asset_swapper keys search path - shell: python - run: | - with open('python/rl_asset_swapper.py', encoding='utf-8') as f: - src = f.read() - old = ( - ' candidates = [\n' - ' here / "keys.txt",\n' - ' here / "keys(1).txt",\n' - ' here.parent / "python" / "keys.txt",\n' - ' here.parent / "python" / "keys(1).txt",\n' - ' Path.cwd() / "keys.txt",\n' - ' Path.cwd() / "python" / "keys.txt",\n' - ' args.donor_dir / "keys.txt" if args.donor_dir else None,\n' - ' ]' - ) - new = ( - ' candidates = [\n' - ' Path(sys._MEIPASS) / "keys.txt" if getattr(sys, "_MEIPASS", None) else None,\n' - ' here / "keys.txt",\n' - ' here / "keys(1).txt",\n' - ' here.parent / "python" / "keys.txt",\n' - ' here.parent / "python" / "keys(1).txt",\n' - ' Path.cwd() / "keys.txt",\n' - ' Path.cwd() / "python" / "keys.txt",\n' - ' args.donor_dir / "keys.txt" if args.donor_dir else None,\n' - ' ]' - ) - if old in src: - src = src.replace(old, new, 1) - print("Patched keys search path") - else: - print("WARNING: keys candidates block not found, skipping patch") - with open('python/rl_asset_swapper.py', 'w', encoding='utf-8') as f: - f.write(src) - - name: Build Python sidecar run: | pip install pyinstaller cryptography mkdir -p src-tauri/bin - pyinstaller --onefile --distpath src-tauri/bin --name velocity-engine-x86_64-unknown-linux-gnu \ - --add-data "python/rl_upk_editor.py:." \ - --add-data "python/keys.txt:." \ - --add-data "python/items.json:." \ - python/rl_asset_swapper.py + pyinstaller --onefile --distpath src-tauri/bin --name velocity-engine-x86_64-unknown-linux-gnu --add-data "python/rl_upk_editor.py:." --add-data "python/keys.txt:." --add-data "python/items.json:." python/rl_asset_swapper.py - name: Generate engine checksum run: | @@ -354,7 +170,7 @@ jobs: - name: Install frontend dependencies run: npm install - - name: Build Tauri app + - name: Build and publish Tauri app uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2196462..d5c554a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,11 +5,14 @@ on: tags: - 'v*' +env: + ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true + jobs: windows: permissions: contents: write - runs-on: windows-latest + runs-on: windows-2022 steps: - uses: actions/checkout@v4 @@ -22,16 +25,46 @@ jobs: - name: Install Rust stable uses: dtolnay/rust-toolchain@stable + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-cargo-release-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-release- + ${{ runner.os }}-cargo- + - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11' + - name: Update items database + run: | + curl -fsSL "https://raw.githubusercontent.com/CrunchyRL/RLUPKTools/refs/heads/main/items.json" -o python/items.json + - name: Build Python sidecar run: | pip install pyinstaller cryptography mkdir -p src-tauri/bin - pyinstaller --onefile --distpath src-tauri/bin --name velocity-engine-x86_64-pc-windows-msvc python/rl_asset_swapper.py + pyinstaller --onefile --distpath src-tauri/bin --name velocity-engine-x86_64-pc-windows-msvc ` + --add-data "python/rl_upk_editor.py;." ` + --add-data "python/keys.txt;." ` + --add-data "python/items.json;." ` + python/rl_asset_swapper.py + + - name: Build standalone CLI + run: | + pyinstaller --onefile --name velocityrl ` + --add-data "python/rl_asset_swapper.py;." ` + --add-data "python/rl_upk_editor.py;." ` + --add-data "python/items.json;." ` + --add-data "python/keys.txt;." ` + python/cli.py + move dist\velocityrl.exe velocityrl.exe - name: Generate engine checksum shell: pwsh @@ -59,7 +92,7 @@ jobs: **Installation:** Download the `.msi` (Windows Installer) or `.exe` (NSIS setup) below. - **Auto-update:** If you already have VelocityRL installed, the updater will notify you automatically. + **Auto-update:** If you already have VelocityRL installed, the app will notify you of future updates automatically. releaseDraft: false prerelease: ${{ contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }} includeUpdaterJson: true @@ -78,11 +111,12 @@ jobs: } } - - name: Upload checksums to release + - name: Upload CLI and checksums to release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} files: | + velocityrl.exe src-tauri/target/release/bundle/msi/*.sha256 src-tauri/target/release/bundle/nsis/*.sha256 python/engine.sha256 @@ -93,6 +127,7 @@ jobs: permissions: contents: write runs-on: ubuntu-22.04 + needs: windows steps: - uses: actions/checkout@v4 @@ -105,6 +140,18 @@ jobs: - name: Install Rust stable uses: dtolnay/rust-toolchain@stable + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-cargo-release-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-release- + ${{ runner.os }}-cargo- + - name: Install Linux build dependencies run: | sudo apt-get update @@ -120,11 +167,19 @@ jobs: with: python-version: '3.11' + - name: Update items database + run: | + curl -fsSL "https://raw.githubusercontent.com/CrunchyRL/RLUPKTools/refs/heads/main/items.json" -o python/items.json + - name: Build Python sidecar run: | pip install pyinstaller cryptography mkdir -p src-tauri/bin - pyinstaller --onefile --distpath src-tauri/bin --name velocity-engine-x86_64-unknown-linux-gnu python/rl_asset_swapper.py + pyinstaller --onefile --distpath src-tauri/bin --name velocity-engine-x86_64-unknown-linux-gnu \ + --add-data "python/rl_upk_editor.py:." \ + --add-data "python/keys.txt:." \ + --add-data "python/items.json:." \ + python/rl_asset_swapper.py - name: Generate engine checksum run: | @@ -151,7 +206,7 @@ jobs: **Installation (Linux):** Download the `.AppImage` (portable, no install needed) or `.deb` (Debian/Ubuntu) below. - **Auto-update:** If you already have VelocityRL installed, the updater will notify you automatically. + **Auto-update:** If you already have VelocityRL installed, the app will notify you of future updates automatically. releaseDraft: false prerelease: ${{ contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }} includeUpdaterJson: true diff --git a/python/cli.py b/python/cli.py index 93c5d6a..0787fa0 100644 --- a/python/cli.py +++ b/python/cli.py @@ -6,8 +6,26 @@ """ import json import sys +import threading from pathlib import Path +VERSION = "1.2.0" +RELEASES_URL = "https://github.com/bitsfdb/VelocityRL/releases/latest" +_RELEASES_API = "https://api.github.com/repos/bitsfdb/VelocityRL/releases/latest" + + +def _print_update_notice(): + try: + import urllib.request + req = urllib.request.Request(_RELEASES_API, headers={"User-Agent": f"velocityrl-cli/{VERSION}"}) + with urllib.request.urlopen(req, timeout=4) as r: + data = json.loads(r.read()) + latest = data.get("tag_name", "").lstrip("v") + if latest and latest != VERSION: + print(f"\n *** Update available: v{latest} → {RELEASES_URL}\n") + except Exception: + pass + # ── Resource paths ──────────────────────────────────────────────────────────── # PyInstaller bundles files into sys._MEIPASS; when running from source, # look in the same directory as this script. @@ -71,6 +89,11 @@ def detect_game_dir(): # ── Main ────────────────────────────────────────────────────────────────────── def main(): + print(f"VelocityRL CLI v{VERSION}") + print("A desktop application with a full UI is available at: https://velocityrl.tech") + print() + threading.Thread(target=_print_update_notice, daemon=True).start() + cfg = load_config() if '--config' in sys.argv: diff --git a/python/rl_asset_swapper.py b/python/rl_asset_swapper.py index 42a0dcf..f20b21f 100644 --- a/python/rl_asset_swapper.py +++ b/python/rl_asset_swapper.py @@ -153,19 +153,22 @@ def import_rl_upk_editor(): def load_items(path: Path) -> List[Item]: raw = json.loads(path.read_text(encoding="utf-8-sig")) - rows = raw.get("Items", raw if isinstance(raw, list) else []) + # Support both CrunchyRL format {"Items":[...]} and new format {"items":[...]} + rows = raw.get("Items") or raw.get("items") or (raw if isinstance(raw, list) else []) out: List[Item] = [] for row in rows: try: - pkg = str(row.get("AssetPackage", "") or "") - asset_path = str(row.get("AssetPath", "") or "") + # CrunchyRL keys: AssetPackage, AssetPath, ID, Product, Quality, Slot + # New format keys: asset_package, asset_path, id, label/long_label, quality_label, slot + pkg = str(row.get("AssetPackage") or row.get("asset_package") or "") + asset_path = str(row.get("AssetPath") or row.get("asset_path") or "") if not pkg or not asset_path: continue out.append(Item( - id=int(row.get("ID", 0) or 0), - product=str(row.get("Product", "") or ""), - quality=str(row.get("Quality", "") or ""), - slot=str(row.get("Slot", "") or ""), + id=int(row.get("ID") or row.get("id") or 0), + product=str(row.get("Product") or row.get("label") or row.get("long_label") or ""), + quality=str(row.get("Quality") or row.get("quality_label") or ""), + slot=str(row.get("Slot") or row.get("slot") or ""), asset_package=pkg, asset_path=asset_path, )) diff --git a/python/rl_upk_editor.py b/python/rl_upk_editor.py index 95abc71..0b69eb1 100644 --- a/python/rl_upk_editor.py +++ b/python/rl_upk_editor.py @@ -21,8 +21,17 @@ import tkinter as tk from tkinter import filedialog, messagebox, simpledialog, ttk HAS_GUI = True -except ImportError: +except Exception: HAS_GUI = False + class _TkStub: + def __getattr__(self, n): return self + def __call__(self, *a, **kw): return self + def __bool__(self): return False + def __iter__(self): return iter([]) + def pack(self, *a, **kw): return self + def grid(self, *a, **kw): return self + def configure(self, *a, **kw): return self + tk = filedialog = messagebox = simpledialog = ttk = _TkStub() from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -2583,6 +2592,9 @@ def find_keys_path(script_dir: Path, selected_file: Path) -> Optional[Path]: Path.cwd() / "keys.txt", selected_file.parent / "keys.txt", ] + if getattr(sys, "_MEIPASS", None): + candidates.insert(0, Path(sys._MEIPASS) / "keys.txt") + for candidate in candidates: if candidate.exists(): return candidate diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 57ebce1..5cd29ec 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4566,7 +4566,7 @@ checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" [[package]] name = "velocity-rl" -version = "1.1.0" +version = "1.2.0" dependencies = [ "hex", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a15b531..d97592f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "velocity-rl" -version = "1.1.0" +version = "1.2.0" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b13789..c6c35cb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1 +1,450 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fs; +use std::path::PathBuf; +use tauri::Manager; +use sha2::{Sha256, Digest}; +use hex; +// ── Embedded engine ─────────────────────────────────────────────────────────── +// The engine binary is compiled into this library at build time. +// This eliminates all runtime path-finding: the engine is always present. +#[cfg(target_os = "windows")] +const ENGINE_BYTES: &[u8] = include_bytes!("../bin/velocity-engine-x86_64-pc-windows-msvc.exe"); +#[cfg(not(target_os = "windows"))] +const ENGINE_BYTES: &[u8] = include_bytes!("../bin/velocity-engine-x86_64-unknown-linux-gnu"); + +fn engine_hash() -> String { + let mut h = Sha256::new(); + h.update(ENGINE_BYTES); + hex::encode(h.finalize()) +} + +/// Extract the embedded engine to AppLocalData if missing or outdated, return its path. +async fn get_engine_path(app: &tauri::AppHandle) -> Result { + let local_dir = app.path().app_local_data_dir().map_err(|e| e.to_string())?; + fs::create_dir_all(&local_dir).map_err(|e| e.to_string())?; + + #[cfg(target_os = "windows")] + let name = "velocity-engine.exe"; + #[cfg(not(target_os = "windows"))] + let name = "velocity-engine"; + + let path = local_dir.join(name); + let expected = engine_hash(); + + let needs_write = if path.exists() { + match fs::read(&path) { + Ok(bytes) => { + let mut h = Sha256::new(); + h.update(&bytes); + hex::encode(h.finalize()) != expected + } + Err(_) => true, + } + } else { + true + }; + + if needs_write { + fs::write(&path, ENGINE_BYTES).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + .map_err(|e| e.to_string())?; + } + } + + Ok(path) +} + +/// Run the engine with the given args and optional env vars. +async fn run_engine( + path: PathBuf, + args: Vec, + env_vars: Vec<(String, String)>, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let mut cmd = std::process::Command::new(&path); + for arg in &args { cmd.arg(arg); } + for (k, v) in &env_vars { cmd.env(k, v); } + cmd.output() + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()) +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone)] +struct Config { + game_dir: String, +} + +#[derive(Serialize, Deserialize, Clone)] +struct Item { + #[serde(alias = "id-rl-garage", alias = "id", alias = "ID")] + id: i32, + #[serde(alias = "name", alias = "Product")] + product: String, + #[serde(default, alias = "src")] + image_url: String, + #[serde(default, alias = "AssetPackage", alias = "asset_package")] + asset_package: String, + #[serde(default, alias = "Type", alias = "Slot", alias = "slot")] + slot: String, + #[serde(default, alias = "Quality", alias = "quality")] + quality: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +enum ItemsResponse { + Database { + #[serde(alias = "Items", alias = "items")] + items: Vec + }, + List(Vec), +} + +#[derive(Serialize, Deserialize)] +struct BackupFile { + name: String, + path: String, + #[serde(default)] + image_url: String, +} + +static ITEMS_CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); + +const DIAGNOSTIC_URL: Option<&str> = option_env!("DIAGNOSTIC_URL"); +const DIAGNOSTIC_SECRET: Option<&str> = option_env!("DIAGNOSTIC_SECRET"); + +// ── Diagnostics ─────────────────────────────────────────────────────────────── + +async fn send_diagnostic(mut payload: serde_json::Value) { + let (Some(url), Some(secret)) = (DIAGNOSTIC_URL, DIAGNOSTIC_SECRET) else { return }; + if let Some(obj) = payload.as_object_mut() { + obj.entry("version").or_insert_with(|| json!(env!("CARGO_PKG_VERSION"))); + obj.entry("os").or_insert_with(|| json!(std::env::consts::OS)); + obj.entry("arch").or_insert_with(|| json!(std::env::consts::ARCH)); + obj.entry("timestamp").or_insert_with(|| { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + json!(ts) + }); + } + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap_or_default(); + let _ = client + .post(url) + .header("Authorization", format!("Bearer {}", secret)) + .json(&payload) + .send() + .await; +} + +// ── Commands ────────────────────────────────────────────────────────────────── + +#[tauri::command] +async fn get_items(app: tauri::AppHandle) -> Result, String> { + if let Some(cached) = ITEMS_CACHE.get() { + return Ok(cached.clone()); + } + + let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; + let cache_path = config_dir.join("items.json"); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| e.to_string())?; + + let api_url = "https://api.velocityrl.tech/items.json"; + let github_url = "https://raw.githubusercontent.com/CrunchyRL/RLUPKTools/refs/heads/main/items.json"; + + let mut fetched_content = None; + if let Ok(resp) = client.get(api_url).send().await { + if let Ok(text) = resp.text().await { + fetched_content = Some(text); + } + } + + if fetched_content.is_none() { + if let Ok(resp) = client.get(github_url).send().await { + if let Ok(text) = resp.text().await { + fetched_content = Some(text); + } + } + } + + if let Some(content) = fetched_content { + if let Ok(resp) = serde_json::from_str::(&content) { + let items = match resp { + ItemsResponse::Database { items } => items, + ItemsResponse::List(items) => items, + }; + fs::create_dir_all(&config_dir).ok(); + fs::write(&cache_path, &content).ok(); + let _ = ITEMS_CACHE.set(items.clone()); + return Ok(items); + } + } + + if cache_path.exists() { + if let Ok(content) = fs::read_to_string(&cache_path) { + if let Ok(resp) = serde_json::from_str::(&content) { + let items = match resp { + ItemsResponse::Database { items } => items, + ItemsResponse::List(items) => items, + }; + let _ = ITEMS_CACHE.set(items.clone()); + return Ok(items); + } + } + } + + Err("Failed to load items database".into()) +} + +#[tauri::command] +async fn get_config(app: tauri::AppHandle) -> Result { + let config_path = app.path().app_config_dir().map_err(|e| e.to_string())?.join("config.json"); + if config_path.exists() { + let content = fs::read_to_string(config_path).map_err(|e| e.to_string())?; + let config: Config = serde_json::from_str(&content).map_err(|e| e.to_string())?; + Ok(config) + } else { + Ok(Config { game_dir: "".to_string() }) + } +} + +#[tauri::command] +async fn save_config(app: tauri::AppHandle, config: Config) -> Result<(), String> { + let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; + fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?; + let config_path = config_dir.join("config.json"); + let content = serde_json::to_string(&config).map_err(|e| e.to_string())?; + fs::write(config_path, content).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +async fn get_backups(app: tauri::AppHandle) -> Result, String> { + let config = get_config(app.clone()).await?; + if config.game_dir.is_empty() { return Ok(vec![]); } + + let items = get_items(app.clone()).await.unwrap_or_default(); + let mut backups = Vec::new(); + let dir = PathBuf::from(&config.game_dir); + + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "bak") { + let file_name = path.file_name().unwrap().to_string_lossy().to_string(); + let clean_name = file_name.to_lowercase() + .replace(".bak", "") + .replace(".upk", ""); + + let matched_item = items.iter() + .find(|i| { + let db_pkg = i.asset_package.to_lowercase().replace(".upk", ""); + if db_pkg.is_empty() || db_pkg == "none" { return false; } + if db_pkg == clean_name { return true; } + if db_pkg.len() > 4 && (clean_name.contains(&db_pkg) || db_pkg.contains(&clean_name)) { + return true; + } + false + }); + + let display_name = matched_item.map(|i| i.product.clone()).unwrap_or(file_name); + let image_url = matched_item.map(|i| i.image_url.clone()).unwrap_or_default(); + + backups.push(BackupFile { + name: display_name, + path: path.to_string_lossy().to_string(), + image_url, + }); + } + } + } + Ok(backups) +} + +#[tauri::command] +async fn check_integrity(app: tauri::AppHandle) -> Result { + // Engine is embedded at compile time — extract it and confirm it's ready. + get_engine_path(&app).await?; + Ok(true) +} + +#[tauri::command] +async fn cleanup_temp_files(app: tauri::AppHandle) -> Result { + let config = get_config(app.clone()).await?; + if config.game_dir.is_empty() { return Ok("No directory to clean".to_string()); } + let dir = PathBuf::from(&config.game_dir); + let mut count = 0; + let now = std::time::SystemTime::now(); + let one_day = std::time::Duration::from_secs(24 * 3600); + + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + let name = match path.file_name() { + Some(n) => n.to_string_lossy(), + None => continue, + }; + if name.ends_with("_decrypted.upk") || name.ends_with("_decompressed.upk") { + if let Ok(metadata) = fs::metadata(&path) { + if let Ok(modified) = metadata.modified() { + if now.duration_since(modified).unwrap_or(std::time::Duration::ZERO) > one_day { + let _ = fs::remove_file(path); + count += 1; + } + } + } + } + } + } + Ok(format!("Cleaned up {} temp files", count)) +} + +#[tauri::command] +async fn fetch_catalog(app: tauri::AppHandle, token: String, account: String) -> Result { + let engine_path = get_engine_path(&app).await?; + let output = run_engine( + engine_path, + vec!["--fetch".into(), "--account".into(), account], + vec![("EPIC_TOKEN".into(), token)], + ).await?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(format!("Fetch error: {}", String::from_utf8_lossy(&output.stderr))) + } +} + +#[tauri::command] +async fn apply_swap(app: tauri::AppHandle, owned_id: String, wanted_id: String) -> Result { + let config = get_config(app.clone()).await?; + if config.game_dir.is_empty() { return Err("Game directory not set".to_string()); } + + let engine_path = get_engine_path(&app).await?; + let items_path = app.path().app_config_dir().map_err(|e| e.to_string())?.join("items.json"); + + let output = run_engine( + engine_path, + vec![ + "--no-gui".into(), + "--items".into(), items_path.to_string_lossy().to_string(), + "--target".into(), owned_id.clone(), + "--donor".into(), wanted_id.clone(), + "--overwrite".into(), + "--donor-dir".into(), config.game_dir.clone(), + "--output-dir".into(), config.game_dir.clone(), + ], + vec![], + ).await?; + + if output.status.success() { + Ok("Swap completed successfully".to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let game_dir_name = PathBuf::from(&config.game_dir) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let exit_code = output.status.code().unwrap_or(-1); + send_diagnostic(json!({ + "event": "swap_fail", + "context": "apply_swap", + "message": format!("Engine exited with code {}", exit_code), + "stderr": stderr, + "stdout": stdout, + "owned_id": owned_id, + "wanted_id": wanted_id, + "game_dir": game_dir_name, + "exit_code": exit_code, + })).await; + Err(format!("Engine error: {}", stderr)) + } +} + +#[tauri::command] +async fn restore_single_backup(app: tauri::AppHandle, path: String) -> Result<(), String> { + let config = get_config(app).await?; + if config.game_dir.is_empty() { return Err("Game directory not configured".into()); } + let allowed_dir = PathBuf::from(&config.game_dir).canonicalize().map_err(|e| e.to_string())?; + + let bak_path = PathBuf::from(&path).canonicalize().map_err(|_| "Invalid backup path".to_string())?; + if !bak_path.starts_with(&allowed_dir) { + return Err("Access denied: path is outside the game directory".into()); + } + if bak_path.extension().map_or(true, |ext| ext != "bak") { + return Err("Access denied: only .bak files can be restored".into()); + } + + let original_path = bak_path.with_extension(""); + fs::copy(&bak_path, &original_path).map_err(|e| e.to_string())?; + fs::remove_file(&bak_path).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +async fn restore_backups(app: tauri::AppHandle) -> Result { + let config = get_config(app.clone()).await?; + if config.game_dir.is_empty() { return Err("Game directory not set".to_string()); } + let dir = PathBuf::from(&config.game_dir); + let mut count = 0; + for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "bak") { + let original = path.with_extension(""); + fs::copy(&path, &original).map_err(|e| e.to_string())?; + fs::remove_file(&path).map_err(|e| e.to_string())?; + count += 1; + } + } + Ok(format!("Restored {} backups", count)) +} + +#[tauri::command] +async fn report_diagnostic(payload: serde_json::Value) -> Result<(), String> { + send_diagnostic(payload).await; + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_log::Builder::default().build()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_process::init()) + .invoke_handler(tauri::generate_handler![ + get_items, + get_config, + save_config, + get_backups, + apply_swap, + restore_backups, + restore_single_backup, + check_integrity, + cleanup_temp_files, + fetch_catalog, + report_diagnostic + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8b13789..abcd8e0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1 +1,48 @@ - +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "VelocityRL", + "version": "1.2.0", + "identifier": "com.velocityrl.app", + "build": { + "frontendDist": "../ui", + "beforeBuildCommand": "echo build" + }, + "app": { + "windows": [ + { + "title": "VelocityRL", + "width": 1000, + "height": 750, + "resizable": true, + "fullscreen": false + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https://api.velocityrl.tech data: blob:; connect-src 'self' https://api.velocityrl.tech https://velocityrl.tech https://api.github.com" + }, + "withGlobalTauri": true + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "resources": [ + "../python/items.json", + "../python/keys.txt" + ], + "android": { + "debugApplicationIdSuffix": ".debug" + } + }, + "plugins": { + "shell": { + "open": "^https://(discord\\.gg|github\\.com|velocityrl\\.tech)" + } + } +} \ No newline at end of file diff --git a/ui/index.html b/ui/index.html index 225d071..5176ac3 100644 --- a/ui/index.html +++ b/ui/index.html @@ -130,17 +130,12 @@

System Settings

- +
-
- -

Fetch the latest item data from Psynet. Requires Epic Auth Token.

- -
diff --git a/ui/main.js b/ui/main.js index c42650b..47d4d75 100644 --- a/ui/main.js +++ b/ui/main.js @@ -105,7 +105,7 @@ async function init() { const container = document.getElementById('owned-selected'); container.innerHTML = ` -
+
${pImg ? `` : ''} @@ -113,6 +113,7 @@ async function init() { ${escHtml(pQuality)}

${escHtml(pSlot)}

`; + container.querySelector('.clear-item-btn').addEventListener('click', clearOwned); container.classList.add('selected'); ownedSearch.value = pName; validate(); @@ -127,7 +128,7 @@ async function init() { const container = document.getElementById('wanted-selected'); container.innerHTML = ` -
+
${pImg ? `` : ''} @@ -135,6 +136,7 @@ async function init() { ${escHtml(pQuality)}

${escHtml(pSlot)}

`; + container.querySelector('.clear-item-btn').addEventListener('click', clearWanted); container.classList.add('selected'); wantedSearch.value = pName; validate(); @@ -162,13 +164,11 @@ async function init() { applyBtn.onclick = handleApply; document.getElementById('restore-btn').onclick = handleRestore; - document.getElementById('website-btn').onclick = () => showToast('⚠️ (WIP)', 'warning'); + document.getElementById('website-btn').onclick = () => window.__TAURI__.core.invoke('plugin:shell|open', { path: 'https://velocityrl.tech' }); document.getElementById('settings-btn').onclick = () => document.getElementById('settings-modal').classList.add('active'); document.getElementById('cancel-settings').onclick = () => document.getElementById('settings-modal').classList.remove('active'); document.getElementById('close-settings').onclick = handleSaveSettings; document.getElementById('browse-dir').onclick = handleBrowse; - document.getElementById('fetch-items-btn').onclick = handleFetchItems; - document.getElementById('settings-modal').onclick = (e) => { if (e.target === document.getElementById('settings-modal')) { document.getElementById('settings-modal').classList.remove('active'); @@ -180,24 +180,23 @@ async function init() { await invoke('check_integrity').catch(e => { throw new Error(`Security Violation: ${e}`); }); - updateStatus('Initializing Engine...', false); - items = await invoke('get_items').catch(async (e) => { - console.warn('Local item database unavailable, fetching from API...', e); - updateStatus('Fetching from API...', false); - return await fetchItemsFromAPI(); + updateStatus('Please Wait...', false); + items = await fetchItemsFromAPI().catch(async (e) => { + console.warn('API unavailable, loading from local cache...', e); + return await invoke('get_items'); }); const config = await invoke('get_config').catch(e => { console.warn('Config load failed:', e); return { game_dir: '' }; }); - if (config) { - if (config.game_dir) document.getElementById('game-dir').value = config.game_dir; + if (config && config.game_dir) { + document.getElementById('game-dir').value = config.game_dir; } else { updateStatus('Setup Required', true); setTimeout(() => { document.getElementById('settings-modal').classList.add('active'); - handleBrowse(); }, 1000); } updateStatus('bitsfdb', false); invoke('cleanup_temp_files').catch(e => console.warn('Cleanup failed:', e)); + checkForUpdates(); } catch (err) { updateStatus('Init Failure', true); alert(`VelocityRL Initialization Failed:\n${err.message || err}`); @@ -259,10 +258,26 @@ async function refreshBackups() { backups.forEach(file => { const div = document.createElement('div'); div.className = 'backup-item'; + let pImg = file.image_url || ''; + if (!pImg && items && items.length > 0) { + const fileName = file.path.split(/[/\\]/).pop(); + const cleanName = fileName.toLowerCase().replace('.bak', '').replace('.upk', ''); + const matched = items.find(i => { + const dbPkg = (i.asset_package || '').toLowerCase().replace('.upk', ''); + if (!dbPkg || dbPkg === 'none') return false; + return dbPkg === cleanName || (dbPkg.length > 4 && (cleanName.includes(dbPkg) || dbPkg.includes(cleanName))); + }); + if (matched && matched.image_url) { + pImg = matched.image_url; + } + } div.innerHTML = ` -
-
${escHtml(file.name)}
-
Modified Package
+
+ ${pImg ? `` : '
'} +
+
${escHtml(file.name)}
+
Modified Product
+
@@ -405,7 +420,7 @@ function validate() { async function handleApply() { try { - updateStatus('Initializing Engine...', false); + updateStatus('Please Wait...', false); showProgress(true, 15); applyBtn.disabled = true; let p = 15; @@ -455,7 +470,7 @@ async function handleSaveSettings() { refreshBackups(); if (dir) showToast('Success!', 'success'); } catch (err) { - showToast('Failed! please report this to the maintainer bitsfdb on the discord support server', 'error'); + showToast('Failed to save configuration!', 'error'); console.error(err); } } @@ -469,41 +484,27 @@ async function handleBrowse() { }); if (selected) { document.getElementById('game-dir').value = selected; - // If first time, auto-save - const config = await invoke('get_config').catch(() => ({ game_dir: '' })); - if (!config.game_dir) { - handleSaveSettings(); - } } } catch (err) { - showToast('Failed! please report this to the maintainer bitsfdb on the discord support server', 'error'); + showToast('Browse failed!', 'error'); console.error(err); } } -async function handleFetchItems() { +async function checkForUpdates() { try { - const token = prompt("Enter Epic Auth Token / Exchange Token:"); - if (!token) return; - const account = prompt("Enter Epic Account ID (optional):") || "Unknown"; - - updateStatus('Updating Database...', false); - showProgress(true, 10); - - await invoke('fetch_catalog', { token, account }); - - showProgress(true, 100); - showToast('Database Updated!', 'success'); - updateStatus('bitsfdb', false); - setTimeout(() => showProgress(false), 2000); - - // Reload items - items = await invoke('get_items'); - } catch (err) { - showToast('Update Failed: ' + err, 'error'); - updateStatus('Update Error', true); - showProgress(false); - } + const current = await window.__TAURI__.app.getVersion(); + const res = await fetch('https://api.github.com/repos/bitsfdb/VelocityRL/releases/latest'); + if (!res.ok) return; + const data = await res.json(); + const latest = (data.tag_name || '').replace(/^v/, ''); + if (!latest || latest === current) return; + const url = escHtml(data.html_url || 'https://github.com/bitsfdb/VelocityRL/releases/latest'); + showToast( + `Update available: v${escHtml(latest)} — Download`, + 'warning' + ); + } catch (_) {} } window.addEventListener('DOMContentLoaded', () => init()); diff --git a/ui/style.css b/ui/style.css index 4b4d8b9..7bf563b 100644 --- a/ui/style.css +++ b/ui/style.css @@ -189,6 +189,7 @@ input { border-radius: 8px; font-size: 14px; outline: none; + user-select: text; } input:focus { @@ -267,6 +268,7 @@ input:focus { cursor: pointer; transition: all 0.2s; border: 1px solid var(--border-color); + z-index: 1; } .item-selected-view.selected .clear-item-btn {