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..897b138 --- /dev/null +++ b/.github/workflows/tauri-linux-build.yml @@ -0,0 +1,275 @@ +name: Tauri Linux Build + +on: + push: + branches: + - main + - Testing + - testing + pull_request: + branches: + - main + - 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: Prepare Tauri sidecars for host tests + run: | + bun run build:core:dev + bun scripts/prepare-tauri-sidecars.ts --dev + bun scripts/verify-tauri-sidecars.ts --dev + + - 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:release + + - 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..824f576 --- /dev/null +++ b/.github/workflows/tauri-windows-build.yml @@ -0,0 +1,262 @@ +name: Tauri Windows Build + +on: + push: + branches: + - main + - Testing + - testing + pull_request: + branches: + - main + - 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: Prepare Tauri sidecars for host tests + run: | + bun run build:core:dev + bun scripts/prepare-tauri-sidecars.ts --dev + bun scripts/verify-tauri-sidecars.ts --dev + + - 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:release + + - 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..501c11d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,12 @@ build/ dist/ node_modules/ .patchopsiii-dev/ +target/ +src-tauri/binaries/* +!src-tauri/binaries/.gitkeep +src-tauri/backend-runtime/* +!src-tauri/backend-runtime/.gitkeep +src-tauri/gen/ # Log files: *.log @@ -28,4 +34,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..be6d17e 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,59 @@ 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 + +# build the Rust core for Tauri dev +bun run build:core:dev + +# run the Tauri desktop shell against the existing React renderer +bun run dev:tauri + +# skip the dev Rust core rebuild when target/debug/patchops-core already exists +PATCHOPSIII_SKIP_CORE_BUILD=1 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/`. Local Tauri package commands use a fast path: the PyInstaller backend is rebuilt only when backend inputs changed, while release commands force a clean backend rebuild for CI and final artifacts. + +```bash +# verify Tauri installer metadata matches the numeric base package version +bun run check:tauri-version + +# Windows fast local package +bun run dist:tauri:win + +# Windows full release package +bun run dist:tauri:win:release + +# Linux fast local package +bun run dist:tauri:linux + +# Linux full release package +bun run dist:tauri:linux:release +``` + +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..b0272ef 100644 --- a/backend/api.py +++ b/backend/api.py @@ -11,13 +11,15 @@ import string import subprocess import sys +import threading +import time import zipfile from collections import deque from pathlib import Path from typing import Any import requests -from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, Header, HTTPException, WebSocket, WebSocketDisconnect from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field @@ -78,9 +80,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 -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" + +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 + + 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 +255,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 +334,13 @@ 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 +_parent_watchdog_started = False +_uvicorn_server: Any | None = None def _file_mtime(path: Path) -> float | None: @@ -323,17 +366,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 +402,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 +415,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 +426,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 +464,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 +510,168 @@ 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 _parent_process_alive(pid: int) -> bool: + if pid <= 0: + return False + if pid == os.getpid(): + return True + if platform.system() == "Windows": + try: + import ctypes + + handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid) + if not handle: + return False + ctypes.windll.kernel32.CloseHandle(handle) + return True + except Exception: + return True + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + + +def _start_parent_watchdog() -> None: + global _parent_watchdog_started + if _parent_watchdog_started: + return + + raw_pid = os.environ.get("PATCHOPSIII_PARENT_PID", "").strip() + if not raw_pid: + return + try: + parent_pid = int(raw_pid) + except ValueError: + write_log(f"Ignoring invalid PATCHOPSIII_PARENT_PID: {raw_pid}", "Warning", log_target) + return + if parent_pid <= 0: + return + + interval = float(os.environ.get("PATCHOPSIII_PARENT_WATCHDOG_INTERVAL", "2") or "2") + _parent_watchdog_started = True + + def watch_parent() -> None: + while True: + time.sleep(max(0.2, interval)) + if not _parent_process_alive(parent_pid): + os._exit(0) + + threading.Thread(target=watch_parent, name="patchops-parent-watchdog", daemon=True).start() + + +def _request_backend_shutdown() -> None: + if _uvicorn_server is not None: + _uvicorn_server.should_exit = True + return + + def stop_process() -> None: + time.sleep(0.25) + os._exit(0) + + threading.Thread(target=stop_process, name="patchops-shutdown", daemon=True).start() + + +def _set_windows_app_identity() -> None: + if platform.system() != "Windows": + return + try: + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("com.patchopsiii.desktop") + except Exception: + return + + +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 +779,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 +794,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 +885,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 +930,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 +1043,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 +1776,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 +1801,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:], @@ -1822,7 +2055,9 @@ def _apply_preset_values(game_dir: str, preset_name: str) -> None: @app.on_event("startup") async def startup() -> None: + _set_windows_app_identity() log_target.set_loop(asyncio.get_running_loop()) + _start_parent_watchdog() @app.get("/api/health") @@ -2292,6 +2527,17 @@ async def logs_clear() -> dict[str, Any]: return {"ok": True, "state": await _current_state_async()} +@app.post("/api/shutdown") +async def shutdown_backend(x_patchopsiii_shutdown_token: str | None = Header(default=None)) -> dict[str, Any]: + expected = os.environ.get("PATCHOPSIII_SHUTDOWN_TOKEN", "") + if expected and x_patchopsiii_shutdown_token != expected: + raise HTTPException(status_code=403, detail="Invalid shutdown token.") + + write_log("Desktop host requested backend shutdown.", "Info", log_target) + _request_backend_shutdown() + return {"ok": True} + + @app.post("/api/mod-files/clear") async def clear_mod_files() -> dict[str, Any]: try: @@ -2352,8 +2598,11 @@ def _run_cli_command(argv: list[str]) -> int | None: import uvicorn - uvicorn.run( - app, - host=os.environ.get("PATCHOPSIII_BACKEND_HOST", "127.0.0.1"), - port=int(os.environ.get("PATCHOPSIII_BACKEND_PORT", "8765")), + _uvicorn_server = uvicorn.Server( + uvicorn.Config( + app, + host=os.environ.get("PATCHOPSIII_BACKEND_HOST", "127.0.0.1"), + port=int(os.environ.get("PATCHOPSIII_BACKEND_PORT", "8765")), + ) ) + _uvicorn_server.run() diff --git a/backend/core_bridge.py b/backend/core_bridge.py new file mode 100644 index 0000000..d34eda2 --- /dev/null +++ b/backend/core_bridge.py @@ -0,0 +1,145 @@ +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 _hidden_subprocess_kwargs() -> dict[str, Any]: + if os.name != "nt": + return {} + return {"creationflags": subprocess.CREATE_NO_WINDOW} + + +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, + **_hidden_subprocess_kwargs(), + ) + + +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..a9a5f3d --- /dev/null +++ b/backend/test_api_status_core.py @@ -0,0 +1,349 @@ +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()) + + +class ParentWatchdogTests(unittest.TestCase): + def tearDown(self) -> None: + api._parent_watchdog_started = False + + def test_parent_process_alive_rejects_invalid_pid(self) -> None: + self.assertFalse(api._parent_process_alive(0)) + self.assertFalse(api._parent_process_alive(-1)) + + def test_parent_process_alive_accepts_current_pid(self) -> None: + self.assertTrue(api._parent_process_alive(os.getpid())) + + def test_parent_watchdog_ignores_missing_or_invalid_env(self) -> None: + with patch.dict(os.environ, {}, clear=True): + api._start_parent_watchdog() + self.assertFalse(api._parent_watchdog_started) + + with patch.dict(os.environ, {"PATCHOPSIII_PARENT_PID": "not-a-pid"}): + with patch.object(api, "write_log") as write_log: + api._start_parent_watchdog() + self.assertFalse(api._parent_watchdog_started) + self.assertTrue(write_log.called) + + +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..d42b54b --- /dev/null +++ b/backend/test_core_bridge.py @@ -0,0 +1,90 @@ +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"}) + + @unittest.skipIf(os.name != "nt", "Windows-only subprocess flag") + def test_core_launch_hides_console_window_on_windows(self) -> None: + result = subprocess.CompletedProcess(["patchops-core"], 0, "{}", "") + with patch.object(core_bridge.subprocess, "run", return_value=result) as run: + core_bridge._run_core(Path("patchops-core.exe"), "status", {"gameDir": "x"}, timeout=5) + + self.assertEqual(run.call_args.kwargs["creationflags"], subprocess.CREATE_NO_WINDOW) + + 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/installer/patchops-backend-version.pyi b/installer/patchops-backend-version.pyi new file mode 100644 index 0000000..7d88143 --- /dev/null +++ b/installer/patchops-backend-version.pyi @@ -0,0 +1,30 @@ +# UTF-8 +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(1, 3, 0, 0), + prodvers=(1, 3, 0, 0), + mask=0x3f, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo([ + StringTable( + '040904B0', + [ + StringStruct('CompanyName', 'PatchOpsIII'), + StringStruct('FileDescription', 'PatchOpsIII Backend'), + StringStruct('FileVersion', '1.3.0.0'), + StringStruct('InternalName', 'PatchOpsIII Backend'), + StringStruct('OriginalFilename', 'patchops-backend.exe'), + StringStruct('ProductName', 'PatchOpsIII'), + StringStruct('ProductVersion', '1.3.0.0') + ] + ) + ]), + VarFileInfo([VarStruct('Translation', [1033, 1200])]) + ] +) diff --git a/package.json b/package.json index 4a2c30a..7697283 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,30 @@ "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:renderer": "bun run typecheck && vite build", "build": "bun run typecheck && bun run build:main && vite build", "build:main": "bun scripts/clean-main.ts && tsc -p tsconfig.electron.json", + "build:core:dev": "cargo build -p patchops-core", + "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", - "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", + "prepare:tauri-sidecars": "bun scripts/prepare-tauri-sidecars.ts", + "verify:tauri-sidecars": "bun scripts/verify-tauri-sidecars.ts", + "build:backend:linux": "bun scripts/build-backend.ts --platform linux --force", + "build:backend:win": "bun scripts/build-backend.ts --platform win --force", "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 scripts/dist-tauri.ts --fast", + "dist:tauri:release": "bun scripts/dist-tauri.ts --release", + "dist:tauri:linux": "bun scripts/dist-tauri.ts --fast --bundles appimage", + "dist:tauri:linux:release": "bun scripts/dist-tauri.ts --release --bundles appimage", + "dist:tauri:win": "bun scripts/dist-tauri.ts --fast --bundles msi", + "dist:tauri:win:release": "bun scripts/dist-tauri.ts --release --bundles msi", + "verify:tauri-win-package": "bun scripts/verify-tauri-win-package.ts", + "test:renderer": "bun test src/renderer/**/*.test.ts", "typecheck": "tsc --noEmit", "start": "bun run build:main && electron ." }, @@ -96,8 +112,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/build-backend-win.ts b/scripts/build-backend-win.ts new file mode 100644 index 0000000..632079f --- /dev/null +++ b/scripts/build-backend-win.ts @@ -0,0 +1,46 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; + +const root = process.cwd(); +const python = path.join(root, ".venv", "Scripts", "python.exe"); +const hiddenImports = [ + "uvicorn.logging", + "uvicorn.loops", + "uvicorn.loops.auto", + "uvicorn.protocols", + "uvicorn.protocols.http", + "uvicorn.protocols.http.auto", + "uvicorn.protocols.websockets", + "uvicorn.protocols.websockets.auto", + "uvicorn.lifespan", + "uvicorn.lifespan.on" +]; + +const args = [ + "-m", + "PyInstaller", + "--noconfirm", + "--onedir", + "--noconsole", + "--name", + "patchops-backend", + "--icon", + path.join(root, "PatchOpsIII.ico"), + "--version-file", + path.join(root, "installer", "patchops-backend-version.pyi"), + "--distpath", + path.join(root, "dist", "backend"), + "--workpath", + path.join(root, "build", "pyinstaller"), + "--specpath", + path.join(root, "build", "pyinstaller"), + ...hiddenImports.flatMap((name) => ["--hidden-import", name]), + path.join(root, "backend", "api.py") +]; + +const result = spawnSync(python, args, { cwd: root, stdio: "inherit" }); +if (result.error) { + throw result.error; +} +process.exit(result.status ?? 1); diff --git a/scripts/build-backend.ts b/scripts/build-backend.ts new file mode 100644 index 0000000..eeac156 --- /dev/null +++ b/scripts/build-backend.ts @@ -0,0 +1,130 @@ +import { existsSync, readdirSync, 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 force = process.argv.includes("--force") || process.argv.includes("--release"); +const platformArg = valueAfter("--platform"); +const platform = platformArg ?? (process.platform === "win32" ? "win" : "linux"); + +if (platform !== "win" && platform !== "linux") { + throw new Error("Usage: bun scripts/build-backend.ts --platform [--force]"); +} + +const hiddenImports = [ + "uvicorn.logging", + "uvicorn.loops", + "uvicorn.loops.auto", + "uvicorn.protocols", + "uvicorn.protocols.http", + "uvicorn.protocols.http.auto", + "uvicorn.protocols.websockets", + "uvicorn.protocols.websockets.auto", + "uvicorn.lifespan", + "uvicorn.lifespan.on" +]; + +function valueAfter(flag: string) { + const index = process.argv.indexOf(flag); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function listFiles(directory: string, predicate: (filePath: string) => boolean) { + const files: string[] = []; + if (!existsSync(directory)) { + return files; + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...listFiles(filePath, predicate)); + } else if (predicate(filePath)) { + files.push(filePath); + } + } + return files; +} + +function newestMtime(files: string[]) { + return files + .filter((file) => existsSync(file)) + .map((file) => statSync(file).mtimeMs) + .reduce((newest, mtime) => Math.max(newest, mtime), 0); +} + +function backendOutput() { + return platform === "win" + ? path.join(root, "dist", "backend", "patchops-backend", "patchops-backend.exe") + : path.join(root, "dist", "backend", "patchops-backend"); +} + +function backendInputs() { + return [ + ...listFiles(path.join(root, "backend"), (file) => file.endsWith(".py")), + path.join(root, "utils.py"), + path.join(root, "bo3_enhanced.py"), + path.join(root, "dxvk_manager.py"), + path.join(root, "t7_patch.py"), + path.join(root, "requirements.txt"), + path.join(root, "PatchOpsIII.ico"), + path.join(root, "installer", "patchops-backend-version.pyi"), + path.join(root, "scripts", "build-backend.ts"), + path.join(root, "scripts", "build-backend-win.ts"), + path.join(root, "scripts", "check-backend-build.ts") + ]; +} + +function run(command: string, args: string[]) { + const result = spawnSync(command, args, { cwd: root, stdio: "inherit" }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +const output = backendOutput(); +const inputMtime = newestMtime(backendInputs()); +const outputMtime = existsSync(output) ? statSync(output).mtimeMs : 0; + +if (!force && outputMtime >= inputMtime) { + console.log(`Reused ${path.relative(root, output)}; backend inputs unchanged.`); + process.exit(0); +} + +run("bun", ["scripts/check-backend-build.ts", platform]); + +const python = platform === "win" ? path.join(root, ".venv", "Scripts", "python.exe") : path.join(root, ".venv", "bin", "python"); +const commonArgs = [ + "-m", + "PyInstaller", + "--noconfirm", + "--name", + "patchops-backend", + "--distpath", + path.join(root, "dist", "backend"), + "--workpath", + path.join(root, "build", "pyinstaller"), + "--specpath", + path.join(root, "build", "pyinstaller"), + ...hiddenImports.flatMap((name) => ["--hidden-import", name]), + path.join(root, "backend", "api.py") +]; + +const args = + platform === "win" + ? [ + ...commonArgs.slice(0, 2), + "--onedir", + "--noconsole", + "--icon", + path.join(root, "PatchOpsIII.ico"), + "--version-file", + path.join(root, "installer", "patchops-backend-version.pyi"), + ...commonArgs.slice(2) + ] + : ["-m", "PyInstaller", "--noconfirm", "--onefile", "--name", "patchops-backend", "--distpath", path.join(root, "dist", "backend"), "--workpath", path.join(root, "build", "pyinstaller"), "--specpath", path.join(root, "build", "pyinstaller"), ...hiddenImports.flatMap((name) => ["--hidden-import", name]), path.join(root, "backend", "api.py")]; + +run(python, args); 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..50a923d --- /dev/null +++ b/scripts/dev-tauri.ts @@ -0,0 +1,53 @@ +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"); +if (process.env.PATCHOPSIII_SKIP_CORE_BUILD !== "1") { + runStep("core", "bun", ["run", "build:core:dev"]); +} + +const tauri = spawn("bun", ["x", "tauri", "dev", "--config", "src-tauri/tauri.dev.conf.json"], { + stdio: "inherit", + shell: process.platform === "win32", + env: { + ...process.env, + PATCHOPSIII_BACKEND_PORT: backendPort + } +}); + +registerProcess("tauri", "tauri", tauri, "bun x tauri dev --config src-tauri/tauri.dev.conf.json"); +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/dist-tauri.ts b/scripts/dist-tauri.ts new file mode 100644 index 0000000..1da289f --- /dev/null +++ b/scripts/dist-tauri.ts @@ -0,0 +1,49 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +const releaseMode = process.argv.includes("--release"); +const fastMode = process.argv.includes("--fast") || !releaseMode; +const bundles = valueAfter("--bundles"); + +function valueAfter(flag: string) { + const index = process.argv.indexOf(flag); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function backendPlatform() { + if (bundles === "msi") { + return "win"; + } + if (bundles === "appimage") { + return "linux"; + } + return process.platform === "win32" ? "win" : "linux"; +} + +function run(command: string, args: string[]) { + const result = spawnSync(command, args, { + stdio: "inherit", + shell: process.platform === "win32", + env: process.env + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +console.log(`Building Tauri package in ${fastMode ? "fast" : "release"} mode${bundles ? ` for ${bundles}` : ""}.`); + +run("bun", ["run", "check:tauri-version"]); +run("bun", ["scripts/build-backend.ts", "--platform", backendPlatform(), ...(releaseMode ? ["--force"] : [])]); +run("bun", ["run", "build:core"]); +run("bun", ["run", "prepare:tauri-sidecars"]); +run("bun", ["run", "verify:tauri-sidecars"]); +run("bun", ["run", "clean:tauri-bundles"]); +run("bun", ["x", "tauri", "build", ...(bundles ? ["--bundles", bundles] : [])]); + +if (bundles === "msi" || (!bundles && process.platform === "win32")) { + run("bun", ["run", "verify:tauri-win-package"]); +} diff --git a/scripts/prepare-tauri-sidecars.ts b/scripts/prepare-tauri-sidecars.ts new file mode 100644 index 0000000..f5fa6c9 --- /dev/null +++ b/scripts/prepare-tauri-sidecars.ts @@ -0,0 +1,161 @@ +import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, 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 backendRuntimeDir = path.join(root, "src-tauri", "backend-runtime"); +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 changed(source: string, target: string) { + if (!existsSync(target)) { + return true; + } + const sourceStats = statSync(source); + const targetStats = statSync(target); + return sourceStats.size !== targetStats.size || sourceStats.mtimeMs > targetStats.mtimeMs + 1; +} + +function copyFileIfChanged(source: string, target: string) { + mkdirSync(path.dirname(target), { recursive: true }); + if (!changed(source, target)) { + return false; + } + copyFileSync(source, target); + if (process.platform !== "win32") { + chmodSync(target, 0o755); + } + return true; +} + +function listFiles(directory: string) { + const files: string[] = []; + if (!existsSync(directory)) { + return files; + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...listFiles(filePath)); + } else { + files.push(filePath); + } + } + return files; +} + +function removeStaleFiles(sourceDir: string, targetDir: string) { + const sourceFiles = new Set(listFiles(sourceDir).map((file) => path.relative(sourceDir, file))); + for (const targetFile of listFiles(targetDir)) { + const relative = path.relative(targetDir, targetFile); + if (relative === ".gitkeep" || sourceFiles.has(relative)) { + continue; + } + rmSync(targetFile, { force: true }); + } +} + +function removeEmptyDirectories(directory: string) { + if (!existsSync(directory)) { + return; + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const child = path.join(directory, entry.name); + removeEmptyDirectories(child); + if (readdirSync(child).length === 0) { + rmSync(child, { recursive: true, force: true }); + } + } +} + +function copySidecar(source: string, name: string, triple: string) { + const target = path.join(binariesDir, `${name}-${triple}${extension}`); + const didCopy = copyFileIfChanged(source, target); + console.log(`${didCopy ? "Prepared" : "Reused"} ${path.relative(root, target)}`); +} + +function prepareBackendRuntime(source: string) { + mkdirSync(backendRuntimeDir, { recursive: true }); + + if (statSync(source).isDirectory()) { + removeStaleFiles(source, backendRuntimeDir); + for (const sourceFile of listFiles(source)) { + const target = path.join(backendRuntimeDir, path.relative(source, sourceFile)); + copyFileIfChanged(sourceFile, target); + } + removeEmptyDirectories(backendRuntimeDir); + } else { + for (const targetFile of listFiles(backendRuntimeDir)) { + const relative = path.relative(backendRuntimeDir, targetFile); + if (relative !== ".gitkeep" && relative !== `patchops-backend${extension}`) { + rmSync(targetFile, { force: true }); + } + } + copyFileIfChanged(source, path.join(backendRuntimeDir, `patchops-backend${extension}`)); + removeEmptyDirectories(backendRuntimeDir); + } + + console.log(`Prepared ${path.relative(root, backendRuntimeDir)}`); +} + +const triple = hostTriple(); +const core = requireFile( + devMode + ? [ + path.join(root, "target", "debug", `patchops-core${extension}`), + path.join(root, "crates", "patchops-core", "target", "debug", `patchops-core${extension}`) + ] + : [ + path.join(root, "target", "release", `patchops-core${extension}`), + path.join(root, "crates", "patchops-core", "target", "release", `patchops-core${extension}`) + ], + "Rust core sidecar" +); + +copySidecar(core, "patchops-core", triple); + +if (devMode) { + console.log("Skipped backend runtime prep in dev mode. Tauri dev runs Python through uvicorn."); + process.exit(0); +} + +const backendCandidates = [ + path.join(root, "dist", "backend", "patchops-backend"), + 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) { + throw new Error(`PyInstaller backend sidecar was not found. Checked:\n${backendCandidates.join("\n")}`); +} + +prepareBackendRuntime(backend); diff --git a/scripts/verify-tauri-sidecars.ts b/scripts/verify-tauri-sidecars.ts new file mode 100644 index 0000000..b1f21ad --- /dev/null +++ b/scripts/verify-tauri-sidecars.ts @@ -0,0 +1,53 @@ +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 backendRuntimeDir = path.join(root, "src-tauri", "backend-runtime"); +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 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(); +const backendRuntime = path.join(backendRuntimeDir, `patchops-backend${extension}`); +if (devMode) { + console.log("Skipped backend runtime verification in dev mode."); +} else if (!existsSync(backendRuntime)) { + throw new Error(`Missing backend runtime executable: ${path.relative(root, backendRuntime)}`); +} else { + const backendStats = statSync(backendRuntime); + if (!backendStats.isFile() || backendStats.size <= 0) { + throw new Error(`Invalid backend runtime executable: ${path.relative(root, backendRuntime)}`); + } + console.log(`Verified ${path.relative(root, backendRuntime)} (${backendStats.size} bytes)`); +} +verifySidecar("patchops-core", triple); diff --git a/scripts/verify-tauri-win-package.ts b/scripts/verify-tauri-win-package.ts new file mode 100644 index 0000000..6d9908a --- /dev/null +++ b/scripts/verify-tauri-win-package.ts @@ -0,0 +1,87 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const root = process.cwd(); +const appExe = path.join(root, "target", "release", "patchopsiii-tauri.exe"); +const wixSource = path.join(root, "target", "release", "wix", "x64", "main.wxs"); +const msiDir = path.join(root, "target", "release", "bundle", "msi"); +const banner = path.join(root, "installer", "patchops-msi-banner.bmp"); +const dialog = path.join(root, "installer", "patchops-msi-dialog.bmp"); + +function fail(message: string): never { + throw new Error(message); +} + +function relative(filePath: string) { + return path.relative(root, filePath) || "."; +} + +function requireFile(filePath: string) { + if (!existsSync(filePath)) { + fail(`Missing ${relative(filePath)}`); + } + const stats = statSync(filePath); + if (!stats.isFile() || stats.size <= 0) { + fail(`Invalid file ${relative(filePath)}`); + } +} + +function verifyWindowsGuiSubsystem() { + requireFile(appExe); + const file = readFileSync(appExe); + if (file.readUInt16LE(0) !== 0x5a4d) { + fail(`${relative(appExe)} is not a PE executable`); + } + + const peOffset = file.readUInt32LE(0x3c); + if (file.toString("ascii", peOffset, peOffset + 4) !== "PE\u0000\u0000") { + fail(`${relative(appExe)} has an invalid PE header`); + } + + const optionalHeaderOffset = peOffset + 24; + const subsystem = file.readUInt16LE(optionalHeaderOffset + 0x44); + if (subsystem !== 2) { + fail(`${relative(appExe)} uses PE subsystem ${subsystem}; expected 2 (Windows GUI)`); + } + + console.log(`Verified ${relative(appExe)} uses Windows GUI subsystem`); +} + +function verifyWixBranding() { + requireFile(banner); + requireFile(dialog); + requireFile(wixSource); + + const wix = readFileSync(wixSource, "utf-8"); + const expectations = [ + ["WixUIBannerBmp", path.basename(banner)], + ["WixUIDialogBmp", path.basename(dialog)], + ['InstallScope="perMachine"', "per-machine install scope"] + ]; + + for (const [needle, label] of expectations) { + if (!wix.includes(needle)) { + fail(`${relative(wixSource)} does not include ${label}`); + } + } + + console.log(`Verified MSI WiX customizations in ${relative(wixSource)}`); +} + +function verifyMsiArtifact() { + if (!existsSync(msiDir)) { + fail(`Missing ${relative(msiDir)}`); + } + const msis = readdirSync(msiDir).filter((entry) => entry.toLowerCase().endsWith(".msi")); + if (msis.length !== 1) { + fail(`Expected exactly one MSI in ${relative(msiDir)}, found ${msis.length}`); + } + requireFile(path.join(msiDir, msis[0])); + + console.log(`Verified MSI artifact ${path.join(relative(msiDir), msis[0])}`); +} + +verifyWindowsGuiSubsystem(); +verifyWixBranding(); +verifyMsiArtifact(); 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/backend-runtime/.gitkeep b/src-tauri/backend-runtime/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src-tauri/backend-runtime/.gitkeep @@ -0,0 +1 @@ + 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..6dc0b2f --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,114 @@ +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; +use std::process::{Command, Stdio}; + +use tauri::{AppHandle, State}; +use tauri_plugin_dialog::DialogExt; + +use crate::AppState; + +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +#[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(()) +} + +#[cfg(target_os = "windows")] +fn hide_command_window(command: &mut Command) { + command.creation_flags(CREATE_NO_WINDOW); +} + +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]); + hide_command_window(&mut command); + 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..4709e97 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,78 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +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..2f9a7df --- /dev/null +++ b/src-tauri/src/sidecar.rs @@ -0,0 +1,479 @@ +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; +use std::{ + env, fs, + io::{Read, Write}, + net::TcpStream, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use tauri::{AppHandle, Manager}; + +const DEFAULT_BACKEND_HOST: &str = "127.0.0.1"; +const DEFAULT_BACKEND_PORT: u16 = 8765; +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +struct BackendEnv { + host: String, + port: u16, + version: String, + parent_pid: u32, + shutdown_token: String, + core_binary: Option, + resource_dir: Option, +} + +#[derive(Clone, Debug)] +struct BackendShutdown { + host: String, + port: u16, + token: String, +} + +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, + shutdown: 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()), + parent_pid: std::process::id(), + shutdown_token: shutdown_token(), + core_binary: find_binary(app, "patchops-core"), + resource_dir: resource_root(app), + }; + let shutdown = BackendShutdown { + host: backend_env.host.clone(), + port: backend_env.port, + token: backend_env.shutdown_token.clone(), + }; + + apply_backend_env(&mut command, &backend_env); + configure_backend_process(&mut command); + + self.child = Some(command.spawn().map_err(|error| error.to_string())?); + self.shutdown = Some(shutdown); + Ok(()) + } + + pub fn stop(&mut self) { + let Some(mut child) = self.child.take() else { + return; + }; + + if let Some(shutdown) = self.shutdown.take() { + let _ = request_backend_shutdown(&shutdown); + if wait_for_exit(&mut child, Duration::from_secs(4)) { + let _ = child.wait(); + return; + } + } + + #[cfg(target_os = "windows")] + { + let _ = hidden_command("taskkill") + .args(["/pid", &child.id().to_string(), "/t"]) + .status(); + + if !wait_for_exit(&mut child, Duration::from_secs(2)) { + let _ = hidden_command("taskkill") + .args(["/pid", &child.id().to_string(), "/t", "/f"]) + .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(); + + if !wait_for_exit(&mut child, Duration::from_secs(3)) { + 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_PARENT_PID", backend_env.parent_pid.to_string()) + .env("PATCHOPSIII_SHUTDOWN_TOKEN", &backend_env.shutdown_token) + .env("PATCHOPSIII_VERSION", &backend_env.version) + .env("PYTHONUNBUFFERED", "1"); +} + +fn shutdown_token() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("{}-{nanos}", std::process::id()) +} + +fn request_backend_shutdown(shutdown: &BackendShutdown) -> std::io::Result<()> { + let mut stream = TcpStream::connect((&*shutdown.host, shutdown.port))?; + stream.set_read_timeout(Some(Duration::from_secs(1)))?; + stream.set_write_timeout(Some(Duration::from_secs(1)))?; + + let request = format!( + "POST /api/shutdown HTTP/1.1\r\nHost: {}:{}\r\nContent-Length: 0\r\nX-Patchopsiii-Shutdown-Token: {}\r\nConnection: close\r\n\r\n", + shutdown.host, shutdown.port, shutdown.token + ); + stream.write_all(request.as_bytes())?; + stream.flush()?; + + let mut response = String::new(); + let _ = stream.read_to_string(&mut response); + if response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200") { + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "backend rejected shutdown request", + )) + } +} + +fn wait_for_exit(child: &mut Child, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(_)) => return true, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(100)); + } + _ => return false, + } + } +} + +fn configure_backend_process(command: &mut Command) { + command.stdin(Stdio::null()); + + #[cfg(target_os = "windows")] + { + if cfg!(debug_assertions) { + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + } else { + command + .creation_flags(CREATE_NO_WINDOW) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + } + } + + #[cfg(not(target_os = "windows"))] + { + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + } +} + +#[cfg(target_os = "windows")] +fn hidden_command(program: &str) -> Command { + let mut command = Command::new(program); + command + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command +} + +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("backend-runtime")); + directories.push(root.join("src-tauri").join("binaries")); + directories.push(root.join("dist").join("backend")); + directories.push(root.join("dist").join("backend").join(name)); + 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.join("backend-runtime")); + directories.push(resource_dir); + } + if let Ok(current_exe) = env::current_exe() { + if let Some(parent) = current_exe.parent() { + directories.push(parent.join("backend-runtime")); + 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(), + parent_pid: 4242, + shutdown_token: "shutdown-token-test".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_PARENT_PID"], "4242"); + assert_eq!(env["PATCHOPSIII_SHUTDOWN_TOKEN"], "shutdown-token-test"); + 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(), + parent_pid: 2424, + shutdown_token: "shutdown-token-test".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"); + assert_eq!(env["PATCHOPSIII_PARENT_PID"], "2424"); + assert_eq!(env["PATCHOPSIII_SHUTDOWN_TOKEN"], "shutdown-token-test"); + } +} 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..37b4a8b --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,53 @@ +{ + "$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:renderer", + "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", + "backend-runtime/" + ], + "externalBin": [ + "binaries/patchops-core" + ], + "windows": { + "wix": { + "bannerPath": "../installer/patchops-msi-banner.bmp", + "dialogImagePath": "../installer/patchops-msi-dialog.bmp" + } + } + } +} diff --git a/src-tauri/tauri.dev.conf.json b/src-tauri/tauri.dev.conf.json new file mode 100644 index 0000000..ca9cf24 --- /dev/null +++ b/src-tauri/tauri.dev.conf.json @@ -0,0 +1,5 @@ +{ + "bundle": { + "externalBin": [] + } +} 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..6ebc27a 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 ( -
-