diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml new file mode 100644 index 0000000..fe1283c --- /dev/null +++ b/.github/workflows/app.yml @@ -0,0 +1,97 @@ +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 }} + permissions: + contents: write + 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 + uses: taiki-e/install-action@v2 + with: + tool: tauri-cli + + - 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 + + - 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 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/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. 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 0000000..cb1cfe9 Binary files /dev/null and b/app/src-tauri/icons/icon.png differ 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..62eeaca --- /dev/null +++ b/app/src-tauri/tauri.conf.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "combatlog.dev", + "version": "0.1.0", + "identifier": "dev.combatlog.app", + "build": { + "frontendDist": "../src", + "beforeDevCommand": "", + "beforeBuildCommand": "" + }, + "app": { + "withGlobalTauri": true, + "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'