From d5e1421ff2d4f4066043885bc4b309859213b2f9 Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Sun, 17 May 2026 11:06:06 -0400 Subject: [PATCH 01/13] migrate: add Tauri shell and Rust core --- .github/workflows/release-beta.yml | 67 +- .github/workflows/release-stable.yml | 67 +- .github/workflows/tauri-linux-build.yml | 267 ++ .github/workflows/tauri-windows-build.yml | 254 ++ .gitignore | 6 +- Cargo.lock | 4781 ++++++++++++++++++++ Cargo.toml | 3 + README.md | 54 +- Release Notes/template.md | 6 + backend/api.py | 247 +- backend/core_bridge.py | 138 + backend/test_api_status_core.py | 326 ++ backend/test_core_bridge.py | 82 + bun.lock | 32 +- crates/patchops-core/Cargo.toml | 17 + crates/patchops-core/src/commands.rs | 51 + crates/patchops-core/src/config_reader.rs | 169 + crates/patchops-core/src/error.rs | 15 + crates/patchops-core/src/file_integrity.rs | 59 + crates/patchops-core/src/game_scan.rs | 147 + crates/patchops-core/src/lib.rs | 8 + crates/patchops-core/src/main.rs | 42 + crates/patchops-core/src/steam_detect.rs | 140 + crates/patchops-core/tests/cli.rs | 120 + package.json | 19 +- scripts/check-tauri-version.ts | 49 + scripts/clean-tauri-bundles.ts | 16 + scripts/dev-cleanup.ts | 4 +- scripts/dev-tauri.ts | 52 + scripts/prepare-tauri-sidecars.ts | 67 + scripts/verify-tauri-sidecars.ts | 40 + src-tauri/Cargo.toml | 16 + src-tauri/binaries/.gitkeep | 1 + src-tauri/build.rs | 18 + src-tauri/capabilities/default.json | 10 + src-tauri/src/commands.rs | 103 + src-tauri/src/main.rs | 76 + src-tauri/src/sidecar.rs | 364 ++ src-tauri/src/window.rs | 67 + src-tauri/tauri.conf.json | 47 + src/renderer/lib/api.ts | 8 +- src/renderer/lib/desktop.test.ts | 178 + src/renderer/lib/desktop.ts | 111 + src/renderer/main.tsx | 83 +- src/vite-env.d.ts | 1 + 45 files changed, 8340 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/tauri-linux-build.yml create mode 100644 .github/workflows/tauri-windows-build.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 backend/core_bridge.py create mode 100644 backend/test_api_status_core.py create mode 100644 backend/test_core_bridge.py create mode 100644 crates/patchops-core/Cargo.toml create mode 100644 crates/patchops-core/src/commands.rs create mode 100644 crates/patchops-core/src/config_reader.rs create mode 100644 crates/patchops-core/src/error.rs create mode 100644 crates/patchops-core/src/file_integrity.rs create mode 100644 crates/patchops-core/src/game_scan.rs create mode 100644 crates/patchops-core/src/lib.rs create mode 100644 crates/patchops-core/src/main.rs create mode 100644 crates/patchops-core/src/steam_detect.rs create mode 100644 crates/patchops-core/tests/cli.rs create mode 100644 scripts/check-tauri-version.ts create mode 100644 scripts/clean-tauri-bundles.ts create mode 100644 scripts/dev-tauri.ts create mode 100644 scripts/prepare-tauri-sidecars.ts create mode 100644 scripts/verify-tauri-sidecars.ts create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/binaries/.gitkeep create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/capabilities/default.json create mode 100644 src-tauri/src/commands.rs create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/src/sidecar.rs create mode 100644 src-tauri/src/window.rs create mode 100644 src-tauri/tauri.conf.json create mode 100644 src/renderer/lib/desktop.test.ts create mode 100644 src/renderer/lib/desktop.ts diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 25e97cd..9234e1b 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -309,6 +309,20 @@ jobs: ref: ${{ needs.prepare.outputs.tag }} secrets: inherit + build-tauri-windows: + needs: + - prepare + uses: ./.github/workflows/tauri-windows-build.yml + with: + ref: ${{ needs.prepare.outputs.tag }} + + build-tauri-linux: + needs: + - prepare + uses: ./.github/workflows/tauri-linux-build.yml + with: + ref: ${{ needs.prepare.outputs.tag }} + release: name: Publish beta release runs-on: ubuntu-latest @@ -318,6 +332,8 @@ jobs: - prepare - build-windows - build-linux + - build-tauri-windows + - build-tauri-linux steps: - name: Checkout repository uses: actions/checkout@v4 @@ -334,6 +350,16 @@ jobs: with: name: ${{ needs.build-linux.outputs.artifact_name }} path: artifacts/linux + - name: Download Tauri Windows artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-tauri-windows.outputs.artifact_name }} + path: artifacts/tauri-windows + - name: Download Tauri Linux artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-tauri-linux.outputs.artifact_name }} + path: artifacts/tauri-linux - name: Inspect downloaded artifacts run: | set -euo pipefail @@ -343,7 +369,7 @@ jobs: run: | set -euo pipefail shopt -s nullglob - for dir in artifacts/windows artifacts/linux; do + for dir in artifacts/windows artifacts/linux artifacts/tauri-windows artifacts/tauri-linux; do if [ -d "$dir" ]; then for archive in "${dir}"/*.zip "${dir}"/*.tar "${dir}"/*.tar.gz "${dir}"/*.tgz; do [ -e "$archive" ] || continue @@ -364,6 +390,8 @@ jobs: WINDOWS_VT_URL: ${{ needs.build-windows.outputs.vt_url }} LINUX_HASH: ${{ needs.build-linux.outputs.hash }} LINUX_VT_URL: ${{ needs.build-linux.outputs.vt_url }} + TAURI_WINDOWS_HASH: ${{ needs.build-tauri-windows.outputs.hash }} + TAURI_LINUX_HASH: ${{ needs.build-tauri-linux.outputs.hash }} REPO: ${{ github.repository }} RELEASE_NOTES_SOURCE: ${{ needs.prepare.outputs.notes_file }} RELEASE_TITLE: ${{ needs.prepare.outputs.release_title }} @@ -393,11 +421,17 @@ jobs: linux_vt = os.environ.get("LINUX_VT_URL", "").strip() or "https://www.virustotal.com/" windows_hash = os.environ.get("WINDOWS_HASH", "").strip() or "None" linux_hash = os.environ.get("LINUX_HASH", "").strip() or "None" + tauri_windows_hash = os.environ.get("TAURI_WINDOWS_HASH", "").strip() or "None" + tauri_linux_hash = os.environ.get("TAURI_LINUX_HASH", "").strip() or "None" asset_windows = "PatchOpsIII-Beta.msi" asset_linux = "PatchOpsIII-Beta.AppImage" + asset_tauri_windows = "PatchOpsIII-Beta-Tauri.msi" + asset_tauri_linux = "PatchOpsIII-Beta-Tauri.AppImage" base_url = f"https://github.com/{repo}/releases/download/{tag}" windows_download = f"{base_url}/{asset_windows}" linux_download = f"{base_url}/{asset_linux}" + tauri_windows_download = f"{base_url}/{asset_tauri_windows}" + tauri_linux_download = f"{base_url}/{asset_tauri_linux}" text = source_path.read_text(encoding="utf-8").splitlines() heading_updated = False @@ -418,12 +452,28 @@ jobs: "{{LINUX_SHA256}}": linux_hash, "{{WINDOWS_DOWNLOAD_URL}}": windows_download, "{{LINUX_DOWNLOAD_URL}}": linux_download, + "{{TAURI_WINDOWS_SHA256}}": tauri_windows_hash, + "{{TAURI_LINUX_SHA256}}": tauri_linux_hash, + "{{TAURI_WINDOWS_DOWNLOAD_URL}}": tauri_windows_download, + "{{TAURI_LINUX_DOWNLOAD_URL}}": tauri_linux_download, } final_text = "\n".join(text) for placeholder, value in replacements.items(): final_text = final_text.replace(placeholder, value) + if "## Tauri Migration Preview" not in final_text: + final_text = final_text.rstrip() + ( + "\n\n---\n\n" + "## Tauri Migration Preview\n" + "- Windows Tauri MSI: " + f"[PatchOpsIII Beta Tauri for Windows]({tauri_windows_download}) " + f"(SHA256: `{tauri_windows_hash}`)\n" + "- Linux Tauri AppImage: " + f"[PatchOpsIII Beta Tauri for Linux & Steam Deck]({tauri_linux_download}) " + f"(SHA256: `{tauri_linux_hash}`)\n" + ) + if not final_text.endswith("\n"): final_text += "\n" @@ -468,6 +518,21 @@ jobs: if [ -f "$(dirname "$linux_exec")/PatchOpsIII.AppImage.sig" ]; then cp "$(dirname "$linux_exec")/PatchOpsIII.AppImage.sig" "release/PatchOpsIII-Beta.AppImage.sig" fi + tauri_win=$(find artifacts/tauri-windows -type f -name '*.msi' -print -quit) + if [ -z "$tauri_win" ]; then + echo "Tauri Windows MSI not found under artifacts/tauri-windows" >&2 + find artifacts/tauri-windows -type f -print >&2 + exit 1 + fi + cp "$tauri_win" "release/PatchOpsIII-Beta-Tauri.msi" + tauri_linux=$(find artifacts/tauri-linux -type f -name '*.AppImage' -print -quit) + if [ -z "$tauri_linux" ]; then + echo "Tauri Linux AppImage not found under artifacts/tauri-linux" >&2 + find artifacts/tauri-linux -type f -print >&2 + exit 1 + fi + cp "$tauri_linux" "release/PatchOpsIII-Beta-Tauri.AppImage" + chmod +x "release/PatchOpsIII-Beta-Tauri.AppImage" - name: Publish GitHub release uses: softprops/action-gh-release@v2 with: diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 1d62e32..094c76c 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -302,6 +302,20 @@ jobs: ref: ${{ needs.prepare.outputs.tag }} secrets: inherit + build-tauri-windows: + needs: + - prepare + uses: ./.github/workflows/tauri-windows-build.yml + with: + ref: ${{ needs.prepare.outputs.tag }} + + build-tauri-linux: + needs: + - prepare + uses: ./.github/workflows/tauri-linux-build.yml + with: + ref: ${{ needs.prepare.outputs.tag }} + release: name: Publish stable release runs-on: ubuntu-latest @@ -311,6 +325,8 @@ jobs: - prepare - build-windows - build-linux + - build-tauri-windows + - build-tauri-linux steps: - name: Checkout repository uses: actions/checkout@v4 @@ -327,6 +343,16 @@ jobs: with: name: ${{ needs.build-linux.outputs.artifact_name }} path: artifacts/linux + - name: Download Tauri Windows artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-tauri-windows.outputs.artifact_name }} + path: artifacts/tauri-windows + - name: Download Tauri Linux artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-tauri-linux.outputs.artifact_name }} + path: artifacts/tauri-linux - name: Inspect downloaded artifacts run: | set -euo pipefail @@ -336,7 +362,7 @@ jobs: run: | set -euo pipefail shopt -s nullglob - for dir in artifacts/windows artifacts/linux; do + for dir in artifacts/windows artifacts/linux artifacts/tauri-windows artifacts/tauri-linux; do if [ -d "$dir" ]; then for archive in "${dir}"/*.zip "${dir}"/*.tar "${dir}"/*.tar.gz "${dir}"/*.tgz; do [ -e "$archive" ] || continue @@ -357,6 +383,8 @@ jobs: WINDOWS_VT_URL: ${{ needs.build-windows.outputs.vt_url }} LINUX_HASH: ${{ needs.build-linux.outputs.hash }} LINUX_VT_URL: ${{ needs.build-linux.outputs.vt_url }} + TAURI_WINDOWS_HASH: ${{ needs.build-tauri-windows.outputs.hash }} + TAURI_LINUX_HASH: ${{ needs.build-tauri-linux.outputs.hash }} REPO: ${{ github.repository }} RELEASE_NOTES_SOURCE: ${{ needs.prepare.outputs.notes_file }} RELEASE_TITLE: ${{ needs.prepare.outputs.release_title }} @@ -386,11 +414,17 @@ jobs: linux_vt = os.environ.get("LINUX_VT_URL", "").strip() or "https://www.virustotal.com/" windows_hash = os.environ.get("WINDOWS_HASH", "").strip() or "None" linux_hash = os.environ.get("LINUX_HASH", "").strip() or "None" + tauri_windows_hash = os.environ.get("TAURI_WINDOWS_HASH", "").strip() or "None" + tauri_linux_hash = os.environ.get("TAURI_LINUX_HASH", "").strip() or "None" asset_windows = "PatchOpsIII.msi" asset_linux = "PatchOpsIII.AppImage" + asset_tauri_windows = "PatchOpsIII-Tauri.msi" + asset_tauri_linux = "PatchOpsIII-Tauri.AppImage" base_url = f"https://github.com/{repo}/releases/download/{tag}" windows_download = f"{base_url}/{asset_windows}" linux_download = f"{base_url}/{asset_linux}" + tauri_windows_download = f"{base_url}/{asset_tauri_windows}" + tauri_linux_download = f"{base_url}/{asset_tauri_linux}" text = source_path.read_text(encoding="utf-8").splitlines() heading_updated = False @@ -411,12 +445,28 @@ jobs: "{{LINUX_SHA256}}": linux_hash, "{{WINDOWS_DOWNLOAD_URL}}": windows_download, "{{LINUX_DOWNLOAD_URL}}": linux_download, + "{{TAURI_WINDOWS_SHA256}}": tauri_windows_hash, + "{{TAURI_LINUX_SHA256}}": tauri_linux_hash, + "{{TAURI_WINDOWS_DOWNLOAD_URL}}": tauri_windows_download, + "{{TAURI_LINUX_DOWNLOAD_URL}}": tauri_linux_download, } final_text = "\n".join(text) for placeholder, value in replacements.items(): final_text = final_text.replace(placeholder, value) + if "## Tauri Migration Preview" not in final_text: + final_text = final_text.rstrip() + ( + "\n\n---\n\n" + "## Tauri Migration Preview\n" + "- Windows Tauri MSI: " + f"[PatchOpsIII Tauri for Windows]({tauri_windows_download}) " + f"(SHA256: `{tauri_windows_hash}`)\n" + "- Linux Tauri AppImage: " + f"[PatchOpsIII Tauri for Linux & Steam Deck]({tauri_linux_download}) " + f"(SHA256: `{tauri_linux_hash}`)\n" + ) + if not final_text.endswith("\n"): final_text += "\n" @@ -461,6 +511,21 @@ jobs: if [ -f "$(dirname "$linux_exec")/PatchOpsIII.AppImage.sig" ]; then cp "$(dirname "$linux_exec")/PatchOpsIII.AppImage.sig" "release/PatchOpsIII.AppImage.sig" fi + tauri_win=$(find artifacts/tauri-windows -type f -name '*.msi' -print -quit) + if [ -z "$tauri_win" ]; then + echo "Tauri Windows MSI not found under artifacts/tauri-windows" >&2 + find artifacts/tauri-windows -type f -print >&2 + exit 1 + fi + cp "$tauri_win" "release/PatchOpsIII-Tauri.msi" + tauri_linux=$(find artifacts/tauri-linux -type f -name '*.AppImage' -print -quit) + if [ -z "$tauri_linux" ]; then + echo "Tauri Linux AppImage not found under artifacts/tauri-linux" >&2 + find artifacts/tauri-linux -type f -print >&2 + exit 1 + fi + cp "$tauri_linux" "release/PatchOpsIII-Tauri.AppImage" + chmod +x "release/PatchOpsIII-Tauri.AppImage" - name: Publish GitHub release uses: softprops/action-gh-release@v2 with: diff --git a/.github/workflows/tauri-linux-build.yml b/.github/workflows/tauri-linux-build.yml new file mode 100644 index 0000000..54f4e54 --- /dev/null +++ b/.github/workflows/tauri-linux-build.yml @@ -0,0 +1,267 @@ +name: Tauri Linux Build + +on: + push: + branches: + - Testing + - testing + pull_request: + branches: + - Testing + - testing + workflow_dispatch: + inputs: + ref: + description: Git ref to build + required: false + type: string + default: '' + workflow_call: + inputs: + ref: + description: Git ref to build + required: false + type: string + default: '' + outputs: + hash: + description: SHA256 hash of the Tauri AppImage + value: ${{ jobs.build-tauri-linux.outputs.hash }} + artifact_name: + description: Name of the produced artifact + value: ${{ jobs.build-tauri-linux.outputs.artifact_name }} + +permissions: + contents: read + +jobs: + build-tauri-linux: + name: Build Tauri Linux AppImage + runs-on: ubuntu-latest + outputs: + hash: ${{ steps.compute_hash.outputs.hash }} + artifact_name: ${{ steps.artifact.outputs.name }} + defaults: + run: + shell: bash + working-directory: . + env: + BUN_VERSION: "1.3.14" + PYTHON_VERSION: "3.12" + PYINSTALLER_VERSION: "6.20.0" + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} + + - name: Set artifact metadata + id: artifact + run: printf 'name=%s\n' 'PatchOpsIII-tauri-linux' >> "$GITHUB_OUTPUT" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + cache-dependency-path: requirements.txt + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Bun install cache + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: tauri-linux-bun-${{ runner.arch }}-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock', 'package.json') }} + restore-keys: | + tauri-linux-bun-${{ runner.arch }}-${{ env.BUN_VERSION }}- + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: tauri-linux-cargo-${{ runner.arch }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src-tauri/Cargo.toml') }} + restore-keys: | + tauri-linux-cargo-${{ runner.arch }}- + + - name: Cache Python virtual environment + id: cache-venv + uses: actions/cache@v4 + with: + path: .venv + key: tauri-linux-venv-${{ runner.arch }}-${{ env.PYTHON_VERSION }}-${{ env.PYINSTALLER_VERSION }}-${{ hashFiles('requirements.txt') }} + restore-keys: | + tauri-linux-venv-${{ runner.arch }}-${{ env.PYTHON_VERSION }}- + + - name: Install Tauri Linux prerequisites + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + curl \ + file \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + patchelf \ + wget \ + xdotool \ + xvfb + + - name: Install Python backend dependencies + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + python -m venv .venv + .venv/bin/python -m pip install -U pip + .venv/bin/python -m pip install -r requirements.txt + .venv/bin/python -m pip install pyinstaller==${{ env.PYINSTALLER_VERSION }} + + - name: Install frontend dependencies + run: bun install --frozen-lockfile + + - name: Check Tauri version metadata + run: bun run check:tauri-version + + - name: Test renderer desktop adapter + run: bun run test:renderer + + - name: Test Rust core + run: cargo test -p patchops-core + + - name: Test Tauri host + run: cargo test -p patchopsiii-tauri + + - name: Test Python bridge + run: .venv/bin/python -m unittest backend.test_core_bridge backend.test_api_status_core + + - name: Build Tauri AppImage + run: bun run dist:tauri:linux + + - name: Locate AppImage + id: appimage + run: | + set -euo pipefail + mapfile -t appimages < <(find target/release/bundle/appimage -maxdepth 1 -type f -name '*.AppImage') + if [ "${#appimages[@]}" -ne 1 ]; then + find target/release/bundle -maxdepth 4 -type f -print + echo "Expected exactly one Tauri AppImage, found ${#appimages[@]}" >&2 + exit 1 + fi + appimage_path="${appimages[0]}" + chmod +x "$appimage_path" + printf 'path=%s\n' "$appimage_path" >> "$GITHUB_OUTPUT" + + - name: Smoke Tauri AppImage runtime + run: | + set -euo pipefail + app_pid="" + xvfb_pid="" + smoke_root="" + cleanup() { + if [ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null; then + pkill -TERM -P "$app_pid" 2>/dev/null || true + kill "$app_pid" 2>/dev/null || true + wait "$app_pid" 2>/dev/null || true + fi + if [ -n "$xvfb_pid" ] && kill -0 "$xvfb_pid" 2>/dev/null; then + kill "$xvfb_pid" 2>/dev/null || true + wait "$xvfb_pid" 2>/dev/null || true + fi + if [ -n "$smoke_root" ]; then + rm -rf "$smoke_root" + fi + } + trap cleanup EXIT + + smoke_root="$(mktemp -d)" + game_dir="$smoke_root/game" + mkdir -p "$game_dir/players" + printf 'exe' > "$game_dir/BlackOps3.exe" + printf 'MaxFPS = "144"' > "$game_dir/players/config.ini" + export GAME_DIR="$game_dir" + export XDG_DATA_HOME="$smoke_root/data" + + Xvfb :99 -screen 0 1280x800x24 >/tmp/patchops-xvfb.log 2>&1 & + xvfb_pid="$!" + export DISPLAY=:99 + sleep 1 + + PATCHOPSIII_BACKEND_PORT=8891 APPIMAGE_EXTRACT_AND_RUN=1 '${{ steps.appimage.outputs.path }}' & + app_pid="$!" + + for attempt in $(seq 1 90); do + if curl -fsS http://127.0.0.1:8891/api/health >/tmp/patchops-health.json 2>/dev/null && + curl -fsS http://127.0.0.1:8891/api/status >/tmp/patchops-status.json 2>/dev/null; then + .venv/bin/python -c "import json; health=json.load(open('/tmp/patchops-health.json', encoding='utf-8')); status=json.load(open('/tmp/patchops-status.json', encoding='utf-8')); presets=status.get('presets'); assert health.get('version') and status.get('appVersion'), 'Tauri runtime did not report backend/app versions'; assert isinstance(presets, list) and len(presets) >= 2, 'Tauri runtime did not load bundled presets.json'; print(f\"healthVersion={health['version']}\"); print(f\"statusVersion={status['appVersion']}\"); print(f\"presetCount={len(presets)}\")" + .venv/bin/python -c "import json, os; print(json.dumps({'path': os.environ['GAME_DIR']}))" >/tmp/patchops-game-dir.json + curl -fsS -X POST http://127.0.0.1:8891/api/game-directory -H 'Content-Type: application/json' --data-binary @/tmp/patchops-game-dir.json >/tmp/patchops-set-dir.json + .venv/bin/python -c "import json, os, pathlib; result=json.load(open('/tmp/patchops-set-dir.json', encoding='utf-8')); assert result.get('ok'), 'Tauri runtime rejected /api/game-directory'; settings_path=pathlib.Path(os.environ['XDG_DATA_HOME'])/'PatchOpsIII'/'patchops-settings.json'; legacy_path=pathlib.Path(os.environ['XDG_DATA_HOME'])/'PatchOpsIII'/'electron-settings.json'; assert settings_path.exists(), 'Tauri runtime did not write patchops-settings.json'; assert not legacy_path.exists(), 'Tauri runtime wrote legacy electron-settings.json'; settings=json.loads(settings_path.read_text(encoding='utf-8')); assert settings.get('game_dir') == os.environ['GAME_DIR'], 'Tauri runtime saved the wrong game directory'; print(f'settingsFile={settings_path.name}')" + cat /tmp/patchops-health.json + printf '\n' + window_id="" + for window_attempt in $(seq 1 40); do + window_id="$(xdotool search --name '^PatchOpsIII$' 2>/dev/null | head -n 1 || true)" + if [ -n "$window_id" ]; then + break + fi + sleep 0.25 + done + if [ -z "$window_id" ]; then + echo "Tauri AppImage window was not visible on the X display" >&2 + exit 1 + fi + xdotool windowclose "$window_id" + for exit_attempt in $(seq 1 40); do + if ! kill -0 "$app_pid" 2>/dev/null; then + break + fi + sleep 0.25 + done + if kill -0 "$app_pid" 2>/dev/null; then + echo "Tauri AppImage did not exit after window close" >&2 + exit 1 + fi + wait "$app_pid" 2>/dev/null || true + app_pid="" + for shutdown_attempt in $(seq 1 40); do + if ! curl -fsS http://127.0.0.1:8891/api/health >/dev/null 2>&1; then + echo "shutdownClean=true" + exit 0 + fi + sleep 0.25 + done + echo "Tauri AppImage backend remained open after app termination" >&2 + exit 1 + fi + sleep 1 + done + + echo "Tauri AppImage did not start the backend or serve /api/status" >&2 + exit 1 + + - name: Compute AppImage hash + id: compute_hash + run: | + hash="$(sha256sum '${{ steps.appimage.outputs.path }}' | cut -d ' ' -f1)" + printf '%s\n' "$hash" > target/release/bundle/appimage/hash.log + printf 'hash=%s\n' "$hash" >> "$GITHUB_OUTPUT" + + - name: Upload Tauri Linux artifact + uses: actions/upload-artifact@v4 + with: + name: PatchOpsIII-tauri-linux + path: | + ${{ steps.appimage.outputs.path }} + target/release/bundle/appimage/hash.log + if-no-files-found: error diff --git a/.github/workflows/tauri-windows-build.yml b/.github/workflows/tauri-windows-build.yml new file mode 100644 index 0000000..4ada6c2 --- /dev/null +++ b/.github/workflows/tauri-windows-build.yml @@ -0,0 +1,254 @@ +name: Tauri Windows Build + +on: + push: + branches: + - Testing + - testing + pull_request: + branches: + - Testing + - testing + workflow_dispatch: + inputs: + ref: + description: Git ref to build + required: false + type: string + default: '' + workflow_call: + inputs: + ref: + description: Git ref to build + required: false + type: string + default: '' + outputs: + hash: + description: SHA256 hash of the Tauri Windows MSI + value: ${{ jobs.build-tauri-windows.outputs.hash }} + artifact_name: + description: Name of the produced artifact + value: ${{ jobs.build-tauri-windows.outputs.artifact_name }} + +permissions: + contents: read + +jobs: + build-tauri-windows: + name: Build Tauri Windows MSI + runs-on: windows-latest + outputs: + hash: ${{ steps.compute_hash.outputs.hash }} + artifact_name: ${{ steps.artifact.outputs.name }} + defaults: + run: + shell: pwsh + working-directory: . + env: + BUN_VERSION: "1.3.14" + PYTHON_VERSION: "3.12" + PYINSTALLER_VERSION: "6.20.0" + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} + + - name: Set artifact metadata + id: artifact + run: | + "name=PatchOpsIII-tauri-windows" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + cache-dependency-path: requirements.txt + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Bun install cache + uses: actions/cache@v4 + with: + path: ~\AppData\Local\bun\install\cache + key: tauri-windows-bun-${{ runner.arch }}-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock', 'package.json') }} + restore-keys: | + tauri-windows-bun-${{ runner.arch }}-${{ env.BUN_VERSION }}- + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~\.cargo\registry + ~\.cargo\git + target + key: tauri-windows-cargo-${{ runner.arch }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src-tauri/Cargo.toml') }} + restore-keys: | + tauri-windows-cargo-${{ runner.arch }}- + + - name: Cache Python virtual environment + id: cache-venv + uses: actions/cache@v4 + with: + path: .venv + key: tauri-windows-venv-${{ runner.arch }}-${{ env.PYTHON_VERSION }}-${{ env.PYINSTALLER_VERSION }}-${{ hashFiles('requirements.txt') }} + restore-keys: | + tauri-windows-venv-${{ runner.arch }}-${{ env.PYTHON_VERSION }}- + + - name: Install Python backend dependencies + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + python -m venv .venv + .\.venv\Scripts\python -m pip install -U pip + .\.venv\Scripts\python -m pip install -r requirements.txt + .\.venv\Scripts\python -m pip install pyinstaller==${{ env.PYINSTALLER_VERSION }} + + - name: Install frontend dependencies + run: bun install --frozen-lockfile + + - name: Check Tauri version metadata + run: bun run check:tauri-version + + - name: Test renderer desktop adapter + run: bun run test:renderer + + - name: Test Rust core + run: cargo test -p patchops-core + + - name: Test Tauri host + run: cargo test -p patchopsiii-tauri + + - name: Test Python bridge + run: .\.venv\Scripts\python -m unittest backend.test_core_bridge backend.test_api_status_core + + - name: Build Tauri MSI + run: bun run dist:tauri:win + + - name: Smoke Tauri Windows runtime + run: | + $smokeRoot = Join-Path $env:RUNNER_TEMP 'patchops-tauri-smoke' + $appData = Join-Path $smokeRoot 'appdata' + $gameDir = Join-Path $smokeRoot 'game' + $playersDir = Join-Path $gameDir 'players' + New-Item -ItemType Directory -Force -Path $playersDir | Out-Null + Set-Content -Path (Join-Path $gameDir 'BlackOps3.exe') -Value 'exe' -NoNewline + Set-Content -Path (Join-Path $playersDir 'config.ini') -Value 'MaxFPS = "144"' -NoNewline + $env:PATCHOPSIII_BACKEND_PORT = '8892' + $env:APPDATA = $appData + $app = Start-Process -FilePath ".\target\release\patchopsiii-tauri.exe" -WindowStyle Hidden -PassThru + try { + $health = $null + $status = $null + for ($attempt = 0; $attempt -lt 90; $attempt++) { + try { + $health = Invoke-RestMethod -Uri 'http://127.0.0.1:8892/api/health' -TimeoutSec 1 + $status = Invoke-RestMethod -Uri 'http://127.0.0.1:8892/api/status' -TimeoutSec 5 + break + } catch { + Start-Sleep -Seconds 1 + } + } + if ($null -eq $health -or $null -eq $status) { + throw 'Tauri Windows app did not start the backend or serve /api/status' + } + $presets = @($status.presets) + if (-not $health.version -or -not $status.appVersion) { + throw 'Tauri Windows runtime did not report backend/app versions' + } + if ($presets.Count -lt 2) { + throw 'Tauri Windows runtime did not load bundled presets.json' + } + $body = @{ path = $gameDir } | ConvertTo-Json -Compress + $setResult = Invoke-RestMethod -Uri 'http://127.0.0.1:8892/api/game-directory' -Method Post -ContentType 'application/json' -Body $body -TimeoutSec 5 + if (-not $setResult.ok) { + throw 'Tauri Windows runtime rejected /api/game-directory' + } + $settingsPath = Join-Path $appData 'PatchOpsIII\patchops-settings.json' + $legacyPath = Join-Path $appData 'PatchOpsIII\electron-settings.json' + if (-not (Test-Path $settingsPath)) { + throw 'Tauri Windows runtime did not write patchops-settings.json' + } + if (Test-Path $legacyPath) { + throw 'Tauri Windows runtime wrote legacy electron-settings.json' + } + $settings = Get-Content -Path $settingsPath -Raw | ConvertFrom-Json + if ([string]$settings.game_dir -ne [string]$gameDir) { + throw 'Tauri Windows runtime saved the wrong game directory' + } + $backendPid = (Get-NetTCPConnection -LocalPort 8892 -State Listen -ErrorAction Stop | Select-Object -First 1).OwningProcess + "healthVersion=$($health.version)" + "statusVersion=$($status.appVersion)" + "presetCount=$($presets.Count)" + "backendPid=$backendPid" + "settingsFile=$(Split-Path -Leaf $settingsPath)" + + $app.CloseMainWindow() | Out-Null + for ($attempt = 0; $attempt -lt 40; $attempt++) { + if ($app.HasExited) { + break + } + Start-Sleep -Milliseconds 250 + $app.Refresh() + } + if (-not $app.HasExited) { + throw 'Tauri Windows app did not exit after CloseMainWindow' + } + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $listener = Get-NetTCPConnection -LocalPort 8892 -State Listen -ErrorAction SilentlyContinue + if (-not $listener) { + break + } + Start-Sleep -Milliseconds 250 + } + if (Get-NetTCPConnection -LocalPort 8892 -State Listen -ErrorAction SilentlyContinue) { + throw 'Tauri Windows backend remained open after app exit' + } + "shutdownClean=true" + } finally { + if ($app -and -not $app.HasExited) { + $app.CloseMainWindow() | Out-Null + Start-Sleep -Seconds 2 + if (-not $app.HasExited) { + Stop-Process -Id $app.Id -Force -ErrorAction SilentlyContinue + } + } + Get-NetTCPConnection -LocalPort 8892 -ErrorAction SilentlyContinue | ForEach-Object { + Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $smokeRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + - name: Locate MSI + id: msi + run: | + $msis = @(Get-ChildItem -Path target\release\bundle\msi -Filter *.msi) + if ($msis.Count -ne 1) { + Get-ChildItem -Recurse target\release\bundle | ForEach-Object { $_.FullName } + throw "Expected exactly one Tauri MSI, found $($msis.Count)" + } + $msi = $msis[0] + "path=$($msi.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Compute MSI hash + id: compute_hash + run: | + $hash = (Get-FileHash -Algorithm SHA256 -Path '${{ steps.msi.outputs.path }}').Hash.ToLowerInvariant() + Set-Content -Path target\release\bundle\msi\hash.log -Value $hash + "hash=$hash" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Upload Tauri Windows artifact + uses: actions/upload-artifact@v4 + with: + name: PatchOpsIII-tauri-windows + path: | + ${{ steps.msi.outputs.path }} + target\release\bundle\msi\hash.log + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 5dc83a9..bd59a82 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ build/ dist/ node_modules/ .patchopsiii-dev/ +target/ +src-tauri/binaries/* +!src-tauri/binaries/.gitkeep +src-tauri/gen/ # Log files: *.log @@ -28,4 +32,4 @@ node_modules/ *.vdf # codex: -.codex/ \ No newline at end of file +.codex/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fd9c600 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4781 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "patchops-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "patchopsiii-tauri" +version = "1.3.0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "thiserror 2.0.18", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d35badd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["crates/patchops-core", "src-tauri"] +resolver = "2" diff --git a/README.md b/README.md index 5e1b243..fed52d0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ - [Star History](#star-history) ## Overview -PatchOpsIII streamlines the setup and upkeep of Black Ops III by surfacing popular community tools and quality-of-life tweaks in a single polished Electron interface backed by a local Python API. Whether you are securing your game with T7 Patch, smoothing shader compilation stutter with DXVK, or fine-tuning launch options, PatchOpsIII consolidates every workflow into one cohesive experience. +PatchOpsIII streamlines the setup and upkeep of Black Ops III by surfacing popular community tools and quality-of-life tweaks in a single polished desktop interface backed by a local Python API. Whether you are securing your game with T7 Patch, smoothing shader compilation stutter with DXVK, or fine-tuning launch options, PatchOpsIII consolidates every workflow into one cohesive experience. ## Key Features @@ -64,7 +64,7 @@ PatchOpsIII streamlines the setup and upkeep of Black Ops III by surfacing popul 4. **Dependencies:** The packaged build bundles all required Python dependencies; no additional setup is needed. ### Developer Setup -PatchOpsIII uses a React + TypeScript renderer wrapped by Electron. Bun is the JavaScript runtime/package manager, while Python runs the local backend API. +PatchOpsIII is migrating from Electron to Tauri in small steps. The React + TypeScript renderer remains the UI, Python FastAPI remains the local API and orchestration layer, and the new Rust core handles narrow deterministic local operations such as status scanning, hashing, and config parsing. ```bash # install Python service dependencies @@ -82,7 +82,55 @@ bun run dev bun run dev:desktop ``` -The browser development server uses Vite on `127.0.0.1:5173` and the Python API on `127.0.0.1:8765`. The Electron desktop command uses Vite on `127.0.0.1:5174` and its Python API on `127.0.0.1:8766`, so both commands can run at the same time. The renderer communicates with Python through HTTP APIs and `/ws` WebSockets; Electron IPC is reserved for desktop-specific bridge actions such as selecting a local game directory. +The browser development server uses Vite on `127.0.0.1:5173` and the Python API on `127.0.0.1:8765`. The Electron desktop command uses Vite on `127.0.0.1:5174` and its Python API on `127.0.0.1:8766`. The Tauri desktop command uses Vite on `127.0.0.1:5175` and its Python API on `127.0.0.1:8767`. These defaults keep the browser, Electron, and Tauri dev flows from fighting over ports during the migration. The renderer communicates with Python through HTTP APIs and `/ws` WebSockets; Electron IPC is reserved for legacy desktop-specific bridge actions. + +### Tauri Migration Developer Flow +The target architecture is: + +```text +Tauri Rust host -> React renderer -> Python FastAPI sidecar -> Rust local operations core -> BO3 / Steam / filesystem +``` + +Tauri owns the native window, folder picker, window controls, platform bridge, backend URL bridge, and Python sidecar supervision. It does not duplicate FastAPI routes. Python still owns downloads, update checks, install decisions, elevated helper flows, WebSocket logs, and public `/api/...` behavior. The Rust core is invoked by Python as a JSON subprocess bridge for read-only local operations first. + +The backend writes shared app settings to `patchops-settings.json`. Existing `electron-settings.json` files are still read as a legacy fallback so current installs keep their saved game directory and release channel during the migration. + +```bash +# build the Rust local operations core +bun run build:core + +# run the Tauri desktop shell against the existing React renderer +bun run dev:tauri + +# run the Rust core directly +echo '{"gameDir":"C:\\path\\to\\Call of Duty Black Ops III"}' | target/release/patchops-core status +echo '{"path":"C:\\path\\to\\BlackOps3.exe"}' | target/release/patchops-core hash +``` + +Tauri packaging expects target-triple sidecars in `src-tauri/binaries/`. Build the PyInstaller backend first, build the Rust core, then prepare sidecars: + +```bash +# verify Tauri installer metadata matches the numeric base package version +bun run check:tauri-version + +# Windows +bun run build:backend:win +bun run build:core +bun run prepare:tauri-sidecars +bun run verify:tauri-sidecars +bun run dist:tauri:win + +# Linux +bun run build:backend:linux +bun run build:core +bun run prepare:tauri-sidecars +bun run verify:tauri-sidecars +bun run dist:tauri:linux +``` + +Electron remains available through `bun run dev:desktop` and the existing Electron packaging scripts until the Tauri shell reaches full parity. +Tauri packaging is also covered by `.github/workflows/tauri-windows-build.yml` and `.github/workflows/tauri-linux-build.yml` so Windows MSI and Linux AppImage artifacts can be verified on their native runners. +The beta and stable release workflows publish Tauri artifacts alongside the legacy Electron artifacts during the migration window. ## Forked Components - **BO3 Enhanced Proton fork metadata:** [bo3-enhanced-proton/README.md](bo3-enhanced-proton/README.md) diff --git a/Release Notes/template.md b/Release Notes/template.md index ed33ad4..f216cad 100644 --- a/Release Notes/template.md +++ b/Release Notes/template.md @@ -96,6 +96,12 @@ - Update metadata: [{{LINUX_ZSYNC_FILENAME}}]({{LINUX_ZSYNC_URL}}) - VirusTotal: {{LINUX_VT_STATUS_OR_URL}} +- **Tauri migration preview** + - Windows: [PatchOpsIII {{VERSION}} Tauri for Windows]({{TAURI_WINDOWS_DOWNLOAD_URL}}) + - Windows SHA256: `{{TAURI_WINDOWS_SHA256}}` + - Linux & Steam Deck: [PatchOpsIII {{VERSION}} Tauri for Linux & Steam Deck]({{TAURI_LINUX_DOWNLOAD_URL}}) + - Linux SHA256: `{{TAURI_LINUX_SHA256}}` + --- ## 🧑‍💻 Acknowledgements diff --git a/backend/api.py b/backend/api.py index f947ebc..54b0912 100644 --- a/backend/api.py +++ b/backend/api.py @@ -78,9 +78,40 @@ is_dxvk_async_installed, ) +try: + from backend.core_bridge import CoreBridgeError, CoreUnavailableError, core_available, core_call +except ImportError: + from core_bridge import CoreBridgeError, CoreUnavailableError, core_available, core_call + + +def _resolve_app_root() -> Path: + for env_name in ("PATCHOPSIII_RESOURCE_DIR", "PATCHOPSIII_APP_ROOT"): + value = os.environ.get(env_name, "").strip() + if value: + path = Path(value).resolve() + if path.exists(): + for candidate in (path, path / "_up_"): + if (candidate / "presets.json").exists() or (candidate / "package.json").exists(): + return candidate + return path -APP_ROOT = Path(sys.executable).resolve().parents[1] if getattr(sys, "frozen", False) else Path(__file__).resolve().parents[1] -SETTINGS_PATH = Path(get_app_data_dir()) / "electron-settings.json" + if getattr(sys, "frozen", False): + executable = Path(sys.executable).resolve() + candidates = [executable.parent] + candidates.append(executable.parent / "_up_") + candidates.extend(executable.parents) + candidates.extend(parent / "_up_" for parent in executable.parents) + for candidate in candidates: + if (candidate / "presets.json").exists() or (candidate / "package.json").exists(): + return candidate + return executable.parents[1] + + return Path(__file__).resolve().parents[1] + + +APP_ROOT = _resolve_app_root() +SETTINGS_PATH = Path(get_app_data_dir()) / "patchops-settings.json" +LEGACY_SETTINGS_PATH = Path(get_app_data_dir()) / "electron-settings.json" PRESETS_PATH = APP_ROOT / "presets.json" MOD_FILES_DIR = Path(get_app_data_dir()) / "BO3 Mod Files" GAME_EXECUTABLE_NAMES = ("BlackOpsIII.exe", "BlackOps3.exe") @@ -222,6 +253,13 @@ def _cors_origins() -> list[str]: "http://127.0.0.1:5173", "http://localhost:5174", "http://127.0.0.1:5174", + "http://localhost:5175", + "http://127.0.0.1:5175", + "http://tauri.localhost", + "https://tauri.localhost", + "tauri://localhost", + "asset://localhost", + "app://localhost", "app://patchopsiii", ] extra = [origin.strip() for origin in os.environ.get("PATCHOPSIII_CORS_ORIGINS", "").split(",") if origin.strip()] @@ -294,10 +332,11 @@ class ReleaseChannelPayload(BaseModel): channel: str -_settings_cache: tuple[float | None, dict[str, Any]] | None = None -_game_dir_cache: tuple[float | None, str | None] | None = None +_settings_cache: tuple[Path, float | None, dict[str, Any]] | None = None +_game_dir_cache: tuple[Path, float | None, str | None] | None = None _preset_names_cache: tuple[float | None, list[str]] | None = None _presets_cache: tuple[float | None, dict[str, Any]] | None = None +_core_warning_logged = False def _file_mtime(path: Path) -> float | None: @@ -323,17 +362,30 @@ async def _config_path_async(game_dir: str | None = None) -> Path | None: return await _blocking(_config_path, game_dir) +def _settings_read_path() -> Path: + if SETTINGS_PATH.exists(): + return SETTINGS_PATH + if LEGACY_SETTINGS_PATH.exists(): + return LEGACY_SETTINGS_PATH + return SETTINGS_PATH + + +def _settings_mtime() -> float | None: + return _file_mtime(_settings_read_path()) + + def _load_settings() -> dict[str, Any]: global _settings_cache - mtime = _file_mtime(SETTINGS_PATH) - if _settings_cache and _settings_cache[0] == mtime: - return dict(_settings_cache[1]) + path = _settings_read_path() + mtime = _file_mtime(path) + if _settings_cache and _settings_cache[0] == path and _settings_cache[1] == mtime: + return dict(_settings_cache[2]) try: - loaded = json.loads(SETTINGS_PATH.read_text(encoding="utf-8")) + loaded = json.loads(path.read_text(encoding="utf-8")) settings = loaded if isinstance(loaded, dict) else {} except Exception: settings = {} - _settings_cache = (mtime, dict(settings)) + _settings_cache = (path, mtime, dict(settings)) return settings @@ -346,7 +398,7 @@ def _save_settings(settings: dict[str, Any]) -> None: global _settings_cache, _game_dir_cache SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) SETTINGS_PATH.write_text(json.dumps(settings, indent=2), encoding="utf-8") - _settings_cache = (_file_mtime(SETTINGS_PATH), dict(settings)) + _settings_cache = (SETTINGS_PATH, _file_mtime(SETTINGS_PATH), dict(settings)) _game_dir_cache = None @@ -359,9 +411,10 @@ def _has_game_executable(directory: str | Path | None) -> bool: def _find_game_directory() -> str | None: global _game_dir_cache - settings_mtime = _file_mtime(SETTINGS_PATH) - if _game_dir_cache and _game_dir_cache[0] == settings_mtime: - cached = _game_dir_cache[1] + settings_path = _settings_read_path() + settings_mtime = _settings_mtime() + if _game_dir_cache and _game_dir_cache[0] == settings_path and _game_dir_cache[1] == settings_mtime: + cached = _game_dir_cache[2] if cached is None or _has_game_executable(cached): return cached _game_dir_cache = None @@ -369,14 +422,20 @@ def _find_game_directory() -> str | None: saved = _load_settings().get("game_dir") if isinstance(saved, str) and _has_game_executable(saved): found = str(Path(saved)) - _game_dir_cache = (settings_mtime, found) + _game_dir_cache = (settings_path, settings_mtime, found) return found for library in get_steam_library_paths(): candidate = Path(library) / "steamapps" / "common" / "Call of Duty Black Ops III" if _has_game_executable(candidate): found = str(candidate) - _game_dir_cache = (settings_mtime, found) + _game_dir_cache = (settings_path, settings_mtime, found) + return found + + for candidate in _core_steam_game_dirs(): + if _has_game_executable(candidate): + found = str(candidate) + _game_dir_cache = (settings_path, settings_mtime, found) return found system = platform.system() @@ -401,9 +460,9 @@ def _find_game_directory() -> str | None: for candidate in candidates: if _has_game_executable(candidate): found = str(candidate) - _game_dir_cache = (settings_mtime, found) + _game_dir_cache = (settings_path, settings_mtime, found) return found - _game_dir_cache = (settings_mtime, None) + _game_dir_cache = (settings_path, settings_mtime, None) return None @@ -447,6 +506,90 @@ def _config_bool(content: str, key: str, enabled_value: str = "1", default: str return str(_extract_config(content, key, default)) == enabled_value +def _core_warn_once(message: str) -> None: + global _core_warning_logged + if _core_warning_logged: + return + _core_warning_logged = True + write_log(message, "Warning", log_target) + + +def _core_status(game_dir: str | None) -> dict[str, Any] | None: + if not game_dir: + return None + try: + if not core_available(): + return None + return core_call("status", {"gameDir": game_dir}, timeout=15) + except CoreUnavailableError: + return None + except CoreBridgeError as exc: + _core_warn_once(f"Rust core status scan unavailable: {exc}") + except Exception as exc: + _core_warn_once(f"Rust core status scan failed: {exc}") + return None + + +def _core_config_values(game_dir: str | None) -> dict[str, str] | None: + if not game_dir: + return None + try: + if not core_available(): + return None + result = core_call("read-config", {"gameDir": game_dir}, timeout=15) + values = result.get("values") + if isinstance(values, dict): + return {str(key): str(value) for key, value in values.items()} + except CoreUnavailableError: + return None + except CoreBridgeError as exc: + _core_warn_once(f"Rust core config read unavailable: {exc}") + except Exception as exc: + _core_warn_once(f"Rust core config read failed: {exc}") + return None + + +def _core_steam_game_dirs() -> list[Path]: + try: + if not core_available(): + return [] + result = core_call("scan-steam", {}, timeout=15) + game_dirs = result.get("gameDirs") + if isinstance(game_dirs, list): + return [Path(str(item)) for item in game_dirs if item] + except CoreUnavailableError: + return [] + except CoreBridgeError as exc: + _core_warn_once(f"Rust core Steam scan unavailable: {exc}") + except Exception as exc: + _core_warn_once(f"Rust core Steam scan failed: {exc}") + return [] + + +def _config_value(content: str, rust_values: dict[str, str] | None, key: str, default: Any) -> Any: + if rust_values and key in rust_values: + return rust_values[key] + return _extract_config(content, key, default) + + +def _config_int_value(content: str, rust_values: dict[str, str] | None, key: str, default: int) -> int: + try: + return int(float(_config_value(content, rust_values, key, default))) + except (TypeError, ValueError): + return default + + +def _config_float_value(content: str, rust_values: dict[str, str] | None, key: str, default: float) -> float: + try: + return float(_config_value(content, rust_values, key, default)) + except (TypeError, ValueError): + return default + + +def _config_bool_value(content: str, rust_values: dict[str, str] | None, key: str, enabled_value: str = "1", default: str = "0") -> bool: + return str(_config_value(content, rust_values, key, default)) == enabled_value + + def _write_config_value(game_dir: str, key: str, value: str | int | float | bool, comment: str) -> None: _write_config_values(game_dir, [(key, value, comment)]) write_log(f"Set {key} to {value}.", "Success", log_target) @@ -554,7 +697,7 @@ def _t7_mode(game_dir: str | None) -> str: return "Custom" if executable else "Unknown" -def _t7_status(game_dir: str | None) -> dict[str, Any]: +def _t7_status(game_dir: str | None, core_status: dict[str, Any] | None = None) -> dict[str, Any]: if not game_dir: return { "installed": False, @@ -569,8 +712,8 @@ def _t7_status(game_dir: str | None) -> dict[str, Any]: patch_status = check_t7_patch_status(game_dir) return { - "installed": is_t7_patch_installed(game_dir), - "confExists": (Path(game_dir) / "t7patch.conf").exists(), + "installed": bool(core_status.get("t7Installed")) if core_status and "t7Installed" in core_status else is_t7_patch_installed(game_dir), + "confExists": bool(core_status.get("t7ConfigExists")) if core_status and "t7ConfigExists" in core_status else (Path(game_dir) / "t7patch.conf").exists(), "gamertag": patch_status.get("gamertag", ""), "plainName": patch_status.get("plain_name", ""), "colorCode": patch_status.get("color_code", ""), @@ -660,11 +803,15 @@ def _executable_integrity_status(exe_hash: str, executable_exists: bool, enhance } -def _exe_swap_status(game_dir: str | None) -> dict[str, Any]: +def _exe_swap_status(game_dir: str | None, core_status: dict[str, Any] | None = None) -> dict[str, Any]: executable = _find_bo3_executable(game_dir) if game_dir else None exe_hash = (file_sha256(str(executable)) or "").lower() if executable and executable.exists() else "" + if core_status and core_status.get("executable"): + executable = Path(str(core_status.get("executable"))) + exe_hash = str(core_status.get("executableHash") or exe_hash).lower() enhanced_active = bool(game_dir and detect_enhanced_install(game_dir)) - integrity = _executable_integrity_status(exe_hash, bool(executable), enhanced_active, exe_hash in _known_enhanced_hashes(game_dir)) + executable_exists = bool(executable and (executable.exists() or core_status and core_status.get("gameDetected"))) + integrity = _executable_integrity_status(exe_hash, executable_exists, enhanced_active, exe_hash in _known_enhanced_hashes(game_dir)) profile = integrity["profile"] or read_exe_variant(game_dir) or "" if not executable: active_build_id = "Unknown" @@ -701,7 +848,7 @@ def _exe_swap_status(game_dir: str | None) -> dict[str, Any]: "enhancedBuildId": "Enhanced", "enhancedBuildDate": "", "executable": str(executable) if executable else "", - "executableName": executable.name if executable else "", + "executableName": str(core_status.get("executableName") or executable.name) if core_status and executable else executable.name if executable else "", "executableHash": exe_hash, "trustedExecutable": integrity["trusted"], "integrityStatus": integrity["status"], @@ -814,9 +961,9 @@ def _write_dxvk_conf(game_dir: str, settings: dict[str, Any], include_gpl_async_ write_log("Updated dxvk.conf from DXVK settings.", "Success", log_target) -def _dxvk_status(game_dir: str | None) -> dict[str, Any]: +def _dxvk_status(game_dir: str | None, core_status: dict[str, Any] | None = None) -> dict[str, Any]: return { - "installed": bool(game_dir and is_dxvk_async_installed(game_dir)), + "installed": bool(core_status.get("dxvkInstalled")) if core_status and "dxvkInstalled" in core_status else bool(game_dir and is_dxvk_async_installed(game_dir)), "confExists": bool(game_dir and (Path(game_dir) / "dxvk.conf").exists()), "settings": _read_dxvk_settings(game_dir), } @@ -1547,18 +1694,22 @@ def _open_external_url(url: str) -> None: def _current_state() -> dict[str, Any]: game_dir = _find_game_directory() + rust_status = _core_status(game_dir) + rust_config = _core_config_values(game_dir) content = _read_config(game_dir) - config_exists = bool(content) + config_exists = bool(rust_status.get("configExists")) if rust_status and "configExists" in rust_status else bool(content) log_path = get_log_file_path() qol = _qol_status(game_dir) current_launch_options = _current_launch_options() launch_profiles = _launch_profiles(current_launch_options) enhanced_summary = status_summary(game_dir, get_app_data_dir()) if game_dir else {"installed": False} + t7_status = _t7_status(game_dir, rust_status) + dxvk_status = _dxvk_status(game_dir, rust_status) return { "appVersion": APP_VERSION, "platform": platform.system(), "gameDir": game_dir, - "gameDetected": bool(game_dir), + "gameDetected": bool(rust_status.get("gameDetected")) if rust_status and "gameDetected" in rust_status else bool(game_dir), "configExists": config_exists, "steamUserId": find_steam_user_id(), "logPath": log_path, @@ -1568,39 +1719,39 @@ def _current_state() -> dict[str, Any]: "releaseChannel": _release_channel(), "launchProfiles": launch_profiles, "enhanced": _enhanced_status(game_dir), - "exeSwap": _exe_swap_status(game_dir), - "t7": _t7_status(game_dir), - "dxvk": _dxvk_status(game_dir), + "exeSwap": _exe_swap_status(game_dir, rust_status), + "t7": t7_status, + "dxvk": dxvk_status, "qol": qol, "graphics": { - "maxFps": _config_int(content, "MaxFPS", 165), - "fov": _config_int(content, "FOV", 80), - "displayMode": _config_int(content, "FullScreenMode", 1), - "resolution": _extract_config(content, "WindowSize", "1920x1080"), - "refreshRate": _config_float(content, "RefreshRate", 60), - "renderResolution": _config_int(content, "ResolutionPercent", 100), - "vsync": _config_bool(content, "Vsync", "1", "1"), - "drawFps": _config_bool(content, "DrawFPS", "1", "0"), + "maxFps": _config_int_value(content, rust_config, "MaxFPS", 165), + "fov": _config_int_value(content, rust_config, "FOV", 80), + "displayMode": _config_int_value(content, rust_config, "FullScreenMode", 1), + "resolution": _config_value(content, rust_config, "WindowSize", "1920x1080"), + "refreshRate": _config_float_value(content, rust_config, "RefreshRate", 60), + "renderResolution": _config_int_value(content, rust_config, "ResolutionPercent", 100), + "vsync": _config_bool_value(content, rust_config, "Vsync", "1", "1"), + "drawFps": _config_bool_value(content, rust_config, "DrawFPS", "1", "0"), }, "advanced": { - "smoothFramerate": _config_bool(content, "SmoothFramerate", "1", "0"), - "unlockOptions": _config_bool(content, "RestrictGraphicsOptions", "0", "1"), - "reduceCpu": _config_bool(content, "SerializeRender", "2", "0"), - "maxFrameLatency": _config_int(content, "MaxFrameLatency", 1), + "smoothFramerate": _config_bool_value(content, rust_config, "SmoothFramerate", "1", "0"), + "unlockOptions": _config_bool_value(content, rust_config, "RestrictGraphicsOptions", "0", "1"), + "reduceCpu": _config_bool_value(content, rust_config, "SerializeRender", "2", "0"), + "maxFrameLatency": _config_int_value(content, rust_config, "MaxFrameLatency", 1), "vramLimited": not ( - str(_extract_config(content, "VideoMemory", "1")) == "1" - and str(_extract_config(content, "StreamMinResident", "0")) == "0" + str(_config_value(content, rust_config, "VideoMemory", "1")) == "1" + and str(_config_value(content, rust_config, "StreamMinResident", "0")) == "0" ), - "vramTarget": int(_config_float(content, "VideoMemory", 0.75) * 100), - "configReadonly": _config_is_readonly(game_dir), + "vramTarget": int(_config_float_value(content, rust_config, "VideoMemory", 0.75) * 100), + "configReadonly": bool(rust_status.get("configReadonly")) if rust_status and "configReadonly" in rust_status else _config_is_readonly(game_dir), }, "maintenance": { "modFilesDir": str(MOD_FILES_DIR), "logPayload": _log_payload(), }, "mods": { - "t7Patch": bool(game_dir and ((Path(game_dir) / "t7patch.exe").exists() or (Path(game_dir) / "t7patch.dll").exists())), - "dxvk": bool(game_dir and is_dxvk_async_installed(game_dir)), + "t7Patch": bool(t7_status["installed"]), + "dxvk": bool(dxvk_status["installed"]), "enhanced": bool(enhanced_summary.get("installed")), }, "logs": log_bus.recent[-80:], diff --git a/backend/core_bridge.py b/backend/core_bridge.py new file mode 100644 index 0000000..d7cb90f --- /dev/null +++ b/backend/core_bridge.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + + +def _resolve_app_root() -> Path: + for env_name in ("PATCHOPSIII_RESOURCE_DIR", "PATCHOPSIII_APP_ROOT"): + value = os.environ.get(env_name, "").strip() + if value: + path = Path(value).resolve() + if path.exists(): + for candidate in (path, path / "_up_"): + if (candidate / "patchops-core").exists() or (candidate / "patchops-core.exe").exists(): + return candidate + try: + if any(candidate.glob("patchops-core*")): + return candidate + except OSError: + continue + return path + + if getattr(sys, "frozen", False): + executable = Path(sys.executable).resolve() + candidates = [executable.parent] + candidates.append(executable.parent / "_up_") + candidates.extend(executable.parents) + candidates.extend(parent / "_up_" for parent in executable.parents) + for candidate in candidates: + if (candidate / "patchops-core").exists() or (candidate / "patchops-core.exe").exists(): + return candidate + try: + if any(candidate.glob("patchops-core*")): + return candidate + except OSError: + continue + return executable.parents[1] + + return Path(__file__).resolve().parents[1] + + +APP_ROOT = _resolve_app_root() + + +class CoreBridgeError(RuntimeError): + pass + + +class CoreUnavailableError(CoreBridgeError): + pass + + +def _binary_name() -> str: + return "patchops-core.exe" if os.name == "nt" else "patchops-core" + + +def _candidate_paths() -> list[Path]: + name = _binary_name() + candidates: list[Path] = [] + + override = os.environ.get("PATCHOPSIII_CORE_BINARY", "").strip() + if override: + candidates.append(Path(override)) + + candidates.extend( + [ + APP_ROOT / "target" / "release" / name, + APP_ROOT / "target" / "debug" / name, + APP_ROOT / "crates" / "patchops-core" / "target" / "release" / name, + APP_ROOT / "crates" / "patchops-core" / "target" / "debug" / name, + APP_ROOT / "backend-bin" / name, + APP_ROOT / name, + Path(sys.executable).resolve().parent / name, + ] + ) + + for directory in (APP_ROOT / "src-tauri" / "binaries", Path(sys.executable).resolve().parent): + try: + candidates.extend(sorted(directory.glob(f"patchops-core*{'.exe' if os.name == 'nt' else ''}"))) + except OSError: + pass + + return candidates + + +def _resolve_core_binary() -> Path | None: + for candidate in _candidate_paths(): + try: + if candidate.is_file(): + return candidate + except OSError: + continue + return None + + +def core_available() -> bool: + return _resolve_core_binary() is not None + + +def _run_core(binary: Path, command: str, payload: dict[str, Any], timeout: int) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(binary), command], + input=json.dumps(payload), + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + + +def core_call(command: str, payload: dict[str, Any], timeout: int = 60) -> dict[str, Any]: + binary = _resolve_core_binary() + if not binary: + raise CoreUnavailableError("patchops-core binary was not found") + + try: + result = _run_core(binary, command, payload, timeout) + except subprocess.TimeoutExpired as exc: + raise CoreBridgeError(f"patchops-core {command} timed out after {timeout}s") from exc + except OSError as exc: + raise CoreBridgeError(f"failed to start patchops-core: {exc}") from exc + + if result.returncode != 0: + message = (result.stderr or result.stdout or f"patchops-core exited with code {result.returncode}").strip() + raise CoreBridgeError(message) + + try: + parsed = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise CoreBridgeError("patchops-core returned invalid JSON") from exc + + if not isinstance(parsed, dict): + raise CoreBridgeError("patchops-core returned a non-object JSON payload") + return parsed diff --git a/backend/test_api_status_core.py b/backend/test_api_status_core.py new file mode 100644 index 0000000..65b9411 --- /dev/null +++ b/backend/test_api_status_core.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from contextlib import ExitStack +from pathlib import Path +from unittest.mock import patch + +from backend import api + + +def _built_core_binary() -> Path | None: + extension = ".exe" if os.name == "nt" else "" + root = Path(__file__).resolve().parents[1] + for mode in ("release", "debug"): + candidate = root / "target" / mode / f"patchops-core{extension}" + if candidate.is_file(): + return candidate + return None + + +class StatusCoreIntegrationTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.game_dir = self.root / "Call of Duty Black Ops III" + self.players_dir = self.game_dir / "players" + self.players_dir.mkdir(parents=True) + self.exe = self.game_dir / "BlackOps3.exe" + self.exe.write_bytes(b"bo3") + self.config = self.players_dir / "config.ini" + self.config.write_text('MaxFPS = "144"\nFOV = "90"\nDrawFPS = "1"\n', encoding="utf-8") + self.settings_path = self.root / "settings.json" + self.settings_path.write_text(json.dumps({"game_dir": str(self.game_dir)}), encoding="utf-8") + self._reset_api_caches() + + def tearDown(self) -> None: + self._reset_api_caches() + self.tmp.cleanup() + + @staticmethod + def _reset_api_caches() -> None: + api._settings_cache = None + api._game_dir_cache = None + api._preset_names_cache = None + api._presets_cache = None + api._core_warning_logged = False + + def _status_patches(self, core_available: bool, core_call=None): + patches = [ + patch.object(api, "SETTINGS_PATH", self.settings_path), + patch.object(api, "core_available", return_value=core_available), + patch.object(api, "get_steam_library_paths", return_value=[]), + patch.object(api, "find_steam_user_id", return_value=None), + patch.object(api, "get_workshop_item_state", return_value={}), + patch.object(api, "_preset_names", return_value=[]), + patch.object(api, "_enhanced_status", return_value={"installed": False}), + patch.object(api, "status_summary", return_value={"installed": False}), + patch.object(api, "detect_enhanced_install", return_value=False), + patch.object(api, "read_exe_variant", return_value=""), + patch.object(api, "_validated_latest_build_backup_path", return_value=None), + patch.object(api, "_validated_preserved_compatible_exe", return_value=None), + patch.object(api, "_validated_enhanced_backup_path", return_value=None), + ] + if core_call is not None: + patches.append(patch.object(api, "core_call", side_effect=core_call)) + return patches + + def _current_state(self, core_available: bool, core_call=None) -> dict: + with ExitStack() as stack: + for item in self._status_patches(core_available, core_call): + stack.enter_context(item) + return api._current_state() + + def test_status_uses_python_fallback_when_core_is_unavailable(self) -> None: + state = self._current_state(core_available=False) + + self.assertTrue(state["gameDetected"]) + self.assertTrue(state["configExists"]) + self.assertEqual(state["gameDir"], str(self.game_dir)) + self.assertEqual(state["graphics"]["maxFps"], 144) + self.assertEqual(state["graphics"]["fov"], 90) + self.assertFalse(state["dxvk"]["installed"]) + + def test_status_merges_rust_core_status_and_config_when_available(self) -> None: + def fake_core_call(command: str, payload: dict, timeout: int = 60) -> dict: + if command == "status": + return { + "ok": True, + "gameDetected": True, + "configExists": True, + "configReadonly": True, + "executable": str(self.exe), + "executableName": self.exe.name, + "executableHash": "abc123", + "t7Installed": True, + "t7ConfigExists": True, + "dxvkInstalled": True, + } + if command == "read-config": + return { + "ok": True, + "configExists": True, + "path": str(self.config), + "values": { + "MaxFPS": "222", + "FOV": "111", + "DrawFPS": "0", + }, + } + if command == "scan-steam": + return {"ok": True, "gameDirs": []} + raise AssertionError(f"unexpected core command: {command}") + + state = self._current_state(core_available=True, core_call=fake_core_call) + + self.assertTrue(state["gameDetected"]) + self.assertTrue(state["configExists"]) + self.assertTrue(state["advanced"]["configReadonly"]) + self.assertEqual(state["graphics"]["maxFps"], 222) + self.assertEqual(state["graphics"]["fov"], 111) + self.assertFalse(state["graphics"]["drawFps"]) + self.assertTrue(state["t7"]["installed"]) + self.assertTrue(state["t7"]["confExists"]) + self.assertTrue(state["dxvk"]["installed"]) + self.assertEqual(state["exeSwap"]["executableName"], self.exe.name) + self.assertEqual(state["exeSwap"]["executableHash"], "abc123") + + def test_status_falls_back_to_python_when_rust_core_errors(self) -> None: + def failing_core_call(command: str, payload: dict, timeout: int = 60) -> dict: + raise api.CoreBridgeError(f"{command} failed") + + with patch.object(api, "write_log") as write_log: + state = self._current_state(core_available=True, core_call=failing_core_call) + + self.assertTrue(state["gameDetected"]) + self.assertTrue(state["configExists"]) + self.assertEqual(state["graphics"]["maxFps"], 144) + self.assertEqual(state["graphics"]["fov"], 90) + self.assertFalse(state["advanced"]["configReadonly"]) + self.assertEqual(state["exeSwap"]["executableName"], self.exe.name) + self.assertNotEqual(state["exeSwap"]["executableHash"], "abc123") + self.assertTrue(write_log.called) + warning_message = write_log.call_args.args[0] + self.assertIn("Rust core", warning_message) + self.assertIn("failed", warning_message) + + def test_core_warning_is_logged_once_across_repeated_failures(self) -> None: + def failing_core_call(command: str, payload: dict, timeout: int = 60) -> dict: + raise api.CoreBridgeError(f"{command} failed") + + with patch.object(api, "write_log") as write_log: + self._current_state(core_available=True, core_call=failing_core_call) + self._current_state(core_available=True, core_call=failing_core_call) + + self.assertEqual(write_log.call_count, 1) + + def test_status_uses_real_rust_core_when_binary_is_built(self) -> None: + core_binary = _built_core_binary() + if core_binary is None: + self.skipTest("patchops-core binary is not built") + + (self.game_dir / "t7patch.dll").write_bytes(b"t7") + (self.game_dir / "t7patch.conf").write_text("playername=PatchOps", encoding="utf-8") + (self.game_dir / "dxgi.dll").write_bytes(b"dxgi") + (self.game_dir / "d3d11.dll").write_bytes(b"d3d11") + + patches = [ + patch.object(api, "SETTINGS_PATH", self.settings_path), + patch.object(api, "get_steam_library_paths", return_value=[]), + patch.object(api, "find_steam_user_id", return_value=None), + patch.object(api, "get_workshop_item_state", return_value={}), + patch.object(api, "_preset_names", return_value=[]), + patch.object(api, "_enhanced_status", return_value={"installed": False}), + patch.object(api, "status_summary", return_value={"installed": False}), + patch.object(api, "detect_enhanced_install", return_value=False), + patch.object(api, "read_exe_variant", return_value=""), + patch.object(api, "_validated_latest_build_backup_path", return_value=None), + patch.object(api, "_validated_preserved_compatible_exe", return_value=None), + patch.object(api, "_validated_enhanced_backup_path", return_value=None), + patch.object(api, "_read_config", return_value=""), + patch.object(api, "is_t7_patch_installed", return_value=False), + patch.object(api, "is_dxvk_async_installed", return_value=False), + ] + + with patch.dict(os.environ, {"PATCHOPSIII_CORE_BINARY": str(core_binary)}): + with ExitStack() as stack: + for item in patches: + stack.enter_context(item) + state = api._current_state() + + self.assertTrue(state["gameDetected"]) + self.assertTrue(state["configExists"]) + self.assertEqual(state["graphics"]["maxFps"], 144) + self.assertEqual(state["graphics"]["fov"], 90) + self.assertTrue(state["t7"]["installed"]) + self.assertTrue(state["t7"]["confExists"]) + self.assertTrue(state["dxvk"]["installed"]) + + +class CorsOriginsTests(unittest.TestCase): + def test_cors_origins_include_all_dev_renderers(self) -> None: + origins = set(api._cors_origins()) + + self.assertIn("http://127.0.0.1:5173", origins) + self.assertIn("http://127.0.0.1:5174", origins) + self.assertIn("http://127.0.0.1:5175", origins) + self.assertIn("http://tauri.localhost", origins) + self.assertIn("tauri://localhost", origins) + self.assertIn("app://patchopsiii", origins) + + +class SettingsPathTests(unittest.TestCase): + def setUp(self) -> None: + api._settings_cache = None + api._game_dir_cache = None + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.settings_path = self.root / "patchops-settings.json" + self.legacy_settings_path = self.root / "electron-settings.json" + + def tearDown(self) -> None: + api._settings_cache = None + api._game_dir_cache = None + self.tmp.cleanup() + + def test_load_settings_reads_legacy_file_when_neutral_file_is_missing(self) -> None: + self.legacy_settings_path.write_text(json.dumps({"game_dir": "legacy"}), encoding="utf-8") + + with patch.object(api, "SETTINGS_PATH", self.settings_path): + with patch.object(api, "LEGACY_SETTINGS_PATH", self.legacy_settings_path): + self.assertEqual(api._settings_read_path(), self.legacy_settings_path) + self.assertEqual(api._load_settings()["game_dir"], "legacy") + + def test_load_settings_prefers_neutral_file_over_legacy_file(self) -> None: + self.settings_path.write_text(json.dumps({"game_dir": "neutral"}), encoding="utf-8") + self.legacy_settings_path.write_text(json.dumps({"game_dir": "legacy"}), encoding="utf-8") + + with patch.object(api, "SETTINGS_PATH", self.settings_path): + with patch.object(api, "LEGACY_SETTINGS_PATH", self.legacy_settings_path): + self.assertEqual(api._settings_read_path(), self.settings_path) + self.assertEqual(api._load_settings()["game_dir"], "neutral") + + def test_settings_cache_tracks_source_path_when_neutral_file_appears(self) -> None: + self.legacy_settings_path.write_text(json.dumps({"game_dir": "legacy"}), encoding="utf-8") + + with patch.object(api, "SETTINGS_PATH", self.settings_path): + with patch.object(api, "LEGACY_SETTINGS_PATH", self.legacy_settings_path): + self.assertEqual(api._load_settings()["game_dir"], "legacy") + + self.settings_path.write_text(json.dumps({"game_dir": "neutral"}), encoding="utf-8") + legacy_mtime = api._file_mtime(self.legacy_settings_path) + if legacy_mtime is not None: + os.utime(self.settings_path, (legacy_mtime, legacy_mtime)) + + self.assertEqual(api._settings_read_path(), self.settings_path) + self.assertEqual(api._load_settings()["game_dir"], "neutral") + + def test_game_directory_cache_tracks_source_path_when_neutral_file_appears(self) -> None: + legacy_game = self.root / "legacy-game" + neutral_game = self.root / "neutral-game" + legacy_game.mkdir() + neutral_game.mkdir() + (legacy_game / "BlackOps3.exe").write_text("", encoding="utf-8") + (neutral_game / "BlackOps3.exe").write_text("", encoding="utf-8") + self.legacy_settings_path.write_text(json.dumps({"game_dir": str(legacy_game)}), encoding="utf-8") + + with patch.object(api, "SETTINGS_PATH", self.settings_path): + with patch.object(api, "LEGACY_SETTINGS_PATH", self.legacy_settings_path): + with patch.object(api, "get_steam_library_paths", return_value=[]): + with patch.object(api, "core_available", return_value=False): + self.assertEqual(api._find_game_directory(), str(legacy_game)) + + self.settings_path.write_text(json.dumps({"game_dir": str(neutral_game)}), encoding="utf-8") + legacy_mtime = api._file_mtime(self.legacy_settings_path) + if legacy_mtime is not None: + os.utime(self.settings_path, (legacy_mtime, legacy_mtime)) + + self.assertEqual(api._find_game_directory(), str(neutral_game)) + + def test_save_settings_writes_neutral_file_without_rewriting_legacy_file(self) -> None: + self.legacy_settings_path.write_text(json.dumps({"game_dir": "legacy"}), encoding="utf-8") + + with patch.object(api, "SETTINGS_PATH", self.settings_path): + with patch.object(api, "LEGACY_SETTINGS_PATH", self.legacy_settings_path): + api._save_settings({"game_dir": "neutral"}) + + self.assertEqual(json.loads(self.settings_path.read_text(encoding="utf-8"))["game_dir"], "neutral") + self.assertEqual(json.loads(self.legacy_settings_path.read_text(encoding="utf-8"))["game_dir"], "legacy") + + +class AppRootTests(unittest.TestCase): + def test_app_root_can_come_from_resource_dir_env(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with patch.dict(os.environ, {"PATCHOPSIII_RESOURCE_DIR": tmp}): + self.assertEqual(api._resolve_app_root(), Path(tmp).resolve()) + + def test_app_root_resource_env_can_point_at_tauri_parent(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + parent = Path(tmp) + resource_dir = parent / "_up_" + resource_dir.mkdir() + (resource_dir / "presets.json").write_text("{}", encoding="utf-8") + + with patch.dict(os.environ, {"PATCHOPSIII_RESOURCE_DIR": str(parent)}): + self.assertEqual(api._resolve_app_root(), resource_dir.resolve()) + + def test_frozen_app_root_can_resolve_tauri_up_resource_dir(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + release_dir = Path(tmp) / "target" / "release" + resource_dir = release_dir / "_up_" + resource_dir.mkdir(parents=True) + backend_exe = release_dir / "patchops-backend.exe" + backend_exe.write_text("", encoding="utf-8") + (resource_dir / "presets.json").write_text("{}", encoding="utf-8") + + with patch.dict(os.environ, {}, clear=True): + with patch.object(api.sys, "frozen", True, create=True): + with patch.object(api.sys, "executable", str(backend_exe)): + self.assertEqual(api._resolve_app_root(), resource_dir.resolve()) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/test_core_bridge.py b/backend/test_core_bridge.py new file mode 100644 index 0000000..9e2aaf0 --- /dev/null +++ b/backend/test_core_bridge.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import hashlib +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from backend import core_bridge + + +class CoreBridgeTests(unittest.TestCase): + def test_core_available_false_when_binary_missing(self) -> None: + with patch.object(core_bridge, "_resolve_core_binary", return_value=None): + self.assertFalse(core_bridge.core_available()) + + def test_core_call_raises_when_binary_missing(self) -> None: + with patch.object(core_bridge, "_resolve_core_binary", return_value=None): + with self.assertRaises(core_bridge.CoreUnavailableError): + core_bridge.core_call("status", {"gameDir": "missing"}) + + def test_core_call_raises_on_non_zero_exit(self) -> None: + result = subprocess.CompletedProcess(["patchops-core"], 1, "", "boom") + with patch.object(core_bridge, "_resolve_core_binary", return_value=Path("patchops-core")): + with patch.object(core_bridge, "_run_core", return_value=result): + with self.assertRaisesRegex(core_bridge.CoreBridgeError, "boom"): + core_bridge.core_call("status", {"gameDir": "x"}) + + def test_core_call_raises_on_invalid_json(self) -> None: + result = subprocess.CompletedProcess(["patchops-core"], 0, "not-json", "") + with patch.object(core_bridge, "_resolve_core_binary", return_value=Path("patchops-core")): + with patch.object(core_bridge, "_run_core", return_value=result): + with self.assertRaisesRegex(core_bridge.CoreBridgeError, "invalid JSON"): + core_bridge.core_call("status", {"gameDir": "x"}) + + def test_core_call_hash_uses_real_binary_when_available(self) -> None: + if core_bridge._resolve_core_binary() is None: + self.skipTest("patchops-core binary is not built") + + with tempfile.TemporaryDirectory() as tmp: + sample = Path(tmp) / "sample.bin" + content = b"patchops-core-bridge" + sample.write_bytes(content) + + response = core_bridge.core_call("hash", {"path": str(sample)}, timeout=10) + + self.assertTrue(response["ok"]) + self.assertEqual(response["sha256"], hashlib.sha256(content).hexdigest()) + + def test_app_root_can_come_from_resource_dir_env(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with patch.dict(os.environ, {"PATCHOPSIII_RESOURCE_DIR": tmp}): + self.assertEqual(core_bridge._resolve_app_root(), Path(tmp).resolve()) + + def test_app_root_resource_env_can_point_at_tauri_parent(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + parent = Path(tmp) + resource_dir = parent / "_up_" + resource_dir.mkdir() + (resource_dir / core_bridge._binary_name()).write_text("", encoding="utf-8") + + with patch.dict(os.environ, {"PATCHOPSIII_RESOURCE_DIR": str(parent)}): + self.assertEqual(core_bridge._resolve_app_root(), resource_dir.resolve()) + + def test_frozen_app_root_can_resolve_tauri_sidecar_parent(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + release_dir = Path(tmp) / "target" / "release" + release_dir.mkdir(parents=True) + backend_exe = release_dir / "patchops-backend.exe" + backend_exe.write_text("", encoding="utf-8") + (release_dir / core_bridge._binary_name()).write_text("", encoding="utf-8") + + with patch.dict(os.environ, {}, clear=True): + with patch.object(core_bridge.sys, "frozen", True, create=True): + with patch.object(core_bridge.sys, "executable", str(backend_exe)): + self.assertEqual(core_bridge._resolve_app_root(), release_dir.resolve()) + + +if __name__ == "__main__": + unittest.main() diff --git a/bun.lock b/bun.lock index 823bde4..2886cc3 100644 --- a/bun.lock +++ b/bun.lock @@ -3,9 +3,13 @@ "configVersion": 1, "workspaces": { "": { - "name": "patchopsiii-electron", + "name": "patchopsiii-desktop", + "dependencies": { + "@tauri-apps/api": "^2.0.0", + }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@tauri-apps/cli": "^2.0.0", "@types/bun": "^1.1.0", "@types/node": "^22.0.0", "@types/react": "^18.3.0", @@ -229,6 +233,32 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], + "@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], + + "@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="], + + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w=="], + + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg=="], + + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.2", "", { "os": "linux", "cpu": "arm" }, "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA=="], + + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw=="], + + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw=="], + + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.2", "", { "os": "linux", "cpu": "none" }, "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ=="], + + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw=="], + + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw=="], + + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA=="], + + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA=="], + + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], diff --git a/crates/patchops-core/Cargo.toml b/crates/patchops-core/Cargo.toml new file mode 100644 index 0000000..745d354 --- /dev/null +++ b/crates/patchops-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "patchops-core" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "patchops-core" +path = "src/main.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/patchops-core/src/commands.rs b/crates/patchops-core/src/commands.rs new file mode 100644 index 0000000..8e09ded --- /dev/null +++ b/crates/patchops-core/src/commands.rs @@ -0,0 +1,51 @@ +use std::path::PathBuf; + +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::{ + config_reader::read_config, + error::{CoreError, Result}, + file_integrity::hash_path, + game_scan::status, + steam_detect::scan_steam, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GameDirPayload { + game_dir: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct HashPayload { + path: PathBuf, +} + +fn parse_payload Deserialize<'de>>(payload: Value) -> Result { + serde_json::from_value(payload).map_err(CoreError::from) +} + +pub fn handle_command(command: &str, payload: Value) -> Result { + let output = match command { + "status" => { + let payload: GameDirPayload = parse_payload(payload)?; + serde_json::to_value(status(&payload.game_dir)?)? + } + "hash" => { + let payload: HashPayload = parse_payload(payload)?; + serde_json::to_value(hash_path(&payload.path)?)? + } + "scan-steam" => serde_json::to_value(scan_steam())?, + "read-config" => { + let payload: GameDirPayload = parse_payload(payload)?; + serde_json::to_value(read_config(&payload.game_dir)?)? + } + other => return Err(CoreError::UnknownCommand(other.to_string())), + }; + + Ok(match output { + Value::Object(_) => output, + _ => json!({ "ok": true, "value": output }), + }) +} diff --git a/crates/patchops-core/src/config_reader.rs b/crates/patchops-core/src/config_reader.rs new file mode 100644 index 0000000..5f8f1e7 --- /dev/null +++ b/crates/patchops-core/src/config_reader.rs @@ -0,0 +1,169 @@ +use std::{collections::BTreeMap, fs, path::Path}; + +use serde::Serialize; + +use crate::error::Result; + +pub const CONFIG_KEYS: [&str; 14] = [ + "MaxFPS", + "FOV", + "FullScreenMode", + "WindowSize", + "RefreshRate", + "ResolutionPercent", + "Vsync", + "DrawFPS", + "SmoothFramerate", + "RestrictGraphicsOptions", + "SerializeRender", + "MaxFrameLatency", + "VideoMemory", + "StreamMinResident", +]; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigOutput { + pub ok: bool, + pub config_exists: bool, + pub path: String, + pub values: BTreeMap, +} + +pub fn parse_config(content: &str) -> BTreeMap { + let wanted: std::collections::BTreeSet<&str> = CONFIG_KEYS.into_iter().collect(); + let mut values = BTreeMap::new(); + + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with("//") || !line.contains('=') { + continue; + } + let (key, raw_value) = match line.split_once('=') { + Some(parts) => (parts.0.trim(), parts.1.trim()), + None => continue, + }; + if !wanted.contains(key) { + continue; + } + + let without_comment = raw_value.split("//").next().unwrap_or(raw_value).trim(); + let value = without_comment + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(without_comment) + .trim() + .to_string(); + values.insert(key.to_string(), value); + } + + values +} + +pub fn read_config(game_dir: &Path) -> Result { + let path = game_dir.join("players").join("config.ini"); + if !path.is_file() { + return Ok(ConfigOutput { + ok: true, + config_exists: false, + path: path.to_string_lossy().into_owned(), + values: BTreeMap::new(), + }); + } + + let content = fs::read_to_string(&path)?; + Ok(ConfigOutput { + ok: true, + config_exists: true, + path: path.to_string_lossy().into_owned(), + values: parse_config(&content), + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{parse_config, read_config}; + + #[test] + fn parses_config_values_and_ignores_comments() { + let values = parse_config( + r#" + // ignored + MaxFPS = "165" // Maximum FPS cap + WindowSize = "2560x1440" + VideoMemory = "0.85" + Unknown = "ignored" + "#, + ); + + assert_eq!(values.get("MaxFPS").unwrap(), "165"); + assert_eq!(values.get("WindowSize").unwrap(), "2560x1440"); + assert_eq!(values.get("VideoMemory").unwrap(), "0.85"); + assert!(!values.contains_key("Unknown")); + } + + #[test] + fn parses_config_edge_cases() { + let values = parse_config( + r#" + MaxFPS=240 + FOV = "95" + FullScreenMode = "0" // windowed + DrawFPS = "0" // comment with = sign + UnknownKey = "ignored" + SmoothFramerate = "1" + SmoothFramerate = "0" + malformed line + "#, + ); + + assert_eq!(values.get("MaxFPS").unwrap(), "240"); + assert_eq!(values.get("FOV").unwrap(), "95"); + assert_eq!(values.get("FullScreenMode").unwrap(), "0"); + assert_eq!(values.get("DrawFPS").unwrap(), "0"); + assert_eq!(values.get("SmoothFramerate").unwrap(), "0"); + assert!(!values.contains_key("UnknownKey")); + } + + #[test] + fn parses_all_requested_config_keys() { + let config = super::CONFIG_KEYS + .iter() + .enumerate() + .map(|(index, key)| format!(r#"{key} = "{index}""#)) + .collect::>() + .join("\n"); + let values = parse_config(&config); + + for (index, key) in super::CONFIG_KEYS.iter().enumerate() { + assert_eq!(values.get(*key).unwrap(), &index.to_string()); + } + } + + #[test] + fn missing_config_file_returns_empty_values() { + let dir = tempdir().unwrap(); + let output = read_config(dir.path()).unwrap(); + + assert!(output.ok); + assert!(!output.config_exists); + assert!(output.values.is_empty()); + } + + #[test] + fn reads_existing_config_file() { + let dir = tempdir().unwrap(); + let players = dir.path().join("players"); + fs::create_dir_all(&players).unwrap(); + fs::write(players.join("config.ini"), "FOV = \"90\"\nDrawFPS = \"1\"").unwrap(); + + let output = read_config(dir.path()).unwrap(); + assert!(output.config_exists); + assert_eq!(output.values.get("FOV").unwrap(), "90"); + assert_eq!(output.values.get("DrawFPS").unwrap(), "1"); + } +} diff --git a/crates/patchops-core/src/error.rs b/crates/patchops-core/src/error.rs new file mode 100644 index 0000000..15b81cf --- /dev/null +++ b/crates/patchops-core/src/error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CoreError { + #[error("invalid input: {0}")] + InvalidInput(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("unknown command: {0}")] + UnknownCommand(String), +} + +pub type Result = std::result::Result; diff --git a/crates/patchops-core/src/file_integrity.rs b/crates/patchops-core/src/file_integrity.rs new file mode 100644 index 0000000..6656a10 --- /dev/null +++ b/crates/patchops-core/src/file_integrity.rs @@ -0,0 +1,59 @@ +use std::{fs::File, io::Read, path::Path}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::error::Result; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HashOutput { + pub ok: bool, + pub path: String, + pub sha256: String, +} + +pub fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + + Ok(format!("{:x}", hasher.finalize())) +} + +pub fn hash_path(path: &Path) -> Result { + Ok(HashOutput { + ok: true, + path: path.to_string_lossy().into_owned(), + sha256: sha256_file(path)?, + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::sha256_file; + + #[test] + fn hashes_file_with_sha256() { + let dir = tempdir().unwrap(); + let file = dir.path().join("sample.bin"); + fs::write(&file, b"patchops").unwrap(); + + assert_eq!( + sha256_file(&file).unwrap(), + "3e0cec93c1876296af254daff5dba161649c003683320e5fefbaf1ad21e67f98" + ); + } +} diff --git a/crates/patchops-core/src/game_scan.rs b/crates/patchops-core/src/game_scan.rs new file mode 100644 index 0000000..fb4f057 --- /dev/null +++ b/crates/patchops-core/src/game_scan.rs @@ -0,0 +1,147 @@ +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::{error::Result, file_integrity::sha256_file}; + +pub const GAME_EXECUTABLE_NAMES: [&str; 2] = ["BlackOpsIII.exe", "BlackOps3.exe"]; +const T7_INSTALL_MARKERS: [&str; 2] = ["t7patch.dll", "t7patchloader.dll"]; +const T7_CONFIG: &str = "t7patch.conf"; +const DXVK_MARKERS: [&str; 2] = ["dxgi.dll", "d3d11.dll"]; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StatusOutput { + pub ok: bool, + pub game_detected: bool, + pub config_exists: bool, + pub config_readonly: bool, + pub executable: String, + pub executable_name: String, + pub executable_hash: String, + pub t7_installed: bool, + pub t7_config_exists: bool, + pub dxvk_installed: bool, +} + +pub fn find_executable(game_dir: &Path) -> Option { + GAME_EXECUTABLE_NAMES + .iter() + .map(|name| game_dir.join(name)) + .find(|candidate| candidate.is_file()) +} + +fn config_readonly(path: &Path) -> bool { + path.metadata() + .map(|metadata| metadata.permissions().readonly()) + .unwrap_or(false) +} + +pub fn status(game_dir: &Path) -> Result { + let executable = find_executable(game_dir); + let config = game_dir.join("players").join("config.ini"); + let executable_hash = match executable.as_deref() { + Some(path) => sha256_file(path)?, + None => String::new(), + }; + + Ok(StatusOutput { + ok: true, + game_detected: executable.is_some(), + config_exists: config.is_file(), + config_readonly: config.is_file() && config_readonly(&config), + executable: executable + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(), + executable_name: executable + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(), + executable_hash, + t7_installed: T7_INSTALL_MARKERS + .iter() + .any(|marker| game_dir.join(marker).is_file()), + t7_config_exists: game_dir.join(T7_CONFIG).is_file(), + dxvk_installed: DXVK_MARKERS + .iter() + .all(|marker| game_dir.join(marker).is_file()), + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{find_executable, status}; + + #[test] + fn detects_supported_executable_names_in_order() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("BlackOps3.exe"), b"old").unwrap(); + fs::write(dir.path().join("BlackOpsIII.exe"), b"new").unwrap(); + + let executable = find_executable(dir.path()).unwrap(); + assert_eq!(executable.file_name().unwrap(), "BlackOpsIII.exe"); + } + + #[test] + fn missing_config_is_structured_status() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("BlackOps3.exe"), b"exe").unwrap(); + + let output = status(dir.path()).unwrap(); + assert!(output.game_detected); + assert!(!output.config_exists); + assert!(!output.config_readonly); + } + + #[test] + fn missing_game_dir_is_structured_status() { + let dir = tempdir().unwrap(); + let missing = dir.path().join("missing-game"); + + let output = status(&missing).unwrap(); + assert!(output.ok); + assert!(!output.game_detected); + assert!(!output.config_exists); + assert!(!output.config_readonly); + assert!(output.executable.is_empty()); + assert!(output.executable_name.is_empty()); + assert!(output.executable_hash.is_empty()); + assert!(!output.t7_installed); + assert!(!output.t7_config_exists); + assert!(!output.dxvk_installed); + } + + #[test] + fn detects_t7_install_markers_without_treating_config_as_installed() { + let dir = tempdir().unwrap(); + + fs::write(dir.path().join("t7patch.conf"), b"playername=PatchOps").unwrap(); + let config_only = status(dir.path()).unwrap(); + assert!(!config_only.t7_installed); + assert!(config_only.t7_config_exists); + + fs::write(dir.path().join("t7patch.dll"), b"dll").unwrap(); + let with_dll = status(dir.path()).unwrap(); + assert!(with_dll.t7_installed); + assert!(with_dll.t7_config_exists); + } + + #[test] + fn dxvk_requires_all_python_recognized_files() { + let dir = tempdir().unwrap(); + + fs::write(dir.path().join("dxgi.dll"), b"dxgi").unwrap(); + let partial = status(dir.path()).unwrap(); + assert!(!partial.dxvk_installed); + + fs::write(dir.path().join("d3d11.dll"), b"d3d11").unwrap(); + let complete = status(dir.path()).unwrap(); + assert!(complete.dxvk_installed); + } +} diff --git a/crates/patchops-core/src/lib.rs b/crates/patchops-core/src/lib.rs new file mode 100644 index 0000000..7edd565 --- /dev/null +++ b/crates/patchops-core/src/lib.rs @@ -0,0 +1,8 @@ +pub mod commands; +pub mod config_reader; +pub mod error; +pub mod file_integrity; +pub mod game_scan; +pub mod steam_detect; + +pub use commands::handle_command; diff --git a/crates/patchops-core/src/main.rs b/crates/patchops-core/src/main.rs new file mode 100644 index 0000000..e39b590 --- /dev/null +++ b/crates/patchops-core/src/main.rs @@ -0,0 +1,42 @@ +use std::{env, io::Read, process}; + +use serde::Deserialize; +use serde_json::Value; + +use patchops_core::handle_command; + +#[derive(Debug, Deserialize)] +struct Envelope { + command: String, + #[serde(default)] + payload: Value, +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let mut stdin = String::new(); + std::io::stdin().read_to_string(&mut stdin)?; + let input = if stdin.trim().is_empty() { + "{}" + } else { + stdin.trim() + }; + + let args: Vec = env::args().collect(); + let (command, payload) = if let Some(command) = args.get(1) { + (command.clone(), serde_json::from_str(input)?) + } else { + let envelope: Envelope = serde_json::from_str(input)?; + (envelope.command, envelope.payload) + }; + + let output = handle_command(&command, payload)?; + println!("{}", serde_json::to_string(&output)?); + Ok(()) +} diff --git a/crates/patchops-core/src/steam_detect.rs b/crates/patchops-core/src/steam_detect.rs new file mode 100644 index 0000000..a991b00 --- /dev/null +++ b/crates/patchops-core/src/steam_detect.rs @@ -0,0 +1,140 @@ +use std::{collections::BTreeSet, env, fs, path::PathBuf}; + +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SteamScanOutput { + pub ok: bool, + pub steam_roots: Vec, + pub library_paths: Vec, + pub game_dirs: Vec, +} + +pub fn scan_steam() -> SteamScanOutput { + let roots = steam_roots(); + let mut libraries = BTreeSet::new(); + let mut game_dirs = BTreeSet::new(); + + for root in &roots { + if root.is_dir() { + libraries.insert(root.to_path_buf()); + } + let vdf = root.join("steamapps").join("libraryfolders.vdf"); + for library in parse_libraryfolders(&fs::read_to_string(vdf).unwrap_or_default()) { + libraries.insert(library); + } + } + + for library in &libraries { + let game_dir = library + .join("steamapps") + .join("common") + .join("Call of Duty Black Ops III"); + if game_dir.is_dir() { + game_dirs.insert(game_dir); + } + } + + SteamScanOutput { + ok: true, + steam_roots: roots.into_iter().map(display_path).collect(), + library_paths: libraries.into_iter().map(display_path).collect(), + game_dirs: game_dirs.into_iter().map(display_path).collect(), + } +} + +fn steam_roots() -> Vec { + let mut roots = Vec::new(); + + if cfg!(target_os = "windows") { + if let Ok(program_files_x86) = env::var("PROGRAMFILES(X86)") { + roots.push(PathBuf::from(program_files_x86).join("Steam")); + } + if let Ok(program_files) = env::var("PROGRAMFILES") { + roots.push(PathBuf::from(program_files).join("Steam")); + } + } else if cfg!(target_os = "macos") { + if let Some(home) = home_dir() { + roots.push( + home.join("Library") + .join("Application Support") + .join("Steam"), + ); + } + } else if let Some(home) = home_dir() { + roots.push(home.join(".steam").join("steam")); + roots.push(home.join(".local").join("share").join("Steam")); + } + + dedupe_paths(roots) +} + +fn home_dir() -> Option { + env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from)) +} + +pub fn parse_libraryfolders(content: &str) -> Vec { + let mut paths = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('"') { + continue; + } + let parts: Vec<&str> = trimmed.split('"').collect(); + if parts.len() < 4 || parts[1] != "path" { + continue; + } + let path = parts[3].replace("\\\\", "\\"); + if !path.trim().is_empty() { + paths.push(PathBuf::from(path)); + } + } + dedupe_paths(paths) +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut seen = BTreeSet::new(); + let mut output = Vec::new(); + for path in paths { + let key = path.to_string_lossy().to_ascii_lowercase(); + if seen.insert(key) { + output.push(path); + } + } + output +} + +fn display_path(path: PathBuf) -> String { + path.to_string_lossy().into_owned() +} + +#[cfg(test)] +mod tests { + use super::parse_libraryfolders; + + #[test] + fn parses_steam_libraryfolders_paths() { + let paths = parse_libraryfolders( + r#" + "libraryfolders" + { + "0" + { + "path" "C:\\Program Files (x86)\\Steam" + } + "1" + { + "path" "D:\\SteamLibrary" + } + } + "#, + ); + + assert_eq!(paths.len(), 2); + assert!(paths[0].to_string_lossy().contains("Steam")); + assert!(paths[1].to_string_lossy().contains("SteamLibrary")); + } +} diff --git a/crates/patchops-core/tests/cli.rs b/crates/patchops-core/tests/cli.rs new file mode 100644 index 0000000..4989bbd --- /dev/null +++ b/crates/patchops-core/tests/cli.rs @@ -0,0 +1,120 @@ +use std::{ + io::Write, + path::PathBuf, + process::{Command, Output, Stdio}, +}; + +use serde_json::{json, Value}; +use tempfile::tempdir; + +fn core_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_patchops-core")) +} + +fn run_core(args: &[&str], input: Value) -> Output { + let mut child = Command::new(core_bin()) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn patchops-core"); + + child + .stdin + .as_mut() + .expect("patchops-core stdin") + .write_all(input.to_string().as_bytes()) + .expect("write JSON stdin"); + + child.wait_with_output().expect("wait for patchops-core") +} + +fn stdout_json(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).expect("patchops-core stdout JSON") +} + +#[test] +fn hash_command_reads_json_stdin_and_writes_json_stdout() { + let dir = tempdir().unwrap(); + let file = dir.path().join("sample.bin"); + std::fs::write(&file, b"patchops").unwrap(); + + let output = run_core(&["hash"], json!({ "path": file.to_string_lossy() })); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); + let json = stdout_json(&output); + assert_eq!(json["ok"], true); + assert_eq!( + json["sha256"], + "3e0cec93c1876296af254daff5dba161649c003683320e5fefbaf1ad21e67f98" + ); +} + +#[test] +fn status_command_reads_json_stdin_and_writes_structured_status() { + let dir = tempdir().unwrap(); + let players = dir.path().join("players"); + std::fs::create_dir_all(&players).unwrap(); + std::fs::write(dir.path().join("BlackOps3.exe"), b"exe").unwrap(); + std::fs::write(players.join("config.ini"), b"MaxFPS = \"165\"").unwrap(); + + let output = run_core( + &["status"], + json!({ "gameDir": dir.path().to_string_lossy() }), + ); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); + let json = stdout_json(&output); + assert_eq!(json["ok"], true); + assert_eq!(json["gameDetected"], true); + assert_eq!(json["configExists"], true); + assert_eq!(json["executableName"], "BlackOps3.exe"); + assert_eq!(json["t7ConfigExists"], false); + assert_eq!( + json["executableHash"], + "9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b" + ); +} + +#[test] +fn envelope_mode_supports_command_and_payload_on_stdin() { + let dir = tempdir().unwrap(); + let file = dir.path().join("sample.bin"); + std::fs::write(&file, b"patchops").unwrap(); + + let output = run_core( + &[], + json!({ + "command": "hash", + "payload": { "path": file.to_string_lossy() } + }), + ); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); + assert_eq!(stdout_json(&output)["ok"], true); +} + +#[test] +fn unknown_command_fails_with_stderr_and_no_stdout() { + let output = run_core(&["unknown"], json!({})); + + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("unknown command: unknown")); +} diff --git a/package.json b/package.json index 4a2c30a..606c9cd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "patchopsiii-electron", + "name": "patchopsiii-desktop", "version": "v1.3.0-beta3", - "description": "Electron control center for Call of Duty: Black Ops III maintenance, mod setup, and performance tuning.", + "description": "Desktop control center for Call of Duty: Black Ops III maintenance, mod setup, and performance tuning.", "author": "boggedbrush", "private": true, "type": "module", @@ -9,14 +9,24 @@ "scripts": { "dev": "bun scripts/dev-server.ts", "dev:desktop": "bun run build:main && bun scripts/dev.ts", + "dev:tauri": "bun scripts/dev-tauri.ts", "dev:cleanup": "bun scripts/dev-cleanup.ts", "build": "bun run typecheck && bun run build:main && vite build", "build:main": "bun scripts/clean-main.ts && tsc -p tsconfig.electron.json", + "build:core": "cargo build -p patchops-core --release", + "check:tauri-version": "bun scripts/check-tauri-version.ts", + "clean:tauri-bundles": "bun scripts/clean-tauri-bundles.ts", "dist": "bun run build && bun x electron-builder@26.8.1", + "prepare:tauri-sidecars": "bun scripts/prepare-tauri-sidecars.ts", + "verify:tauri-sidecars": "bun scripts/verify-tauri-sidecars.ts", "build:backend:linux": "bun scripts/check-backend-build.ts linux && .venv/bin/python -m PyInstaller --onefile --name patchops-backend --distpath dist/backend --workpath build/pyinstaller --specpath build/pyinstaller --hidden-import uvicorn.logging --hidden-import uvicorn.loops --hidden-import uvicorn.loops.auto --hidden-import uvicorn.protocols --hidden-import uvicorn.protocols.http --hidden-import uvicorn.protocols.http.auto --hidden-import uvicorn.protocols.websockets --hidden-import uvicorn.protocols.websockets.auto --hidden-import uvicorn.lifespan --hidden-import uvicorn.lifespan.on backend/api.py", "build:backend:win": "bun scripts/check-backend-build.ts win && .venv/Scripts/python.exe -m PyInstaller --onefile --name patchops-backend --distpath dist/backend --workpath build/pyinstaller --specpath build/pyinstaller --hidden-import uvicorn.logging --hidden-import uvicorn.loops --hidden-import uvicorn.loops.auto --hidden-import uvicorn.protocols --hidden-import uvicorn.protocols.http --hidden-import uvicorn.protocols.http.auto --hidden-import uvicorn.protocols.websockets --hidden-import uvicorn.protocols.websockets.auto --hidden-import uvicorn.lifespan --hidden-import uvicorn.lifespan.on backend/api.py", "dist:linux": "bun run build && bun run build:backend:linux && bun x electron-builder@26.8.1 --linux AppImage --publish never", "dist:win": "bun run build && bun run build:backend:win && bun x electron-builder@26.8.1 --win --publish never --config.win.signAndEditExecutable=false", + "dist:tauri": "bun run check:tauri-version && bun run build && bun run build:core && bun run prepare:tauri-sidecars && bun run verify:tauri-sidecars && bun run clean:tauri-bundles && bun x tauri build", + "dist:tauri:linux": "bun run check:tauri-version && bun run build && bun run build:backend:linux && bun run build:core && bun run prepare:tauri-sidecars && bun run verify:tauri-sidecars && bun run clean:tauri-bundles && bun x tauri build --bundles appimage", + "dist:tauri:win": "bun run check:tauri-version && bun run build && bun run build:backend:win && bun run build:core && bun run prepare:tauri-sidecars && bun run verify:tauri-sidecars && bun run clean:tauri-bundles && bun x tauri build --bundles msi", + "test:renderer": "bun test src/renderer/**/*.test.ts", "typecheck": "tsc --noEmit", "start": "bun run build:main && electron ." }, @@ -96,8 +106,11 @@ "artifactName": "PatchOpsIII.AppImage" } }, - "dependencies": {}, + "dependencies": { + "@tauri-apps/api": "^2.0.0" + }, "devDependencies": { + "@tauri-apps/cli": "^2.0.0", "@tailwindcss/vite": "^4.0.0", "@vitejs/plugin-react": "^5.0.0", "@types/bun": "^1.1.0", diff --git a/scripts/check-tauri-version.ts b/scripts/check-tauri-version.ts new file mode 100644 index 0000000..c343896 --- /dev/null +++ b/scripts/check-tauri-version.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const root = process.cwd(); + +function readJson(filePath: string): T { + return JSON.parse(readFileSync(filePath, "utf-8")) as T; +} + +function numericBaseVersion(version: string): string { + const match = /^v?(\d+\.\d+\.\d+)(?:[-+].*)?$/.exec(version); + if (!match) { + throw new Error(`package.json version must look like v1.2.3 or v1.2.3-beta. Got: ${version}`); + } + return match[1]; +} + +type PackageJson = { + version?: string; +}; + +type TauriConfig = { + version?: string; +}; + +const packageJson = readJson(path.join(root, "package.json")); +if (!packageJson.version) { + throw new Error("package.json is missing version."); +} + +const expectedTauriVersion = numericBaseVersion(packageJson.version); +const tauriConfig = readJson(path.join(root, "src-tauri", "tauri.conf.json")); +const tauriCargoToml = readFileSync(path.join(root, "src-tauri", "Cargo.toml"), "utf-8"); +const tauriCargoVersion = /^\s*version\s*=\s*"([^"]+)"/m.exec(tauriCargoToml)?.[1]; + +const errors: string[] = []; +if (tauriConfig.version !== expectedTauriVersion) { + errors.push(`src-tauri/tauri.conf.json version is ${tauriConfig.version}, expected ${expectedTauriVersion}`); +} +if (tauriCargoVersion !== expectedTauriVersion) { + errors.push(`src-tauri/Cargo.toml version is ${tauriCargoVersion ?? ""}, expected ${expectedTauriVersion}`); +} + +if (errors.length > 0) { + throw new Error(errors.join("\n")); +} + +console.log(`Tauri version metadata OK: ${expectedTauriVersion} from ${packageJson.version}`); diff --git a/scripts/clean-tauri-bundles.ts b/scripts/clean-tauri-bundles.ts new file mode 100644 index 0000000..2eb9914 --- /dev/null +++ b/scripts/clean-tauri-bundles.ts @@ -0,0 +1,16 @@ +import { existsSync, rmSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const root = process.cwd(); +const bundleDir = path.resolve(root, "target", "release", "bundle"); +const releaseTarget = path.resolve(root, "target", "release"); + +if (!bundleDir.startsWith(`${releaseTarget}${path.sep}`)) { + throw new Error(`Refusing to clean unexpected Tauri bundle path: ${bundleDir}`); +} + +if (existsSync(bundleDir)) { + rmSync(bundleDir, { recursive: true, force: true }); + console.log(`Removed ${path.relative(root, bundleDir)}`); +} diff --git a/scripts/dev-cleanup.ts b/scripts/dev-cleanup.ts index 3f4c635..6d99576 100644 --- a/scripts/dev-cleanup.ts +++ b/scripts/dev-cleanup.ts @@ -1,4 +1,4 @@ import { cleanupAllDevProcesses } from "./dev-lifecycle"; -cleanupAllDevProcesses(["5173", "5174", "8765", "8766"]); -console.log("Stopped PatchOpsIII dev processes on ports 5173, 5174, 8765, and 8766."); +cleanupAllDevProcesses(["5173", "5174", "5175", "8765", "8766", "8767"]); +console.log("Stopped PatchOpsIII dev processes on ports 5173, 5174, 5175, 8765, 8766, and 8767."); diff --git a/scripts/dev-tauri.ts b/scripts/dev-tauri.ts new file mode 100644 index 0000000..a6d9301 --- /dev/null +++ b/scripts/dev-tauri.ts @@ -0,0 +1,52 @@ +import { spawn, spawnSync } from "node:child_process"; +import process from "node:process"; +import { cleanupSession, killChildTree, registerProcess } from "./dev-lifecycle"; + +function runStep(name: string, command: string, args: string[]) { + const result = spawnSync(command, args, { + stdio: "inherit", + shell: process.platform === "win32", + env: process.env + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +const backendPort = process.env.PATCHOPSIII_BACKEND_PORT ?? "8767"; +let shuttingDown = false; + +cleanupSession("tauri"); +runStep("core", "bun", ["run", "build:core"]); +runStep("sidecars", "bun", ["scripts/prepare-tauri-sidecars.ts", "--dev"]); + +const tauri = spawn("bun", ["x", "tauri", "dev"], { + stdio: "inherit", + shell: process.platform === "win32", + env: { + ...process.env, + PATCHOPSIII_BACKEND_PORT: backendPort + } +}); + +registerProcess("tauri", "tauri", tauri, "bun x tauri dev"); +tauri.on("exit", (code) => shutdown(code ?? 0)); + +function shutdown(code = 0) { + if (shuttingDown) { + return; + } + + shuttingDown = true; + killChildTree(tauri); + process.exit(code); +} + +process.on("SIGINT", () => shutdown(0)); +process.on("SIGTERM", () => shutdown(0)); +process.on("SIGBREAK", () => shutdown(0)); +process.on("SIGHUP", () => shutdown(0)); +process.on("uncaughtException", (error) => { + console.error(error); + shutdown(1); +}); diff --git a/scripts/prepare-tauri-sidecars.ts b/scripts/prepare-tauri-sidecars.ts new file mode 100644 index 0000000..3522bda --- /dev/null +++ b/scripts/prepare-tauri-sidecars.ts @@ -0,0 +1,67 @@ +import { chmodSync, copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; + +const root = process.cwd(); +const binariesDir = path.join(root, "src-tauri", "binaries"); +const extension = process.platform === "win32" ? ".exe" : ""; +const devMode = process.argv.includes("--dev"); + +function hostTriple() { + const result = spawnSync("rustc", ["-Vv"], { encoding: "utf-8" }); + if (result.status !== 0) { + throw new Error(result.stderr || "rustc -Vv failed"); + } + const host = result.stdout + .split(/\r?\n/) + .find((line) => line.startsWith("host:")) + ?.split(":", 2)[1] + ?.trim(); + if (!host) { + throw new Error("Could not read Rust host target triple."); + } + return host; +} + +function requireFile(candidates: string[], label: string) { + const found = candidates.find((candidate) => existsSync(candidate)); + if (!found) { + throw new Error(`${label} was not found. Checked:\n${candidates.join("\n")}`); + } + return found; +} + +function copySidecar(source: string, name: string, triple: string) { + mkdirSync(binariesDir, { recursive: true }); + const target = path.join(binariesDir, `${name}-${triple}${extension}`); + copyFileSync(source, target); + if (process.platform !== "win32") { + chmodSync(target, 0o755); + } + console.log(`Prepared ${path.relative(root, target)}`); +} + +const triple = hostTriple(); +const core = requireFile( + [ + path.join(root, "target", "release", `patchops-core${extension}`), + path.join(root, "crates", "patchops-core", "target", "release", `patchops-core${extension}`) + ], + "Rust core sidecar" +); + +const backendCandidates = [ + path.join(root, "dist", "backend", `patchops-backend${extension}`), + path.join(root, "backend-bin", `patchops-backend${extension}`) +]; +const backend = backendCandidates.find((candidate) => existsSync(candidate)); +if (!backend && !devMode) { + throw new Error(`PyInstaller backend sidecar was not found. Checked:\n${backendCandidates.join("\n")}`); +} + +copySidecar(backend ?? core, "patchops-backend", triple); +copySidecar(core, "patchops-core", triple); +if (!backend && devMode) { + console.log("Prepared dev-only backend placeholder. Tauri dev still runs Python through uvicorn."); +} diff --git a/scripts/verify-tauri-sidecars.ts b/scripts/verify-tauri-sidecars.ts new file mode 100644 index 0000000..de874f3 --- /dev/null +++ b/scripts/verify-tauri-sidecars.ts @@ -0,0 +1,40 @@ +import { existsSync, statSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; + +const root = process.cwd(); +const binariesDir = path.join(root, "src-tauri", "binaries"); +const extension = process.platform === "win32" ? ".exe" : ""; + +function hostTriple() { + const result = spawnSync("rustc", ["-Vv"], { encoding: "utf-8" }); + if (result.status !== 0) { + throw new Error(result.stderr || "rustc -Vv failed"); + } + const host = result.stdout + .split(/\r?\n/) + .find((line) => line.startsWith("host:")) + ?.split(":", 2)[1] + ?.trim(); + if (!host) { + throw new Error("Could not read Rust host target triple."); + } + return host; +} + +function verifySidecar(name: string, triple: string) { + const filePath = path.join(binariesDir, `${name}-${triple}${extension}`); + if (!existsSync(filePath)) { + throw new Error(`Missing Tauri sidecar: ${path.relative(root, filePath)}`); + } + const stats = statSync(filePath); + if (!stats.isFile() || stats.size <= 0) { + throw new Error(`Invalid Tauri sidecar: ${path.relative(root, filePath)}`); + } + console.log(`Verified ${path.relative(root, filePath)} (${stats.size} bytes)`); +} + +const triple = hostTriple(); +verifySidecar("patchops-backend", triple); +verifySidecar("patchops-core", triple); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..7d26322 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "patchopsiii-tauri" +version = "1.3.0" +description = "PatchOpsIII Tauri host" +edition = "2021" + +[build-dependencies] +serde_json = "1" +tauri-build = { version = "2", features = [] } + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +thiserror = "2" diff --git a/src-tauri/binaries/.gitkeep b/src-tauri/binaries/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src-tauri/binaries/.gitkeep @@ -0,0 +1 @@ + diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..8b6096f --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,18 @@ +fn main() { + println!("cargo:rerun-if-changed=../package.json"); + if let Ok(content) = std::fs::read_to_string("../package.json") { + if let Some(version) = serde_json::from_str::(&content) + .ok() + .and_then(|package| { + package + .get("version") + .and_then(|value| value.as_str()) + .map(str::to_owned) + }) + { + println!("cargo:rustc-env=PATCHOPSIII_PACKAGE_VERSION={version}"); + } + } + + tauri_build::build(); +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..90d6712 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default PatchOpsIII desktop permissions", + "windows": ["main"], + "permissions": [ + "core:default", + "dialog:default" + ] +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..78b483f --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,103 @@ +use std::process::{Command, Stdio}; + +use tauri::{AppHandle, State}; +use tauri_plugin_dialog::DialogExt; + +use crate::AppState; + +#[tauri::command] +pub fn backend_url(state: State<'_, AppState>) -> String { + state.backend_url.clone() +} + +#[tauri::command] +pub fn platform() -> &'static str { + #[cfg(target_os = "windows")] + { + "win32" + } + #[cfg(target_os = "macos")] + { + "darwin" + } + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + { + "linux" + } +} + +#[tauri::command] +pub async fn pick_game_directory(app: AppHandle) -> Option { + app.dialog() + .file() + .set_title("Select Black Ops III folder") + .blocking_pick_folder() + .map(|path| path.to_string()) +} + +#[tauri::command] +pub fn open_external_url(url: String) -> Result<(), String> { + let trimmed = url.trim(); + if !is_allowed_external_url(trimmed) { + return Err("unsupported external URL scheme".to_string()); + } + + let mut command = external_open_command(trimmed); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command.spawn().map_err(|error| error.to_string())?; + Ok(()) +} + +fn is_allowed_external_url(url: &str) -> bool { + let lower = url.to_ascii_lowercase(); + ["https://", "http://", "steam://"] + .iter() + .any(|scheme| lower.starts_with(scheme)) +} + +fn external_open_command(url: &str) -> Command { + #[cfg(target_os = "windows")] + { + let mut command = Command::new("rundll32"); + command.args(["url.dll,FileProtocolHandler", url]); + command + } + + #[cfg(target_os = "macos")] + { + let mut command = Command::new("open"); + command.arg(url); + command + } + + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + { + let mut command = Command::new("xdg-open"); + command.arg(url); + command + } +} + +#[cfg(test)] +mod tests { + use super::is_allowed_external_url; + + #[test] + fn allows_expected_external_url_schemes() { + assert!(is_allowed_external_url("https://example.com")); + assert!(is_allowed_external_url("http://example.com")); + assert!(is_allowed_external_url("steam://open/console")); + } + + #[test] + fn rejects_local_or_script_urls() { + assert!(!is_allowed_external_url( + "file:///C:/Windows/system32/calc.exe" + )); + assert!(!is_allowed_external_url("javascript:alert(1)")); + assert!(!is_allowed_external_url("")); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..e49c22b --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,76 @@ +mod commands; +mod sidecar; +mod window; + +use std::sync::Mutex; + +use tauri::Manager; + +use sidecar::BackendSupervisor; + +pub struct AppState { + backend_url: String, + backend: Mutex, +} + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .manage(AppState { + backend_url: sidecar::backend_url(), + backend: Mutex::new(BackendSupervisor::default()), + }) + .setup(|app| { + let state = app.state::(); + if let Err(error) = state + .backend + .lock() + .expect("backend supervisor poisoned") + .start(app.handle()) + { + eprintln!("failed to start backend: {error}"); + } + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::backend_url, + commands::open_external_url, + commands::platform, + commands::pick_game_directory, + window::window_state, + window::window_minimize, + window::window_toggle_maximize, + window::window_close + ]) + .on_window_event(|window, event| { + if matches!( + event, + tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. } + ) { + window::emit_window_state(window); + } + if matches!(event, tauri::WindowEvent::CloseRequested { .. }) { + let state = window.state::(); + state + .backend + .lock() + .expect("backend supervisor poisoned") + .stop(); + } + }) + .build(tauri::generate_context!()) + .expect("error while building PatchOpsIII Tauri app") + .run(|app, event| { + if matches!( + event, + tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit + ) { + let state = app.state::(); + state + .backend + .lock() + .expect("backend supervisor poisoned") + .stop(); + } + }); +} diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs new file mode 100644 index 0000000..dd037f4 --- /dev/null +++ b/src-tauri/src/sidecar.rs @@ -0,0 +1,364 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, +}; + +use tauri::{AppHandle, Manager}; + +const DEFAULT_BACKEND_HOST: &str = "127.0.0.1"; +const DEFAULT_BACKEND_PORT: u16 = 8765; + +struct BackendEnv { + host: String, + port: u16, + version: String, + core_binary: Option, + resource_dir: Option, +} + +pub fn backend_host() -> String { + env::var("PATCHOPSIII_BACKEND_HOST") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_BACKEND_HOST.to_string()) +} + +pub fn backend_port() -> u16 { + env::var("PATCHOPSIII_BACKEND_PORT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_BACKEND_PORT) +} + +pub fn backend_url() -> String { + format!("http://{}:{}", backend_host(), backend_port()) +} + +#[derive(Default)] +pub struct BackendSupervisor { + child: Option, +} + +impl BackendSupervisor { + pub fn start(&mut self, app: &AppHandle) -> Result<(), String> { + if self.child.is_some() { + return Ok(()); + } + + let root = app_root(app); + let host = backend_host(); + let port = backend_port(); + let backend_binary = if cfg!(debug_assertions) { + None + } else { + find_binary(app, "patchops-backend") + }; + let mut command = if let Some(binary) = backend_binary { + Command::new(binary) + } else { + let mut command = Command::new(python_command(root.as_deref())); + command.args([ + "-m", + "uvicorn", + "backend.api:app", + "--host", + &host, + "--port", + &port.to_string(), + ]); + command + }; + + if let Some(root) = root.as_ref() { + command.current_dir(root); + } + + let backend_env = BackendEnv { + host, + port, + version: app_version(app, root.as_deref()), + core_binary: find_binary(app, "patchops-core"), + resource_dir: resource_root(app), + }; + + apply_backend_env(&mut command, &backend_env); + command + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + self.child = Some(command.spawn().map_err(|error| error.to_string())?); + Ok(()) + } + + pub fn stop(&mut self) { + let Some(mut child) = self.child.take() else { + return; + }; + + #[cfg(target_os = "windows")] + { + let _ = Command::new("taskkill") + .args(["/pid", &child.id().to_string(), "/t", "/f"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + + #[cfg(not(target_os = "windows"))] + { + let _ = Command::new("kill") + .args(["-TERM", &child.id().to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(100)); + } + _ => break, + } + } + + let _ = child.kill(); + } + + let _ = child.wait(); + } +} + +fn apply_backend_env(command: &mut Command, backend_env: &BackendEnv) { + if let Some(core_binary) = backend_env.core_binary.as_ref() { + command.env("PATCHOPSIII_CORE_BINARY", core_binary); + } + if let Some(resource_dir) = backend_env.resource_dir.as_ref() { + command.env("PATCHOPSIII_RESOURCE_DIR", resource_dir); + } + + command + .env("PATCHOPSIII_BACKEND_HOST", &backend_env.host) + .env("PATCHOPSIII_BACKEND_PORT", backend_env.port.to_string()) + .env("PATCHOPSIII_VERSION", &backend_env.version) + .env("PYTHONUNBUFFERED", "1"); +} + +impl Drop for BackendSupervisor { + fn drop(&mut self) { + self.stop(); + } +} + +fn app_version(app: &AppHandle, app_root: Option<&Path>) -> String { + if let Ok(value) = env::var("PATCHOPSIII_VERSION") { + if !value.trim().is_empty() { + return value; + } + } + + let mut candidates = Vec::new(); + if let Some(root) = app_root { + candidates.push(root.join("package.json")); + } + if let Some(resource_dir) = resource_root(app) { + candidates.push(resource_dir.join("package.json")); + } + + for candidate in candidates { + let Ok(content) = fs::read_to_string(candidate) else { + continue; + }; + let Ok(package) = serde_json::from_str::(&content) else { + continue; + }; + if let Some(version) = package.get("version").and_then(|value| value.as_str()) { + if !version.trim().is_empty() { + return version.to_string(); + } + } + } + + if let Some(version) = option_env!("PATCHOPSIII_PACKAGE_VERSION") { + if !version.trim().is_empty() { + return version.to_string(); + } + } + + app.package_info().version.to_string() +} + +fn python_command(app_root: Option<&Path>) -> String { + if let Ok(value) = env::var("PATCHOPSIII_PYTHON") { + if !value.trim().is_empty() { + return value; + } + } + + let root = app_root.unwrap_or_else(|| Path::new(".")); + let venv = if cfg!(target_os = "windows") { + root.join(".venv").join("Scripts").join("python.exe") + } else { + root.join(".venv").join("bin").join("python") + }; + if venv.is_file() { + return venv.to_string_lossy().into_owned(); + } + + if cfg!(target_os = "windows") { + "python".to_string() + } else { + "python3".to_string() + } +} + +fn app_root(app: &AppHandle) -> Option { + if cfg!(debug_assertions) { + let current = env::current_dir().ok()?; + if current.join("backend").is_dir() { + return Some(current); + } + if let Some(parent) = current.parent() { + if parent.join("backend").is_dir() { + return Some(parent.to_path_buf()); + } + } + return Some(current); + } + resource_root(app).or_else(|| app.path().resource_dir().ok()) +} + +fn resource_root(app: &AppHandle) -> Option { + let mut candidates = Vec::new(); + if let Ok(resource_dir) = app.path().resource_dir() { + candidates.push(resource_dir.join("_up_")); + candidates.push(resource_dir); + } + if let Ok(current_exe) = env::current_exe() { + if let Some(parent) = current_exe.parent() { + candidates.push(parent.join("_up_")); + candidates.push(parent.to_path_buf()); + } + } + + candidates.into_iter().find(|candidate| { + candidate.join("presets.json").is_file() || candidate.join("package.json").is_file() + }) +} + +fn find_binary(app: &AppHandle, name: &str) -> Option { + let mut directories = Vec::new(); + if let Some(root) = app_root(app) { + directories.push(root.join("src-tauri").join("binaries")); + directories.push(root.join("dist").join("backend")); + directories.push(root.join("target").join("release")); + directories.push(root.join("target").join("debug")); + } + if let Ok(resource_dir) = app.path().resource_dir() { + directories.push(resource_dir); + } + if let Ok(current_exe) = env::current_exe() { + if let Some(parent) = current_exe.parent() { + directories.push(parent.to_path_buf()); + } + } + + directories + .into_iter() + .find_map(|directory| find_binary_in(&directory, name)) +} + +fn find_binary_in(directory: &Path, name: &str) -> Option { + let entries = std::fs::read_dir(directory).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + let lower = file_name.to_ascii_lowercase(); + if lower.starts_with(name) && (!cfg!(target_os = "windows") || lower.ends_with(".exe")) { + return Some(path); + } + } + None +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, ffi::OsString}; + + use super::{apply_backend_env, BackendEnv, Command, PathBuf}; + + fn command_env(command: &Command) -> HashMap { + command + .get_envs() + .filter_map(|(key, value)| { + value.map(|value| (key.to_string_lossy().into_owned(), value.to_os_string())) + }) + .collect() + } + + #[test] + fn applies_backend_sidecar_environment() { + let mut command = if cfg!(target_os = "windows") { + Command::new("cmd") + } else { + Command::new("sh") + }; + let backend_env = BackendEnv { + host: "127.0.0.1".to_string(), + port: 8767, + version: "v1.3.0-beta3".to_string(), + core_binary: Some(PathBuf::from("patchops-core-test")), + resource_dir: Some(PathBuf::from("resource-root-test")), + }; + + apply_backend_env(&mut command, &backend_env); + + let env = command_env(&command); + assert_eq!(env["PATCHOPSIII_BACKEND_HOST"], "127.0.0.1"); + assert_eq!(env["PATCHOPSIII_BACKEND_PORT"], "8767"); + assert_eq!(env["PATCHOPSIII_VERSION"], "v1.3.0-beta3"); + assert_eq!(env["PYTHONUNBUFFERED"], "1"); + assert_eq!( + env["PATCHOPSIII_CORE_BINARY"], + OsString::from("patchops-core-test") + ); + assert_eq!( + env["PATCHOPSIII_RESOURCE_DIR"], + OsString::from("resource-root-test") + ); + } + + #[test] + fn omits_optional_sidecar_environment_when_paths_are_missing() { + let mut command = if cfg!(target_os = "windows") { + Command::new("cmd") + } else { + Command::new("sh") + }; + let backend_env = BackendEnv { + host: "127.0.0.1".to_string(), + port: 8765, + version: "1.3.0".to_string(), + core_binary: None, + resource_dir: None, + }; + + apply_backend_env(&mut command, &backend_env); + + let env = command_env(&command); + assert!(!env.contains_key("PATCHOPSIII_CORE_BINARY")); + assert!(!env.contains_key("PATCHOPSIII_RESOURCE_DIR")); + assert_eq!(env["PATCHOPSIII_BACKEND_HOST"], "127.0.0.1"); + assert_eq!(env["PATCHOPSIII_BACKEND_PORT"], "8765"); + } +} diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs new file mode 100644 index 0000000..1fc7882 --- /dev/null +++ b/src-tauri/src/window.rs @@ -0,0 +1,67 @@ +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager, WebviewWindow, Window}; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowState { + maximized: bool, +} + +fn main_window(app: &AppHandle) -> Option { + app.get_webview_window("main") +} + +fn state_for(window: &WebviewWindow) -> WindowState { + WindowState { + maximized: window.is_maximized().unwrap_or(false), + } +} + +fn state_for_window(window: &Window) -> WindowState { + WindowState { + maximized: window.is_maximized().unwrap_or(false), + } +} + +pub fn emit_window_state(window: &Window) { + let _ = window.emit("desktop:window-state", state_for_window(window)); +} + +#[tauri::command] +pub fn window_state(app: AppHandle) -> WindowState { + main_window(&app) + .map(|window| state_for(&window)) + .unwrap_or(WindowState { maximized: false }) +} + +#[tauri::command] +pub fn window_minimize(app: AppHandle) { + if let Some(window) = main_window(&app) { + let _ = window.minimize(); + } +} + +#[tauri::command] +pub fn window_toggle_maximize(app: AppHandle) -> WindowState { + let Some(window) = main_window(&app) else { + return WindowState { maximized: false }; + }; + + let maximized = window.is_maximized().unwrap_or(false); + if maximized { + let _ = window.unmaximize(); + } else { + let _ = window.maximize(); + } + + let state = state_for(&window); + let _ = window.emit("desktop:window-state", state.clone()); + state +} + +#[tauri::command] +pub fn window_close(app: AppHandle) { + if let Some(window) = main_window(&app) { + let _ = window.close(); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..7d2fc9d --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,47 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "PatchOpsIII", + "version": "1.3.0", + "identifier": "com.patchopsiii.desktop", + "build": { + "beforeDevCommand": "bun x vite --host 127.0.0.1 --port 5175 --strictPort", + "beforeBuildCommand": "bun run build", + "devUrl": "http://127.0.0.1:5175", + "frontendDist": "../dist/renderer" + }, + "app": { + "windows": [ + { + "title": "PatchOpsIII", + "width": 1320, + "height": 860, + "minWidth": 1100, + "minHeight": 720, + "resizable": true, + "decorations": false, + "fullscreen": false + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": ["msi", "appimage"], + "icon": [ + "../PatchOpsIII.ico", + "../website/assets/img/icon-512.png" + ], + "resources": [ + "../package.json", + "../presets.json", + "../PatchOpsIII.ico", + "../website/assets/img/icon-512.png" + ], + "externalBin": [ + "binaries/patchops-backend", + "binaries/patchops-core" + ] + } +} diff --git a/src/renderer/lib/api.ts b/src/renderer/lib/api.ts index a40d1db..724f20f 100644 --- a/src/renderer/lib/api.ts +++ b/src/renderer/lib/api.ts @@ -1,3 +1,5 @@ +import { getBackendUrl } from "./desktop"; + export type LogEntry = { category: "Info" | "Success" | "Warning" | "Error" | string; message: string; @@ -136,11 +138,7 @@ export async function resolveBackendUrl() { if (cachedBackendUrl) { return cachedBackendUrl; } - if (window.patchOpsDesktop) { - cachedBackendUrl = await window.patchOpsDesktop.getBackendUrl(); - return cachedBackendUrl; - } - cachedBackendUrl = import.meta.env.VITE_PATCHOPSIII_BACKEND_URL ?? "http://127.0.0.1:8765"; + cachedBackendUrl = await getBackendUrl(); return cachedBackendUrl; } diff --git a/src/renderer/lib/desktop.test.ts b/src/renderer/lib/desktop.test.ts new file mode 100644 index 0000000..cc723f0 --- /dev/null +++ b/src/renderer/lib/desktop.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +import { + closeWindow, + desktopRuntime, + getBackendUrl, + getPlatform, + getWindowState, + hasDesktopBridge, + minimizeWindow, + onWindowStateChange, + openExternalUrl, + pickGameDirectory, + toggleMaximizeWindow +} from "./desktop"; + +const invokeCalls: Array<{ command: string; args?: Record }> = []; +const listenCalls: Array<{ event: string }> = []; +let invokeResponses: Record = {}; +let tauriUnlistenCalls = 0; + +mock.module("@tauri-apps/api/core", () => ({ + invoke: async (command: string, args?: Record) => { + invokeCalls.push({ command, args }); + return invokeResponses[command]; + } +})); + +mock.module("@tauri-apps/api/event", () => ({ + listen: async (event: string, callback: (event: { payload: { maximized: boolean } }) => void) => { + listenCalls.push({ event }); + callback({ payload: { maximized: true } }); + return () => { + tauriUnlistenCalls += 1; + }; + } +})); + +function setWindow(value: Partial) { + Object.defineProperty(globalThis, "window", { + configurable: true, + value + }); +} + +describe("desktop adapter", () => { + beforeEach(() => { + invokeCalls.length = 0; + listenCalls.length = 0; + invokeResponses = {}; + tauriUnlistenCalls = 0; + setWindow({}); + }); + + test("prefers Tauri commands when Tauri internals exist", async () => { + invokeResponses = { + backend_url: "http://127.0.0.1:8767", + pick_game_directory: "C:/Games/Call of Duty Black Ops III", + platform: "win32", + window_state: { maximized: false }, + window_toggle_maximize: { maximized: true } + }; + setWindow({ + __TAURI_INTERNALS__: {}, + patchOpsDesktop: { + getBackendUrl: async () => "electron", + pickGameDirectory: async () => null, + getPlatform: async () => "electron", + getWindowState: async () => ({ maximized: false }), + minimizeWindow: async () => undefined, + toggleMaximizeWindow: async () => ({ maximized: false }), + closeWindow: async () => undefined, + onWindowStateChange: () => () => undefined + } + }); + + expect(desktopRuntime()).toBe("tauri"); + expect(hasDesktopBridge()).toBe(true); + expect(await getBackendUrl()).toBe("http://127.0.0.1:8767"); + expect(await pickGameDirectory()).toBe("C:/Games/Call of Duty Black Ops III"); + expect(await getPlatform()).toBe("win32"); + expect(await getWindowState()).toEqual({ maximized: false }); + await minimizeWindow(); + expect(await toggleMaximizeWindow()).toEqual({ maximized: true }); + await closeWindow(); + await openExternalUrl("steam://open/console"); + + expect(invokeCalls.map((call) => call.command)).toEqual([ + "backend_url", + "pick_game_directory", + "platform", + "window_state", + "window_minimize", + "window_toggle_maximize", + "window_close", + "open_external_url" + ]); + expect(invokeCalls.at(-1)?.args).toEqual({ url: "steam://open/console" }); + }); + + test("uses Electron bridge when Tauri is unavailable", async () => { + const calls: string[] = []; + setWindow({ + patchOpsDesktop: { + getBackendUrl: async () => { + calls.push("backend"); + return "http://127.0.0.1:8766"; + }, + pickGameDirectory: async () => { + calls.push("picker"); + return "D:/Steam/steamapps/common/Call of Duty Black Ops III"; + }, + getPlatform: async () => "win32", + getWindowState: async () => ({ maximized: false }), + minimizeWindow: async () => { + calls.push("minimize"); + }, + toggleMaximizeWindow: async () => { + calls.push("toggle"); + return { maximized: true }; + }, + closeWindow: async () => { + calls.push("close"); + }, + onWindowStateChange: (callback) => { + callback({ maximized: true }); + return () => calls.push("unlisten"); + } + } + }); + + expect(desktopRuntime()).toBe("electron"); + expect(await getBackendUrl()).toBe("http://127.0.0.1:8766"); + expect(await pickGameDirectory()).toBe("D:/Steam/steamapps/common/Call of Duty Black Ops III"); + await minimizeWindow(); + expect(await toggleMaximizeWindow()).toEqual({ maximized: true }); + await closeWindow(); + const unlisten = onWindowStateChange((state) => calls.push(`state:${state.maximized}`)); + unlisten(); + + expect(invokeCalls).toEqual([]); + expect(calls).toEqual(["backend", "picker", "minimize", "toggle", "close", "state:true", "unlisten"]); + }); + + test("falls back to web defaults without a desktop bridge", async () => { + const opened: string[] = []; + setWindow({ + open: (url: string) => { + opened.push(url); + return null; + } + }); + + expect(desktopRuntime()).toBe("web"); + expect(hasDesktopBridge()).toBe(false); + expect(await getBackendUrl()).toBe("http://127.0.0.1:8765"); + expect(await pickGameDirectory()).toBeNull(); + expect(await getPlatform()).toBe("web"); + expect(await getWindowState()).toEqual({ maximized: false }); + await openExternalUrl("https://example.com"); + expect(opened).toEqual(["https://example.com"]); + }); + + test("subscribes to Tauri window-state events and unlistens", async () => { + invokeResponses = { window_state: { maximized: false } }; + setWindow({ __TAURI_INTERNALS__: {} }); + const states: boolean[] = []; + + const unlisten = onWindowStateChange((state) => states.push(state.maximized)); + await new Promise((resolve) => setTimeout(resolve, 0)); + unlisten(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(listenCalls).toEqual([{ event: "desktop:window-state" }]); + expect(states).toEqual([false, true]); + expect(tauriUnlistenCalls).toBe(1); + }); +}); diff --git a/src/renderer/lib/desktop.ts b/src/renderer/lib/desktop.ts new file mode 100644 index 0000000..77610e6 --- /dev/null +++ b/src/renderer/lib/desktop.ts @@ -0,0 +1,111 @@ +export type WindowState = { maximized: boolean }; +export type DesktopRuntime = "tauri" | "electron" | "web"; + +type TauriInvoke = (command: string, args?: Record) => Promise; + +function desktopWindow() { + return typeof window === "undefined" ? undefined : window; +} + +function hasTauriRuntime() { + return Boolean(desktopWindow()?.__TAURI_INTERNALS__); +} + +async function tauriInvoke(command: string, args?: Record) { + const api = await import("@tauri-apps/api/core"); + return (api.invoke as TauriInvoke)(command, args); +} + +function electronBridge() { + return desktopWindow()?.patchOpsDesktop; +} + +export function hasDesktopBridge() { + return hasTauriRuntime() || Boolean(electronBridge()); +} + +export function desktopRuntime(): DesktopRuntime { + if (hasTauriRuntime()) { + return "tauri"; + } + if (electronBridge()) { + return "electron"; + } + return "web"; +} + +export async function getBackendUrl(): Promise { + if (hasTauriRuntime()) { + return tauriInvoke("backend_url"); + } + const bridge = electronBridge(); + if (bridge) { + return bridge.getBackendUrl(); + } + return import.meta.env?.VITE_PATCHOPSIII_BACKEND_URL ?? "http://127.0.0.1:8765"; +} + +export async function pickGameDirectory(): Promise { + if (hasTauriRuntime()) { + return tauriInvoke("pick_game_directory"); + } + return electronBridge()?.pickGameDirectory() ?? null; +} + +export async function getPlatform(): Promise { + if (hasTauriRuntime()) { + return tauriInvoke("platform"); + } + return electronBridge()?.getPlatform() ?? Promise.resolve("web"); +} + +export async function getWindowState(): Promise { + if (hasTauriRuntime()) { + return tauriInvoke("window_state"); + } + return electronBridge()?.getWindowState() ?? Promise.resolve({ maximized: false }); +} + +export async function minimizeWindow(): Promise { + if (hasTauriRuntime()) { + await tauriInvoke("window_minimize"); + return; + } + await electronBridge()?.minimizeWindow(); +} + +export async function toggleMaximizeWindow(): Promise { + if (hasTauriRuntime()) { + return tauriInvoke("window_toggle_maximize"); + } + return electronBridge()?.toggleMaximizeWindow() ?? Promise.resolve({ maximized: false }); +} + +export async function closeWindow(): Promise { + if (hasTauriRuntime()) { + await tauriInvoke("window_close"); + return; + } + await electronBridge()?.closeWindow(); +} + +export async function openExternalUrl(url: string): Promise { + if (hasTauriRuntime()) { + await tauriInvoke("open_external_url", { url }); + return; + } + desktopWindow()?.open(url, "_blank", "noopener,noreferrer"); +} + +export function onWindowStateChange(callback: (state: WindowState) => void): () => void { + if (hasTauriRuntime()) { + void getWindowState().then(callback).catch(() => undefined); + let unlistenPromise = import("@tauri-apps/api/event") + .then(({ listen }) => listen("desktop:window-state", (event) => callback(event.payload))) + .catch(() => undefined); + return () => { + void unlistenPromise.then((unlisten) => unlisten?.()); + }; + } + return electronBridge()?.onWindowStateChange?.(callback) ?? (() => undefined); +} diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 580732d..2dd1f3c 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -26,6 +26,18 @@ import { import type { LucideIcon } from "lucide-react"; import { Toggle } from "./components/Toggle"; import { apiRequest, makeSocket, resolveBackendUrl, type ApiResult, type LogEntry, type PatchOpsState } from "./lib/api"; +import { + closeWindow as closeDesktopWindow, + desktopRuntime, + getPlatform, + getWindowState, + hasDesktopBridge, + minimizeWindow as minimizeDesktopWindow, + onWindowStateChange, + openExternalUrl, + pickGameDirectory, + toggleMaximizeWindow +} from "./lib/desktop"; import packageInfo from "../../package.json"; import "./styles/app.css"; @@ -203,41 +215,42 @@ function TitleBar({ appVersion, updateDisabled, onCheckForUpdates }: { appVersio const [maximized, setMaximized] = useState(false); const isMac = platform === "darwin"; const displayVersion = appVersion.toLowerCase().startsWith("v") ? appVersion : `v${appVersion}`; - const hasDesktopChrome = Boolean(window.patchOpsDesktop); - const usesNativeWindowControls = hasDesktopChrome && platform === "win32"; - const showWindowControls = !isMac && !usesNativeWindowControls; + const hasDesktopChrome = hasDesktopBridge(); + const runtime = desktopRuntime(); + const usesNativeWindowControls = runtime === "electron" && platform === "win32"; + const showWindowControls = runtime === "tauri" || (!isMac && !usesNativeWindowControls); useEffect(() => { let removeWindowStateListener: (() => void) | undefined; - void window.patchOpsDesktop?.getPlatform().then(setPlatform).catch(() => undefined); - void window.patchOpsDesktop?.getWindowState?.().then((state) => setMaximized(state.maximized)); - removeWindowStateListener = window.patchOpsDesktop?.onWindowStateChange?.((state) => setMaximized(state.maximized)); + void getPlatform().then(setPlatform).catch(() => undefined); + void getWindowState().then((state) => setMaximized(state.maximized)).catch(() => undefined); + removeWindowStateListener = onWindowStateChange((state) => setMaximized(state.maximized)); return () => removeWindowStateListener?.(); }, []); async function minimizeWindow() { - if (window.patchOpsDesktop) { - await window.patchOpsDesktop.minimizeWindow(); + if (hasDesktopChrome) { + await minimizeDesktopWindow(); } } async function toggleMaximize() { - if (window.patchOpsDesktop) { - const state = await window.patchOpsDesktop.toggleMaximizeWindow(); + if (hasDesktopChrome) { + const state = await toggleMaximizeWindow(); setMaximized(state.maximized); } } function closeWindow() { - if (window.patchOpsDesktop) { - void window.patchOpsDesktop.closeWindow(); + if (hasDesktopChrome) { + void closeDesktopWindow(); return; } } return ( -
-