From 23785ad4ac88b88d89a42e52f4f0cf0b2642962b Mon Sep 17 00:00:00 2001 From: Vinter Date: Fri, 17 Apr 2026 09:21:57 +0200 Subject: [PATCH 1/6] added tauri desktop app + moved web stuff into web/ --- .github/workflows/app.yml | 80 +++ .github/workflows/web.yml | 24 + .gitignore | 16 + app/scripts/prepare-sidecar.sh | 65 +++ app/src-tauri/Cargo.toml | 32 ++ app/src-tauri/build.rs | 3 + app/src-tauri/capabilities/default.json | 9 + app/src-tauri/icons/.gitkeep | 2 + app/src-tauri/icons/icon.png | Bin 0 -> 21059 bytes app/src-tauri/src/main.rs | 310 ++++++++++++ app/src-tauri/src/parser.rs | 199 ++++++++ app/src-tauri/src/wcl.rs | 409 ++++++++++++++++ app/src-tauri/tauri.conf.json | 43 ++ app/src/index.html | 587 +++++++++++++++++++++++ docker-compose.local.yml | 2 +- docker-compose.yml | 4 +- Dockerfile => web/Dockerfile | 2 +- Dockerfile.local => web/Dockerfile.local | 2 +- webapp.py => web/webapp.py | 4 +- 19 files changed, 1788 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/app.yml create mode 100644 .github/workflows/web.yml create mode 100644 app/scripts/prepare-sidecar.sh create mode 100644 app/src-tauri/Cargo.toml create mode 100644 app/src-tauri/build.rs create mode 100644 app/src-tauri/capabilities/default.json create mode 100644 app/src-tauri/icons/.gitkeep create mode 100644 app/src-tauri/icons/icon.png create mode 100644 app/src-tauri/src/main.rs create mode 100644 app/src-tauri/src/parser.rs create mode 100644 app/src-tauri/src/wcl.rs create mode 100644 app/src-tauri/tauri.conf.json create mode 100644 app/src/index.html rename Dockerfile => web/Dockerfile (91%) rename Dockerfile.local => web/Dockerfile.local (91%) rename webapp.py => web/webapp.py (99%) diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml new file mode 100644 index 0000000..09c0312 --- /dev/null +++ b/.github/workflows/app.yml @@ -0,0 +1,80 @@ +name: app + +on: + push: + branches: [main, tauri] + tags: ["v*"] + paths: + - app/** + - parser-harness.js + - .github/workflows/app.yml + pull_request: + paths: + - app/** + - parser-harness.js + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: windows-latest + target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install Linux system deps + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libssl-dev \ + pkg-config + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + app/src-tauri/target + key: ${{ matrix.os }}-cargo-${{ hashFiles('app/src-tauri/Cargo.lock', 'app/src-tauri/Cargo.toml') }} + + - name: Stage Node sidecar + parser-harness + run: bash app/scripts/prepare-sidecar.sh ${{ matrix.target }} + + - name: Install tauri-cli + run: cargo install --locked tauri-cli --version "^2" + + - name: Generate icons from source PNG + working-directory: app + run: cargo tauri icon src-tauri/icons/icon.png + + - name: Build Tauri app + working-directory: app + run: cargo tauri build --target ${{ matrix.target }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: combatlog-${{ matrix.target }} + path: | + app/src-tauri/target/${{ matrix.target }}/release/bundle/**/* + if-no-files-found: error diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 0000000..499556f --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,24 @@ +name: web + +on: + push: + branches: [main] + paths: + - web/** + - parser-harness.js + - docker-compose*.yml + - .github/workflows/web.yml + pull_request: + paths: + - web/** + - parser-harness.js + +jobs: + docker-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build production image + run: docker build -f web/Dockerfile -t combatlog-web:ci . + - name: Build local image + run: docker build -f web/Dockerfile.local -t combatlog-web-local:ci . diff --git a/.gitignore b/.gitignore index f5a4bd8..1c8dc76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,19 @@ .vscode __pycache__/ .claude/ + +# Rust build output +app/src-tauri/target/ +app/src-tauri/Cargo.lock + +# Node sidecar and staged parser harness are fetched at build time +app/src-tauri/binaries/ +app/src-tauri/resources/parser-harness.js + +# Icons generated by `cargo tauri icon`. Keep only the source icon.png. +app/src-tauri/icons/* +!app/src-tauri/icons/icon.png +!app/src-tauri/icons/.gitkeep + +# Tauri generated schemas and ACL manifests +app/src-tauri/gen/ diff --git a/app/scripts/prepare-sidecar.sh b/app/scripts/prepare-sidecar.sh new file mode 100644 index 0000000..36be1fe --- /dev/null +++ b/app/scripts/prepare-sidecar.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# This downloads the Node.js binary for the current host target and copies +# it into app/src-tauri/binaries/ with the target-triple suffix Tauri expects. +# Also copies the root parser-harness.js into src-tauri/resources/. + +# This is necessary because the whole thing ig silly and we're running +# an obfuscated js, and the rust node runtimes can't JIT compile it properly +# so this is the only way to avoid 10 min uploads at the cost of 800MB of sidecar. + +set -euo pipefail + +cd "$(dirname "$0")/.." +ROOT="$(cd .. && pwd)" +BINARIES_DIR="src-tauri/binaries" +RESOURCES_DIR="src-tauri/resources" +NODE_VERSION="${NODE_VERSION:-v20.18.1}" + +TARGET="${1:-}" +if [[ -z "$TARGET" ]]; then + TARGET="$(rustc -vV | awk -F': ' '/^host/ {print $2}')" +fi + +case "$TARGET" in + x86_64-unknown-linux-gnu) + NODE_ARCHIVE="node-${NODE_VERSION}-linux-x64.tar.xz" + NODE_BIN_PATH="node-${NODE_VERSION}-linux-x64/bin/node" + EXT="" + ;; + x86_64-pc-windows-msvc|x86_64-pc-windows-gnu) + NODE_ARCHIVE="node-${NODE_VERSION}-win-x64.zip" + NODE_BIN_PATH="node-${NODE_VERSION}-win-x64/node.exe" + EXT=".exe" + ;; + aarch64-unknown-linux-gnu) + NODE_ARCHIVE="node-${NODE_VERSION}-linux-arm64.tar.xz" + NODE_BIN_PATH="node-${NODE_VERSION}-linux-arm64/bin/node" + EXT="" + ;; + *) + echo "unsupported target: $TARGET" >&2 + exit 2 + ;; +esac + +mkdir -p "$BINARIES_DIR" "$RESOURCES_DIR" +cp "$ROOT/parser-harness.js" "$RESOURCES_DIR/parser-harness.js" + +DEST="$BINARIES_DIR/node-${TARGET}${EXT}" +if [[ -f "$DEST" ]]; then + echo "sidecar already present: $DEST" + exit 0 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +url="https://nodejs.org/dist/${NODE_VERSION}/${NODE_ARCHIVE}" +echo "downloading $url" +curl -fsSL -o "$tmp/$NODE_ARCHIVE" "$url" +if [[ "$NODE_ARCHIVE" == *.zip ]]; then + unzip -q "$tmp/$NODE_ARCHIVE" -d "$tmp" +else + tar -xJf "$tmp/$NODE_ARCHIVE" -C "$tmp" +fi +install -m 0755 "$tmp/$NODE_BIN_PATH" "$DEST" +echo "staged sidecar: $DEST" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml new file mode 100644 index 0000000..63d9dad --- /dev/null +++ b/app/src-tauri/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "combatlog" +version = "0.1.0" +edition = "2021" +description = "combatlog.dev — local WarcraftLogs uploader" +default-run = "combatlog" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-shell = "2" +tauri-plugin-dialog = "2" +tauri-plugin-opener = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "process", "sync", "fs", "time"] } +rquest = { version = "2", features = ["json", "cookies"] } +regex = "1" +anyhow = "1" +rand = "0.8" +zip = { version = "2", default-features = false, features = ["deflate"] } + +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] + +[profile.release] +opt-level = 3 +lto = "thin" +strip = true diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs new file mode 100644 index 0000000..261851f --- /dev/null +++ b/app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json new file mode 100644 index 0000000..c971cff --- /dev/null +++ b/app/src-tauri/capabilities/default.json @@ -0,0 +1,9 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capabilities granted to the main window", + "windows": ["main"], + "permissions": [ + "core:default" + ] +} diff --git a/app/src-tauri/icons/.gitkeep b/app/src-tauri/icons/.gitkeep new file mode 100644 index 0000000..b007dd4 --- /dev/null +++ b/app/src-tauri/icons/.gitkeep @@ -0,0 +1,2 @@ +Icons are generated from a source PNG via `cargo tauri icon `. +Run that command before `cargo tauri build` or let the CI workflow do it. diff --git a/app/src-tauri/icons/icon.png b/app/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cb1cfe93682a7f0d1a0414797786b46e36dd3ab3 GIT binary patch literal 21059 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 B1Ze;O literal 0 HcmV?d00001 diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs new file mode 100644 index 0000000..c4d135f --- /dev/null +++ b/app/src-tauri/src/main.rs @@ -0,0 +1,310 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod parser; +mod wcl; + +use std::path::PathBuf; + +use anyhow::{anyhow, Context as _, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_opener::OpenerExt; + +const BATCH_SIZE: usize = 100_000; +const UPLOAD_UI_RESERVED_PCT: u32 = 10; // the first 10% are reserved for client-side read + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct UploadArgs { + log_path: String, + email: String, + password: String, + region: i32, + visibility: i32, + guild_id: Option, +} + +#[derive(Serialize)] +struct VersionInfo { + app: &'static str, +} + +#[derive(Serialize)] +struct FileInfo { + path: String, + name: String, + size: u64, +} + +#[tauri::command] +fn app_version() -> VersionInfo { + VersionInfo { + app: env!("CARGO_PKG_VERSION"), + } +} + +/// native file picker. +#[tauri::command] +async fn pick_log_file(app: AppHandle) -> Option { + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter("Combat log", &["txt"]) + .pick_file(move |path| { + let _ = tx.send(path); + }); + let path = rx.await.ok().flatten()?; + let pb = path.as_path()?.to_path_buf(); + Some(describe_file(&pb)) +} + +/// file info for a user-dropped path +#[tauri::command] +fn file_info(path: String) -> Result { + let pb = std::path::PathBuf::from(&path); + if !pb.is_file() { + return Err(format!("not a file: {path}")); + } + Ok(describe_file(&pb)) +} + +/// external URL handler +#[tauri::command] +fn open_url(app: AppHandle, url: String) -> Result<(), String> { + app.opener() + .open_url(url, None::) + .map_err(|e| format!("failed to open URL: {e}")) +} + +fn describe_file(path: &std::path::Path) -> FileInfo { + let name = path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("") + .to_string(); + let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + FileInfo { + path: path.to_string_lossy().to_string(), + name, + size, + } +} + +/// start upload +#[tauri::command] +async fn start_upload(app: AppHandle, args: UploadArgs) -> Result<(), String> { + tokio::spawn(async move { + if let Err(e) = run_upload(&app, args).await { + let _ = app.emit( + "upload:error", + json!({"message": format!("{e:#}")}), + ); + } + }); + Ok(()) +} + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) + .invoke_handler(tauri::generate_handler![ + app_version, + pick_log_file, + file_info, + open_url, + start_upload + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +fn emit_progress(app: &AppHandle, step: &str, message: impl Into, pct: u32) { + let _ = app.emit( + "upload:progress", + json!({ + "step": step, + "message": message.into(), + "pct": pct, + }), + ); +} + +/// this is (hopefully) a mirror of `upload_worker` in `web/webapp.py`. +/// (TODO: I should really deduplicate this) +async fn run_upload(app: &AppHandle, args: UploadArgs) -> Result<()> { + let log_path = PathBuf::from(&args.log_path); + let filename = log_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("log.txt") + .to_string(); + + emit_progress(app, "read", "Reading log file...", 1); + let raw = tokio::fs::read_to_string(&log_path) + .await + .with_context(|| format!("reading {}", log_path.display()))?; + let all_lines: Vec = raw + .lines() + .map(|s| s.to_string()) + .collect(); + let total = all_lines.len(); + emit_progress( + app, + "read", + format!("Read {} lines", format_with_commas(total)), + 2, + ); + + emit_progress(app, "session", "Initializing session...", 3); + let session = wcl::WclSession::new().await?; + + emit_progress(app, "login", "Logging in...", 4); + let login = session.login(&args.email, &args.password).await?; + let user_name = login + .user + .as_ref() + .and_then(|u| u.user_name.as_deref()) + .unwrap_or("?") + .to_string(); + emit_progress(app, "login", format!("Logged in as {user_name}"), 5); + + emit_progress(app, "fetch-parser", "Fetching latest parser...", 6); + let bundle = session.fetch_parser_code().await?; + let parser_version = bundle.parser_version; + emit_progress( + app, + "fetch-parser", + format!("Parser v{parser_version} loaded"), + 7, + ); + + let harness = parser::harness_path(app)?; + emit_progress(app, "parser", "Starting parser...", 8); + let parser = parser::Parser::spawn(app, &harness, &bundle.gamedata_code, &bundle.parser_code) + .await?; + parser.clear_state().await?; + if let Some(date) = wcl::parse_start_date(&filename) { + parser.set_start_date(&date).await?; + } + emit_progress(app, "parser", "Parser ready", 9); + + let mut segment_id: i64 = 1; + let mut report_code: Option = None; + let mut last_master_ids: Option<(i64, i64, i64, i64)> = None; + let total_batches = (total + BATCH_SIZE - 1) / BATCH_SIZE; + + for (batch_idx, chunk) in all_lines.chunks(BATCH_SIZE).enumerate() { + let batch_num = batch_idx + 1; + let pct = UPLOAD_UI_RESERVED_PCT + + (80 * batch_num as u32 / total_batches.max(1) as u32); + + parser.parse_lines(&chunk.to_vec(), args.region).await?; + let fd = parser.collect_fights().await?; + let fights = fd.get("fights").and_then(|v| v.as_array()); + if fights.map(|a| a.is_empty()).unwrap_or(true) { + emit_progress( + app, + "parse", + format!("Batch {batch_num}/{total_batches} — no fights yet"), + pct, + ); + continue; + } + + if report_code.is_none() { + let start_time = fd.get("startTime").and_then(|v| v.as_i64()).unwrap_or(0); + let end_time = fd.get("endTime").and_then(|v| v.as_i64()).unwrap_or(0); + let code = session + .create_report( + &filename, + start_time, + end_time, + args.region, + args.visibility, + args.guild_id, + parser_version, + ) + .await?; + emit_progress( + app, + "report", + format!("Report created: {code}"), + pct, + ); + report_code = Some(code); + } + let code = report_code.as_deref().unwrap(); + + let mi = parser.collect_master_info().await?; + let master_ids = ( + mi.get("lastAssignedActorID").and_then(|v| v.as_i64()).unwrap_or(0), + mi.get("lastAssignedAbilityID").and_then(|v| v.as_i64()).unwrap_or(0), + mi.get("lastAssignedTupleID").and_then(|v| v.as_i64()).unwrap_or(0), + mi.get("lastAssignedPetID").and_then(|v| v.as_i64()).unwrap_or(0), + ); + if Some(master_ids) != last_master_ids { + let log_version = fd.get("logVersion").and_then(|v| v.as_i64()).unwrap_or(0); + let game_version = fd.get("gameVersion").and_then(|v| v.as_i64()).unwrap_or(0); + let master = wcl::build_master_string(&mi, log_version, game_version); + let zipped = wcl::make_zip(&master)?; + session.set_master_table(code, segment_id, zipped).await?; + last_master_ids = Some(master_ids); + } + + let evts: i64 = fights + .map(|a| { + a.iter() + .filter_map(|f| f.get("eventCount").and_then(|n| n.as_i64())) + .sum() + }) + .unwrap_or(0); + let start_time = fd.get("startTime").and_then(|v| v.as_i64()).unwrap_or(0); + let end_time = fd.get("endTime").and_then(|v| v.as_i64()).unwrap_or(0); + let mythic = fd.get("mythic").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + + let fights_str = wcl::build_fights_string(&fd); + let zipped = wcl::make_zip(&fights_str)?; + segment_id = session + .add_segment(code, segment_id, start_time, end_time, mythic, zipped) + .await?; + parser.clear_fights().await?; + emit_progress( + app, + "upload", + format!( + "Segment {batch_num}/{total_batches} — {} events", + format_with_commas(evts as usize) + ), + pct, + ); + } + + parser.close().await; + + match report_code { + Some(code) => { + session.terminate_report(&code).await?; + let url = format!("https://www.warcraftlogs.com/reports/{code}"); + let _ = app.emit("upload:done", json!({"url": url, "code": code})); + Ok(()) + } + None => Err(anyhow!("No fights found in log file.")), + } +} + +fn format_with_commas(n: usize) -> String { + let s = n.to_string(); + let bytes = s.as_bytes(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, &b) in bytes.iter().enumerate() { + if i > 0 && (bytes.len() - i) % 3 == 0 { + out.push(','); + } + out.push(b as char); + } + out +} diff --git a/app/src-tauri/src/parser.rs b/app/src-tauri/src/parser.rs new file mode 100644 index 0000000..d7537b3 --- /dev/null +++ b/app/src-tauri/src/parser.rs @@ -0,0 +1,199 @@ +//! Node sidecar driver. Spawns the bundled Node binary with `parser-harness.js` +//! and talks to it over stdin/stdout protocol defined in `parser-harness.js`. +use std::path::Path; + +use anyhow::{anyhow, bail, Context as _, Result}; +use serde_json::{json, Value}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; +use tauri_plugin_shell::ShellExt; +use tokio::sync::{mpsc, Mutex}; + +const READY_TIMEOUT_MS: u64 = 15_000; +const RESPONSE_TIMEOUT_MS: u64 = 60_000; + +struct Inner { + child: CommandChild, + rx: mpsc::Receiver, +} + +pub struct Parser { + inner: Mutex, +} + +impl Parser { + /// `harness_path` is an absolute path to `parser-harness.js`. + pub async fn spawn( + app: &AppHandle, + harness_path: &Path, + gamedata_code: &str, + parser_code: &str, + ) -> Result { + let harness = harness_path + .to_str() + .ok_or_else(|| anyhow!("non-utf8 harness path"))?; + + let (mut raw_rx, mut child) = app + .shell() + .sidecar("node")? + .args([harness]) + .spawn() + .context("failed to spawn Node sidecar")?; + + let (line_tx, line_rx) = mpsc::channel::(64); + tokio::spawn(async move { + let mut buf: Vec = Vec::with_capacity(4096); + while let Some(event) = raw_rx.recv().await { + match event { + CommandEvent::Stdout(bytes) => { + buf.extend_from_slice(&bytes); + while let Some(pos) = buf.iter().position(|&b| b == b'\n') { + let mut line: Vec = buf.drain(..=pos).collect(); + line.pop(); // trailing \n + if line.last() == Some(&b'\r') { + line.pop(); + } + let s = String::from_utf8_lossy(&line).to_string(); + if line_tx.send(s).await.is_err() { + return; + } + } + } + CommandEvent::Stderr(bytes) => { + eprintln!("[node] {}", String::from_utf8_lossy(&bytes).trim_end()); + } + CommandEvent::Error(e) => { + eprintln!("[node] error: {e}"); + } + CommandEvent::Terminated(t) => { + eprintln!("[node] terminated: code={:?}", t.code); + return; + } + _ => {} + } + } + }); + + let bootstrap = serde_json::to_string(&json!({ + "gamedataCode": gamedata_code, + "parserCode": parser_code, + }))?; + child + .write(format!("{bootstrap}\n").as_bytes()) + .context("failed to write bootstrap to sidecar stdin")?; + + let inner = Inner { + child, + rx: line_rx, + }; + let parser = Self { + inner: Mutex::new(inner), + }; + + let ready = parser + .recv_with_timeout(READY_TIMEOUT_MS) + .await + .context("parser did not emit ready response")?; + if !ready.get("ready").and_then(|v| v.as_bool()).unwrap_or(false) { + bail!("parser bootstrap failed: {ready}"); + } + Ok(parser) + } + + async fn recv_with_timeout(&self, timeout_ms: u64) -> Result { + let mut inner = self.inner.lock().await; + let line = tokio::time::timeout( + std::time::Duration::from_millis(timeout_ms), + inner.rx.recv(), + ) + .await + .context("parser response timeout")? + .context("parser stdout closed")?; + Ok(serde_json::from_str(&line).with_context(|| format!("parser emitted non-JSON: {line}"))?) + } + + async fn exchange(&self, payload: Value) -> Result { + let line = serde_json::to_string(&payload)?; + { + let mut inner = self.inner.lock().await; + inner + .child + .write(format!("{line}\n").as_bytes()) + .context("failed to write command to sidecar stdin")?; + } + self.recv_with_timeout(RESPONSE_TIMEOUT_MS).await + } + + pub async fn clear_state(&self) -> Result<()> { + let r = self.exchange(json!({"action": "clear-state"})).await?; + check_ok(&r) + } + + pub async fn set_start_date(&self, date: &str) -> Result<()> { + let r = self + .exchange(json!({"action": "set-start-date", "startDate": date})) + .await?; + check_ok(&r) + } + + pub async fn parse_lines(&self, lines: &[String], region: i32) -> Result<()> { + let r = self + .exchange(json!({ + "action": "parse-lines", + "lines": lines, + "selectedRegion": region, + })) + .await?; + check_ok(&r) + } + + pub async fn collect_fights(&self) -> Result { + let r = self + .exchange(json!({ + "action": "collect-fights", + "pushFightIfNeeded": true, + "scanningOnly": false, + })) + .await?; + check_ok(&r)?; + Ok(r) + } + + pub async fn collect_master_info(&self) -> Result { + let r = self.exchange(json!({"action": "collect-master-info"})).await?; + check_ok(&r)?; + Ok(r) + } + + pub async fn clear_fights(&self) -> Result<()> { + let r = self.exchange(json!({"action": "clear-fights"})).await?; + check_ok(&r) + } + + pub async fn close(self) { + let inner = self.inner.into_inner(); + let _ = inner.child.kill(); + } +} + +fn check_ok(v: &Value) -> Result<()> { + if v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) { + Ok(()) + } else { + Err(anyhow!( + "parser error: {}", + v.get("error") + .and_then(|e| e.as_str()) + .unwrap_or("unknown error") + )) + } +} + +/// resolved from `tauri.conf.json` bundle.resources +pub fn harness_path(app: &AppHandle) -> Result { + let resource_dir = app + .path() + .resource_dir() + .context("resource dir unavailable")?; + Ok(resource_dir.join("resources").join("parser-harness.js")) +} diff --git a/app/src-tauri/src/wcl.rs b/app/src-tauri/src/wcl.rs new file mode 100644 index 0000000..988553b --- /dev/null +++ b/app/src-tauri/src/wcl.rs @@ -0,0 +1,409 @@ +//! HTTP client + WarcraftLogs session + + +use std::io::{Cursor, Write}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, bail, Context as _, Result}; +use rand::Rng; +use regex::Regex; +use rquest::{Client, Impersonate}; +use serde::Deserialize; +use serde_json::{json, Value}; + +const BASE_URL: &str = "https://www.warcraftlogs.com"; +// This will be fetched dynamically +const FALLBACK_CLIENT_VERSION: &str = "9.0.1"; +// These, well, we hope they dont chage/matter +const CHROME_VERSION: &str = "134.0.6998.205"; +const ELECTRON_VERSION: &str = "37.7.0"; +const MAX_RETRIES: u32 = 3; +const RETRY_BASE_DELAY_MS: u64 = 1000; + +#[derive(Debug, Clone, Deserialize)] +pub struct LoginUser { + #[serde(rename = "userName")] + pub user_name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LoginResponse { + pub user: Option, +} + +pub struct ParserBundle { + pub gamedata_code: String, + pub parser_code: String, + pub parser_version: i32, +} + +pub struct WclSession { + client: Client, + client_version: String, +} + +impl WclSession { + pub async fn new() -> Result { + let client_version = fetch_latest_client_version() + .await + .unwrap_or_else(|_| FALLBACK_CLIENT_VERSION.to_string()); + let client = Client::builder() + .impersonate(Impersonate::Chrome133) + .cookie_store(true) + .build()?; + Ok(Self { client, client_version }) + } + + fn user_agent(&self) -> String { + format!( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \ + (KHTML, like Gecko) ArchonApp/{} Chrome/{} Electron/{} Safari/537.36", + self.client_version, CHROME_VERSION, ELECTRON_VERSION + ) + } + + /// exponential backoff + jitter on 429/5xx. + async fn send_with_retry( + &self, + mut builder: rquest::RequestBuilder, + ) -> Result { + builder = builder.header("User-Agent", self.user_agent()); + for attempt in 0..=MAX_RETRIES { + let req = builder + .try_clone() + .ok_or_else(|| anyhow!("request body not cloneable for retry"))?; + let resp = req.send().await; + match resp { + Ok(r) => { + let s = r.status().as_u16(); + if s < 400 { + return Ok(r); + } + if (s == 429 || s >= 500) && attempt < MAX_RETRIES { + let base = RETRY_BASE_DELAY_MS * (1u64 << attempt); + let jitter: u64 = rand::thread_rng().gen_range(0..1000); + tokio::time::sleep(Duration::from_millis(base + jitter)).await; + continue; + } + let body = r.text().await.unwrap_or_default(); + bail!("HTTP {s}: {}", truncate(&body, 500)); + } + Err(e) => { + if attempt < MAX_RETRIES { + let base = RETRY_BASE_DELAY_MS * (1u64 << attempt); + tokio::time::sleep(Duration::from_millis(base)).await; + continue; + } + return Err(e.into()); + } + } + } + unreachable!() + } + + pub async fn login(&self, email: &str, password: &str) -> Result { + let body = json!({ + "email": email, + "password": password, + "version": self.client_version, + }); + let resp = self + .send_with_retry( + self.client + .post(format!("{BASE_URL}/desktop-client/log-in")) + .header("Content-Type", "application/json") + .json(&body), + ) + .await?; + Ok(resp.json::().await?) + } + + pub async fn fetch_parser_code(&self) -> Result { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_millis(); + let url = format!( + "{BASE_URL}/desktop-client/parser?id=1&ts={ts}\ + &gameContentDetectionEnabled=false&metersEnabled=false&liveFightDataEnabled=false" + ); + let resp = self.send_with_retry(self.client.get(&url)).await?; + let html = resp.text().await?; + + let gamedata_re = Regex::new(r"(?s)]*>(.*?window\.gameContentTypes.*?)")?; + let gamedata_code = gamedata_re + .captures(&html) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().trim().to_string()) + .unwrap_or_default(); + + let parser_url_re = + Regex::new(r#"src="(https://assets\.rpglogs\.com/js/parser-warcraft[^"]+)""#)?; + let parser_url = parser_url_re + .captures(&html) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .context("parser-warcraft script URL not found in parser page")?; + + let parser_resp = self.send_with_retry(self.client.get(&parser_url)).await?; + let parser_code = parser_resp.text().await?; + + let pv_re = Regex::new(r"const parserVersion\s*=\s*(\d+)")?; + let parser_version = pv_re + .captures(&html) + .and_then(|c| c.get(1)) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(59); + + Ok(ParserBundle { + gamedata_code, + parser_code, + parser_version, + }) + } + + pub async fn create_report( + &self, + filename: &str, + start_time: i64, + end_time: i64, + region: i32, + visibility: i32, + guild_id: Option, + parser_version: i32, + ) -> Result { + let body = json!({ + "clientVersion": self.client_version, + "parserVersion": parser_version, + "startTime": start_time, + "endTime": end_time, + "guildId": guild_id, + "fileName": filename, + "serverOrRegion": region, + "visibility": visibility, + "reportTagId": serde_json::Value::Null, + "description": "", + }); + let resp = self + .send_with_retry( + self.client + .post(format!("{BASE_URL}/desktop-client/create-report")) + .header("Content-Type", "application/json") + .json(&body), + ) + .await?; + let v: Value = resp.json().await?; + v.get("code") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()) + .context("create-report response missing `code`") + } + + pub async fn set_master_table( + &self, + code: &str, + segment_id: i64, + zip_bytes: Vec, + ) -> Result<()> { + let (boundary, body) = build_multipart( + &[("segmentId", &segment_id.to_string()), ("isRealTime", "false")], + &[("logfile", "blob", "application/zip", zip_bytes)], + ); + self.send_with_retry( + self.client + .post(format!( + "{BASE_URL}/desktop-client/set-report-master-table/{code}" + )) + .header("Content-Type", format!("multipart/form-data; boundary={boundary}")) + .body(body), + ) + .await?; + Ok(()) + } + + pub async fn add_segment( + &self, + code: &str, + segment_id: i64, + start_time: i64, + end_time: i64, + mythic: i32, + zip_bytes: Vec, + ) -> Result { + let parameters = json!({ + "startTime": start_time, + "endTime": end_time, + "mythic": mythic, + "isLiveLog": false, + "isRealTime": false, + "inProgressEventCount": 0, + "segmentId": segment_id, + }); + let (boundary, body) = build_multipart( + &[("parameters", ¶meters.to_string())], + &[("logfile", "blob", "application/zip", zip_bytes)], + ); + let resp = self + .send_with_retry( + self.client + .post(format!( + "{BASE_URL}/desktop-client/add-report-segment/{code}" + )) + .header( + "Content-Type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(body), + ) + .await?; + let v: Value = resp.json().await?; + Ok(v.get("nextSegmentId") + .and_then(|n| n.as_i64()) + .unwrap_or(segment_id + 1)) + } + + pub async fn terminate_report(&self, code: &str) -> Result<()> { + self.send_with_retry( + self.client + .post(format!("{BASE_URL}/desktop-client/terminate-report/{code}")), + ) + .await?; + Ok(()) + } +} + +async fn fetch_latest_client_version() -> Result { + let client = rquest::Client::builder().build()?; + let resp = client + .get("https://api.github.com/repos/RPGLogs/Uploaders-archon/releases/latest") + .header("Accept", "application/vnd.github.v3+json") + .header("User-Agent", "wcl-upload") + .timeout(Duration::from_secs(10)) + .send() + .await?; + let v: Value = resp.json().await?; + let name = v + .get("name") + .and_then(|n| n.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .context("no release name")?; + Ok(name) +} + +fn build_multipart( + fields: &[(&str, &str)], + files: &[(&str, &str, &str, Vec)], +) -> (String, Vec) { + let boundary = format!( + "----WebKitFormBoundary{}", + random_alnum(16) + ); + let mut body: Vec = Vec::new(); + for (name, value) in fields { + let part = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n", + boundary = boundary, + name = name, + value = value + ); + body.extend_from_slice(part.as_bytes()); + } + for (name, fname, ctype, data) in files { + let header = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; \ + filename=\"{fname}\"\r\nContent-Type: {ctype}\r\n\r\n", + boundary = boundary, + name = name, + fname = fname, + ctype = ctype + ); + body.extend_from_slice(header.as_bytes()); + body.extend_from_slice(data); + body.extend_from_slice(b"\r\n"); + } + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + (boundary, body) +} + +fn random_alnum(n: usize) -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::thread_rng(); + (0..n) + .map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char) + .collect() +} + +pub fn make_zip(content: &str) -> Result> { + use zip::write::SimpleFileOptions; + use zip::CompressionMethod; + + let mut buf = Vec::new(); + { + let mut zw = zip::ZipWriter::new(Cursor::new(&mut buf)); + let opts = SimpleFileOptions::default() + .compression_method(CompressionMethod::Deflated) + .compression_level(Some(6)); + zw.start_file("log.txt", opts)?; + zw.write_all(content.as_bytes())?; + zw.finish()?; + } + Ok(buf) +} + + +pub fn build_master_string(m: &Value, log_version: i64, game_version: i64) -> String { + let mut parts = vec![format!("{log_version}|{game_version}|")]; + for (key, skey) in &[ + ("lastAssignedActorID", "actorsString"), + ("lastAssignedAbilityID", "abilitiesString"), + ("lastAssignedTupleID", "tuplesString"), + ("lastAssignedPetID", "petsString"), + ] { + let last = m.get(*key).and_then(|v| v.as_i64()).unwrap_or(0); + parts.push(last.to_string()); + let s = m.get(*skey).and_then(|v| v.as_str()).unwrap_or(""); + if !s.is_empty() { + parts.push(s.trim_end_matches('\n').to_string()); + } + } + parts.join("\n") + "\n" +} + + +pub fn build_fights_string(fd: &Value) -> String { + let log_version = fd.get("logVersion").and_then(|v| v.as_i64()).unwrap_or(0); + let game_version = fd.get("gameVersion").and_then(|v| v.as_i64()).unwrap_or(0); + let fights = fd.get("fights").and_then(|v| v.as_array()); + let total: i64 = fights + .map(|a| { + a.iter() + .filter_map(|f| f.get("eventCount").and_then(|n| n.as_i64())) + .sum() + }) + .unwrap_or(0); + let evts: String = fights + .map(|a| { + a.iter() + .filter_map(|f| f.get("eventsString").and_then(|s| s.as_str())) + .collect() + }) + .unwrap_or_default(); + format!("{log_version}|{game_version}\n{total}\n{evts}") +} + +pub fn parse_start_date(filename: &str) -> Option { + let re = Regex::new(r"WoWCombatLog-(\d{2})(\d{2})(\d{2})_").ok()?; + let c = re.captures(filename)?; + let mm: i32 = c.get(1)?.as_str().parse().ok()?; + let dd: i32 = c.get(2)?.as_str().parse().ok()?; + let yy: i32 = c.get(3)?.as_str().parse().ok()?; + Some(format!("{mm}/{dd}/{}", 2000 + yy)) +} + +fn truncate(s: &str, n: usize) -> String { + if s.len() <= n { + s.to_string() + } else { + format!("{}…", &s[..n]) + } +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json new file mode 100644 index 0000000..6e25f12 --- /dev/null +++ b/app/src-tauri/tauri.conf.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "combatlog.dev", + "version": "0.1.0", + "identifier": "dev.combatlog.app", + "build": { + "frontendDist": "../src", + "beforeDevCommand": "", + "beforeBuildCommand": "" + }, + "app": { + "windows": [ + { + "title": "combatlog.dev", + "width": 520, + "height": 760, + "resizable": true, + "center": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "externalBin": [ + "binaries/node" + ], + "resources": [ + "resources/parser-harness.js" + ] + }, + "plugins": {} +} diff --git a/app/src/index.html b/app/src/index.html new file mode 100644 index 0000000..f0ed557 --- /dev/null +++ b/app/src/index.html @@ -0,0 +1,587 @@ + + + + + +combatlog.dev + + + + + + + +
+
+
+

combatlog.dev

+
a privacy conscious uploader for warcraftlogs
+
+ +
+
+ + +
+ +
+ + +
+ + + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + + + + +
+ Drop your WoWCombatLog here
or click to browse +
+
+
+
+ + +
+ +
+
+
+
+
+ +
+
Upload complete
+ + +
+ +
+
+
+ + + + diff --git a/docker-compose.local.yml b/docker-compose.local.yml index bccef07..22218fb 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -2,7 +2,7 @@ services: wcl-uploader: build: context: . - dockerfile: Dockerfile.local + dockerfile: web/Dockerfile.local container_name: wcl-uploader restart: unless-stopped ports: diff --git a/docker-compose.yml b/docker-compose.yml index f2c6d67..6e97a0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: wcl-uploader: - build: . + build: + context: . + dockerfile: web/Dockerfile container_name: wcl-uploader restart: unless-stopped expose: diff --git a/Dockerfile b/web/Dockerfile similarity index 91% rename from Dockerfile rename to web/Dockerfile index a9e4b78..dfe8dc0 100644 --- a/Dockerfile +++ b/web/Dockerfile @@ -6,7 +6,7 @@ RUN apt-get update && \ apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY parser-harness.js webapp.py ./ +COPY parser-harness.js web/webapp.py ./ EXPOSE 5050 diff --git a/Dockerfile.local b/web/Dockerfile.local similarity index 91% rename from Dockerfile.local rename to web/Dockerfile.local index a9e4b78..dfe8dc0 100644 --- a/Dockerfile.local +++ b/web/Dockerfile.local @@ -6,7 +6,7 @@ RUN apt-get update && \ apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY parser-harness.js webapp.py ./ +COPY parser-harness.js web/webapp.py ./ EXPOSE 5050 diff --git a/webapp.py b/web/webapp.py similarity index 99% rename from webapp.py rename to web/webapp.py index ea9be88..77b3dab 100644 --- a/webapp.py +++ b/web/webapp.py @@ -21,7 +21,9 @@ app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 * 1024 # 1 GB SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -PARSER_HARNESS = os.path.join(SCRIPT_DIR, 'parser-harness.js') +_LOCAL_HARNESS = os.path.join(SCRIPT_DIR, 'parser-harness.js') +_REPO_HARNESS = os.path.normpath(os.path.join(SCRIPT_DIR, '..', 'parser-harness.js')) +PARSER_HARNESS = _LOCAL_HARNESS if os.path.exists(_LOCAL_HARNESS) else _REPO_HARNESS BATCH_SIZE = 100000 BASE_URL = 'https://www.warcraftlogs.com' FALLBACK_CLIENT_VERSION = '9.0.1' From b5feae4b7bc2b4351fccb1cb718c74851a39ed0b Mon Sep 17 00:00:00 2001 From: Vinter Date: Fri, 17 Apr 2026 09:47:21 +0200 Subject: [PATCH 2/6] enable window.__TAURI__ global so the frontend script actually runs --- app/src-tauri/tauri.conf.json | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 6e25f12..62eeaca 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -9,6 +9,7 @@ "beforeBuildCommand": "" }, "app": { + "withGlobalTauri": true, "windows": [ { "title": "combatlog.dev", From 829a0318b4b49b33bc7eaaf81cdec56cedbe9e25 Mon Sep 17 00:00:00 2001 From: Vinter Date: Fri, 17 Apr 2026 09:53:50 +0200 Subject: [PATCH 3/6] use prebuilt tauri-cli instead of compiling it every run --- .github/workflows/app.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index 09c0312..adc4928 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -61,7 +61,9 @@ jobs: run: bash app/scripts/prepare-sidecar.sh ${{ matrix.target }} - name: Install tauri-cli - run: cargo install --locked tauri-cli --version "^2" + uses: taiki-e/install-action@v2 + with: + tool: tauri-cli@^2 - name: Generate icons from source PNG working-directory: app From 64d43aa534199c693077d434195a94961115b2e4 Mon Sep 17 00:00:00 2001 From: Vinter Date: Fri, 17 Apr 2026 09:56:47 +0200 Subject: [PATCH 4/6] drop caret from tauri-cli version, install-action wants plain version or none --- .github/workflows/app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index adc4928..e178143 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -63,7 +63,7 @@ jobs: - name: Install tauri-cli uses: taiki-e/install-action@v2 with: - tool: tauri-cli@^2 + tool: tauri-cli - name: Generate icons from source PNG working-directory: app From 8b7b2f7b87a02c737e3d2928d0fc1c5228d013e0 Mon Sep 17 00:00:00 2001 From: Vinter Date: Fri, 17 Apr 2026 10:16:55 +0200 Subject: [PATCH 5/6] attach each bundle file to github release on tag push --- .github/workflows/app.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index e178143..fe1283c 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -25,6 +25,8 @@ jobs: - os: windows-latest target: x86_64-pc-windows-msvc runs-on: ${{ matrix.os }} + permissions: + contents: write defaults: run: shell: bash @@ -80,3 +82,16 @@ jobs: path: | app/src-tauri/target/${{ matrix.target }}/release/bundle/**/* if-no-files-found: error + + - name: Attach bundles to GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + draft: true + fail_on_unmatched_files: false + files: | + app/src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb + app/src-tauri/target/${{ matrix.target }}/release/bundle/appimage/*.AppImage + app/src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm + app/src-tauri/target/${{ matrix.target }}/release/bundle/msi/*.msi + app/src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*-setup.exe From 8549df1668de3c8fea0c155d25bace61ed7e46de Mon Sep 17 00:00:00 2001 From: Vinter Date: Fri, 17 Apr 2026 10:47:07 +0200 Subject: [PATCH 6/6] update readme for desktop app + release links --- README.md | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 63fb1b4..504b3b5 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,34 @@ # combatlog.dev -A privacy-conscious combat log uploader for [WarcraftLogs](https://www.warcraftlogs.com). No telemetry, no analytics, no ads. +A privacy-conscious combat log uploader for [WarcraftLogs](https://www.warcraftlogs.com). No telemetry, no analytics, no ads. -## Web UI (Self-Hosted) +## Desktop app -### Requirements +The easiest option if you just want to upload logs from your own machine. Grab the installer for your OS from the [Releases](../../releases) page: -- Docker & Docker Compose +- **Windows** — `.msi` installer +- **Linux** — `.deb`, `.rpm`, or `.AppImage` -### Local +Credentials stay in local storage on your machine. + +## Web UI (self-hosted) + +**Requirements:** Docker + Docker Compose. ```bash docker compose -f docker-compose.local.yml up --build ``` -Open [http://localhost:5050](http://localhost:5050) in your browser. - -## CLI Script +Then open [http://localhost:5050](http://localhost:5050). -### Requirements +## CLI +**Requirements:** - Python 3.10+ - Node.js 18+ - `curl_cffi` (`pip install curl_cffi`) -### Usage +**Usage:** ```bash python3 wcl-upload.py WoWCombatLog-041225_203000.txt \ @@ -32,7 +36,7 @@ python3 wcl-upload.py WoWCombatLog-041225_203000.txt \ --password yourpass ``` -### Options +**Options:** | Flag | Default | Description | |---|---|---| @@ -40,4 +44,17 @@ python3 wcl-upload.py WoWCombatLog-041225_203000.txt \ | `--password` | *(required)* | WarcraftLogs password | | `--region` | `2` | 1=US, 2=EU, 3=KR, 4=TW, 5=CN | | `--visibility` | `2` | 0=Public, 1=Private, 2=Unlisted | -| `--guild-id` | *none* | Guild ID to associate the report with | \ No newline at end of file +| `--guild-id` | *none* | Guild ID to associate the report with | + +## Building the desktop app from source + +If you want to build yourself instead of downloading a release: + +```bash +cd app +bash scripts/prepare-sidecar.sh # downloads the Node sidecar for your host +cargo tauri icon src-tauri/icons/icon.png # first time only +cargo tauri build +``` + +Needs Rust (stable) and, on Linux, the usual webkit2gtk dev packages.