commit 377f3a31b3d68be27dc422d0c287c02b234a5160 Author: SowinskiBraeden Date: Thu Sep 24 20:29:00 2026 -0700 initial commit: reforgermods.net launcher 0.1.0 A desktop server browser for Arma Reforger, built on the reforgermods.net v2 API. Live server list with search, sort and filters; a detail inspector with population history and mod readiness; measured ICMP latency; favorites, recents and settings; and direct joining through Steam. Tauri 2 and Rust, with a Preact frontend that builds to 80 KB of JavaScript in one chunk. Logic lives in rfm-core, which has no Tauri dependency and is tested directly; src-tauri is the window, the command surface and the wiring. Packaged for the public beta: a per-user NSIS installer, a portable .exe and an Arch package, with signature-verified background updates on Windows and a release pipeline that builds from a tag. The artifacts are not yet Authenticode-signed — that is wired and waiting on an Azure Trusted Signing identity, and docs/distribution.md says so plainly rather than letting anyone discover it at the SmartScreen dialog. Co-Authored-By: Claude Opus 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1522b8d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,266 @@ +# Build, sign and publish a launcher release. +# +# Triggered by a version tag. The tag is the instruction: the job refuses to +# build unless the tag and every version in the tree already agree, because an +# updater manifest that disagrees with the installed version either loops +# forever or never fires (docs/distribution.md section 6). +# +# The Windows build runs here rather than cross-compiled from Linux, even though +# scripts/build-windows.sh works: NSIS bundling and Authenticode signing both +# want Windows tooling, and the signing credential should live in one place that +# is not a laptop. +# +# Stored under .github/workflows so both GitHub Actions and Gitea Actions pick it +# up, matching the other two repositories. +name: Release + +on: + push: + tags: ['v*'] + # Reruns of a published tag, for when a publish step failed and the artifacts + # are fine. Builds only; nothing is published without a tag. + workflow_dispatch: + inputs: + version: + description: 'Version to build, without the leading v (e.g. 0.1.0)' + required: true + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + windows: + name: Windows installer + runs-on: windows-latest + # A protected environment: put the signing and R2 credentials on it, and add + # required reviewers so publishing a release needs a human. + environment: release + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: '. -> target' + + # The tag is the source of truth for what is being released; the tree has + # to already say the same thing. + - name: Check the version agrees with the tag + id: version + shell: bash + run: | + tag="${{ inputs.version || github.ref_name }}" + scripts/version.sh check "$tag" + echo "version=$(scripts/version.sh show)" >> "$GITHUB_OUTPUT" + + - run: npm ci + + # Re-validated here rather than trusting that CI ran on this commit: a + # release is the one build that must not ship a known-broken tree. + - name: Checks + run: | + npm run check + npm test + cargo fmt --all -- --check + cargo clippy --workspace --all-targets -- -D warnings + cargo test -p rfm-core + + # Signing is optional so the pipeline works before Azure Trusted Signing is + # provisioned. When the secrets are absent the build is simply unsigned, and + # the summary says so — rather than the whole release failing, or worse, + # silently producing an unsigned installer that the notes call signed. + - name: Decide whether artifacts can be signed + id: signing + shell: bash + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + RFM_SIGN_ENDPOINT: ${{ vars.RFM_SIGN_ENDPOINT }} + run: | + if [ -n "$AZURE_CLIENT_ID" ] && [ -n "$RFM_SIGN_ENDPOINT" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning::Azure Trusted Signing is not configured; this release will be UNSIGNED and will show the SmartScreen warning." + fi + + - name: Install the signing tool + if: steps.signing.outputs.enabled == 'true' + run: cargo install trusted-signing-cli --locked + + # TAURI_SIGNING_PRIVATE_KEY is the updater key, which is a different thing + # from Authenticode code signing: it proves an update came from this project, + # while Authenticode is what stops SmartScreen calling the download unknown. + # Both are needed and they solve different problems. + - name: Build + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + RFM_SIGN_ENDPOINT: ${{ vars.RFM_SIGN_ENDPOINT }} + RFM_SIGN_ACCOUNT: ${{ vars.RFM_SIGN_ACCOUNT }} + RFM_SIGN_PROFILE: ${{ vars.RFM_SIGN_PROFILE }} + shell: bash + run: | + if [ "${{ steps.signing.outputs.enabled }}" = "true" ]; then + # The overlay adds only bundle.windows.signCommand, so Tauri signs each + # artifact during bundling and the updater zip contains a signed + # installer. + npm run tauri -- build --config src-tauri/tauri.signing.conf.json + else + npm run tauri -- build + fi + + # The portable exe is the same binary the installer wraps, offered for people + # who will not run installers. It costs nothing to publish. + - name: Collect artifacts + shell: bash + run: | + set -euo pipefail + version="${{ steps.version.outputs.version }}" + mkdir -p dist/release + nsis="target/release/bundle/nsis" + cp "$nsis"/*-setup.exe "dist/release/reforgermods-launcher-${version}-setup.exe" + cp "target/release/reforgermods-launcher.exe" "dist/release/reforgermods-launcher-${version}.exe" + # The NSIS bundle is signed in place, so the updater artifact is the + # installer itself plus a detached .sig. It exists only when the signing + # key was available; an unsigned release simply has no updater entry. + for f in "$nsis"/*-setup.exe.sig; do + [ -e "$f" ] || continue + cp "$f" "dist/release/reforgermods-launcher-${version}-setup.exe.sig" + done + ls -l dist/release + + # Uploaded whether or not publishing happens, so a failed publish never + # means rebuilding. + - uses: actions/upload-artifact@v4 + with: + name: windows-${{ steps.version.outputs.version }} + path: dist/release/ + if-no-files-found: error + retention-days: 14 + + # The updater key is read through env rather than interpolated into the + # script: `${{ secrets.X }}` in a run block writes the secret into the shell + # command itself, and only the log is masked. + - name: Summary + shell: bash + env: + HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} + SIGNED: ${{ steps.signing.outputs.enabled }} + VERSION: ${{ steps.version.outputs.version }} + run: | + { + echo "### Launcher $VERSION" + if [ "$SIGNED" = "true" ]; then + echo "- Authenticode: **signed**" + else + echo "- Authenticode: **UNSIGNED** — expect the SmartScreen warning" + fi + if [ "$HAS_UPDATER_KEY" = "true" ]; then + echo "- Updater: artifacts signed, \`latest.json\` will be published" + else + echo "- Updater: **no key configured**, no updater artifacts produced" + fi + } >> "$GITHUB_STEP_SUMMARY" + + publish: + name: Publish to R2 + needs: windows + runs-on: ubuntu-latest + # Only a tag publishes. A manual run builds and stops, so it can be used to + # check a release without touching what is live. + if: startsWith(github.ref, 'refs/tags/v') + environment: release + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: windows-${{ needs.windows.outputs.version }} + path: dist/release + + # The Arch package is not built here: it needs an Arch userspace (see + # scripts/build-arch.sh, which runs one in a container) and is handed over + # per tester rather than published. Drop it into dist/release before the + # manifest step if that changes. + - name: Build the manifests + run: | + python3 scripts/release-manifest.py \ + --version "${{ needs.windows.outputs.version }}" \ + --artifacts dist/release \ + --base-url "https://dl.reforgermods.net" \ + --out dist/release + + - name: Checksums + working-directory: dist/release + run: | + sha256sum -- * > SHA256SUMS || true + # The manifests are generated from the artifacts, so they must not be + # listed as if they were part of the signed set. + grep -v -E '(releases|latest)\.json|SHA256SUMS' SHA256SUMS > sums.tmp + mv sums.tmp SHA256SUMS + cat SHA256SUMS + + # dl.reforgermods.net is an R2 bucket with a custom domain. Versioned paths + # are immutable; only /updates/latest.json and /releases.json move, which is + # what makes a rollback a matter of re-publishing one small file. + - name: Publish + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + BUCKET: reforgermods-launcher-downloads + run: | + set -euo pipefail + version="${{ needs.windows.outputs.version }}" + put() { + npx --yes wrangler@4 r2 object put "$BUCKET/$1" --file "$2" --content-type "$3" --remote + } + for file in dist/release/*; do + name="$(basename "$file")" + case "$name" in + releases.json|latest.json) continue ;; + *.exe) type="application/vnd.microsoft.portable-executable" ;; + *.zip) type="application/zip" ;; + *.zst) type="application/zstd" ;; + *) type="text/plain; charset=utf-8" ;; + esac + put "$version/$name" "$file" "$type" + done + # Last, and in this order: the updater manifest only starts pointing at a + # release once every file it names is already in place. + put "updates/latest.json" dist/release/latest.json "application/json" + put "releases.json" dist/release/releases.json "application/json" + + - name: Next step + run: | + { + echo "### Published ${{ needs.windows.outputs.version }}" + echo "Artifacts are live under \`https://dl.reforgermods.net/${{ needs.windows.outputs.version }}/\`." + echo "" + echo "The website's download panel reads its own copy of the manifest." + echo "Copy \`releases.json\` from this run's artifacts into" + echo "\`public/static/launcher/releases.json\` in reforgermods-web and deploy," + echo "or the page will keep offering the previous release." + } >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@v4 + with: + name: manifests-${{ needs.windows.outputs.version }} + path: | + dist/release/releases.json + dist/release/latest.json + dist/release/SHA256SUMS diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..461da8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/node_modules +/dist +/target +/src-tauri/target +*.local +.DS_Store +.env +.env.local + +# Generated by tauri-build at compile time. +/src-tauri/gen/schemas diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..dc084b3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6138 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[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 = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[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.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +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 = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +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.13.2", + "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +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.21", +] + +[[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.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "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 1.26.1", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.13.2", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "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.13.2", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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 = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[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.119", +] + +[[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.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[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 = "discord-rich-presence" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494" +dependencies = [ + "log", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "thiserror 2.0.21", + "uuid 0.8.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.119", +] + +[[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", + "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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.6+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[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 = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[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 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[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.13.2", + "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.119", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[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.119", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[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 = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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 = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[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", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[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 = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[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" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[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.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "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.13.2", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[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.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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 = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[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 = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +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.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "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 = "no-std-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[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.119", +] + +[[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.13.2", + "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.13.2", + "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.13.2", + "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.13.2", + "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.13.2", + "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.13.2", + "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.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-app-kit", + "objc2-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.13.2", + "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.13.2", + "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.13.2", + "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 = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[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" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 = "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.119", +] + +[[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 = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "pnet_base" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc190d4067df16af3aba49b3b74c469e611cad6314676eaf1157f31aa0fb2f7" +dependencies = [ + "no-std-net", +] + +[[package]] +name = "pnet_macros" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13325ac86ee1a80a480b0bc8e3d30c25d133616112bb16e86f712dcf8a71c863" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.119", +] + +[[package]] +name = "pnet_macros_support" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed67a952585d509dd0003049b1fc56b982ac665c8299b124b90ea2bdb3134ab" +dependencies = [ + "pnet_base", +] + +[[package]] +name = "pnet_packet" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c96ebadfab635fcc23036ba30a7d33a80c39e8461b8bd7dc7bb186acb96560f" +dependencies = [ + "glob", + "pnet_base", + "pnet_macros", + "pnet_macros_support", +] + +[[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 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +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 = "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.15+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.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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 = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[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.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "reforgermods-launcher" +version = "0.1.0" +dependencies = [ + "discord-rich-presence", + "rfm-core", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-opener", + "tauri-plugin-updater", + "tokio", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfm-core" +version = "0.1.0" +dependencies = [ + "base64 0.23.1", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.0", + "surge-ping", + "thiserror 2.0.21", + "tokio", + "uuid 1.26.1", + "winping", + "winreg 0.56.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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 1.26.1", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +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.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.2", + "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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.119", +] + +[[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 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[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.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +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 = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "surge-ping" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cdc709585dead30bc08dafb5b25fee3d1a9d5e81e4eff43e21f609476b64a50" +dependencies = [ + "hex", + "parking_lot", + "pnet_packet", + "rand", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", +] + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "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.21", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.21", + "time", + "url", + "uuid 1.26.1", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.21", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +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.21", + "toml 1.1.6+spec-1.1.0", + "url", + "urlpattern", + "uuid 1.26.1", + "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.6+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.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[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.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "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.2", + "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.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[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.2", + "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.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.2", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "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-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +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.21", + "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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 = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[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 = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +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 = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[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.119", +] + +[[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.21", + "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 = "winapi_forked_icmpapi" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42aecb895d6340af9ccc8dab9aeabfeab6d5d7266c5fd172c8be7e07db71c1e3" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[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.119", +] + +[[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.119", +] + +[[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-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winping" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ed0e3a789beb896b3de9fb7e93c76340f6f4adfab7770d6222b4b8625ef0aa" +dependencies = [ + "lazy_static", + "static_assertions", + "winapi_forked_icmpapi", +] + +[[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 = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[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 0.21.1", + "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 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "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 = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid 1.26.1", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3b3d2a5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[workspace] +members = ["crates/rfm-core", "src-tauri"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.82" +license = "Proprietary" +publish = false + +[workspace.dependencies] +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +thiserror = "2.0.21" +# TLS backend is selected per target in crates/rfm-core/Cargo.toml. +reqwest = { version = "0.13.5", default-features = false, features = ["json", "query", "gzip", "charset", "http2"] } +tokio = { version = "1.53.1", default-features = false, features = ["time", "sync", "rt", "net"] } +base64 = "0.23.1" +uuid = { version = "1.26.1", default-features = false, features = ["v4", "std"] } +sha2 = "0.11.0" + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "s" +panic = "abort" +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..c689779 --- /dev/null +++ b/README.md @@ -0,0 +1,378 @@ +# reforgermods.net launcher + +A fast, lightweight desktop companion for **Arma Reforger**, built on the +[reforgermods.net](https://reforgermods.net) API. It is a live server browser +that can put you straight into a server through Steam, without going through the +in-game lobby. + +Unofficial. Not affiliated with or endorsed by Bohemia Interactive. + +## Status + +**0.1.0 — public beta.** The launcher browses and filters the live server list, +shows full detail for a selected server, joins directly through Steam, and keeps +favorites, recents and settings across restarts. Milestone 2 added population +history charts, a scenario filter, and mod readiness as reporting only; 0.1.0 +adds packaging, signed auto-updates and a download page. + +Beta artifacts are a per-user NSIS installer, a portable `.exe` and an Arch +package, published to `dl.reforgermods.net` and offered from +[reforgermods.net/launcher/](https://reforgermods.net/launcher/). They are **not +Authenticode-signed yet**, so Windows shows a SmartScreen warning on first run — +the pipeline is wired for Azure Trusted Signing and waiting on the identity. +See [`docs/distribution.md`](docs/distribution.md). + +What works today: + +- Live server browser over `GET /v2/servers`, with search, sort, region, + platform, official / modded / BattlEye / password / offline filters, and + page-local scenario and minimum-player filters +- **Sortable columns.** Clicking a heading sorts by it. `players` and `name` are + ordered by the API across every matching server; queue, ping, mods, region and + any reversed direction are orderings the API does not offer, so they reorder + the loaded page — marked with a hollow arrow and named in the footer, never + presented as a search of the whole fleet +- Selected-server inspector: scenario, population, queue, region, server FPS, + and the full required-mod list with versions and sizes +- **Population history chart** over 6h / 24h / 7d / 30d, drawn as inline SVG with + no charting library +- **Mod readiness, reporting only**: how many required mods are installed, + outdated or missing, and at least how much is left to download +- **Measured ping** to the selected server by ICMP — a real round trip, not a + region estimate +- Scenario imagery from the Bohemia CDN, host-allowlisted and CSP-enforced +- **Region latency in the browser**, sortable — eight ICMP probes cover a whole page +- Per-platform marks (PC / Xbox / PlayStation) matching the site, not a crossplay flag +- Fleet-wide player total in the title bar +- **Join Discord** where a server advertises an invite in its name +- **Discord Rich Presence**, showing the launcher as the running application + (needs a Discord application; see [`docs/discord-presence.md`](docs/discord-presence.md)) +- **Direct join**: `steam://run/1874880///` +- Favorites, recent servers and launch history, persisted locally +- Settings, and an anonymous, user-resettable install identifier +- **Background updates on Windows**, signature-verified, downloaded quietly and + applied as the app closes — never mid-session. Linux installs are the distro's + to update +- Short-lived response caching, and loading / empty / error / offline states +- Compact API status in the header, with dataset freshness + +Not implemented, on purpose: + +- **"Prepare mods & join".** Readiness is *reported*, never acted on. There is no + verified way to install Reforger Workshop content from outside the game, so the + launcher says what the game will do instead of offering a button it cannot + honour. See [`docs/mod-readiness.md`](docs/mod-readiness.md). +- Accounts, uptime figures, mod browsing, background tray, auto-start. + +## Stack + +| Layer | Choice | Why | +| --- | --- | --- | +| Shell | Tauri 2 | Native WebView2 on Windows; no bundled browser runtime | +| Native | Rust | Steam launching, HTTP, persistence, local filesystem inspection | +| UI | Preact + `@preact/signals` + TypeScript | ~4 KB runtime, JSX in plain `.tsx`, and per-component signal subscriptions so the one-second freshness clock re-renders the status pill and not the 500-row table | +| Build | Vite 7 | | + +The production bundle is **51 KB of JavaScript (17 KB gzipped)** and **14 KB of +CSS**, in one chunk. The history chart is hand-rolled inline SVG — a charting +library would have cost more than the rest of the UI combined. + +### Layout + +```text +crates/rfm-core/ Pure Rust. No Tauri dependency, so it is unit-testable directly. + api/ HTTP client, typed models, query builder, error taxonomy + launch.rs Server id validation -> join payload -> Steam URI + store.rs Settings, favorites, recents, launch history + cache.rs Bounded TTL cache of response bodies + modscan.rs Local Workshop addon detection + readiness.rs Required mods vs installed; reporting only, never acts + ping.rs ICMP latency; address validation is the security boundary + presence.rs Discord Rich Presence text, within Discord's limits + +src-tauri/ The application shell. Window, commands, wiring. Thin. + src/commands.rs The entire IPC surface + src/state.rs Shared state + src/update.rs Background updates, Windows only; applied as the app exits + capabilities/main.json + tauri.signing.conf.json Adds only signCommand; merged over the config in CI + +src/ UI + lib/ types, IPC wrappers, signals, pure formatters, chart geometry + components/ title bar, status pill, sidebar, filter bar, server table, + inspector, history chart, readiness note, platform badges + views/ browse, favorites, recent, settings + +docs/api-contract.md Endpoints used, and what is deliberately not used +docs/mod-readiness.md The mod-readiness investigation and its findings +docs/client-identification.md Install-identity spec and API-side implementation notes +docs/discord-presence.md Discord Rich Presence setup and behaviour +docs/distribution.md Installer, code signing, release procedure, R2 layout +packaging/arch/ PKGBUILD and desktop entry for the Arch package +packaging/windows/sign.cmd Authenticode signing, called by Tauri during bundling + +scripts/version.sh The version, in one place: show / check / set +scripts/release-manifest.py releases.json (the site) and latest.json (the updater) +scripts/build-windows.sh Cross-compile the bare .exe from Linux +scripts/build-arch.sh Native Arch package, built in a container +.github/workflows/release.yml Tag -> build, sign, publish to R2 +``` + +## Prerequisites + +- **Rust** stable (1.82+) and **Node.js** 20+ +- **Windows** (the primary target): the WebView2 runtime, which ships with + Windows 11 and current Windows 10, plus the MSVC build tools +- **Linux** (development, and Arch packaging): the Tauri system dependencies — + `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libayatana-appindicator3-dev`, + `librsvg2-dev`, `libsoup-3.0-dev`, `build-essential` + +The TLS backend is chosen per target: schannel on Windows (no bundled C crypto +library, and the machine's own trust store), rustls elsewhere. + +Installed-mod detection finds Reforger's addon store on both platforms: the +(often redirected) `Documents` directory on Windows, read from the shell folder +registry, and the Proton prefix inside each Steam library on Linux — there is no +native Linux client, so the game's `Documents` is +`/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents`. +`RFM_ADDONS_DIR` overrides the search for an install neither layout describes. + +## Development + +```sh +npm install +npm run tauri dev # run the app +``` + +Frontend alone, in a browser (IPC calls will fail, but layout iterates fast): + +```sh +npm run dev +``` + +## Checks + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test -p rfm-core # 168 unit tests, no network +npm run check # tsc --noEmit +npm test # 82 tests, including a jsdom shell smoke test +scripts/version.sh check # the four version copies agree +``` + +Contract checks against the live API, ignored by default: + +```sh +# Serially: the public rate limit is 60 requests/minute, and these tests +# fetch server detail in loops. +cargo test -p rfm-core --test live_api -- --ignored --test-threads=1 +``` + +To exercise the mod scanner against a real Arma Reforger addon store: + +```sh +RFM_ADDONS_DIR="$HOME/Documents/My Games/ArmaReforger/addons" \ + cargo test -p rfm-core --test live_api -- --ignored readiness_against_a_real +``` + +The ping tests send ICMP echo requests to live game servers; they are part of the +same ignored-by-default suite. + +## Build + +```sh +npm run tauri build +``` + +Produces a per-user NSIS installer under `target/release/bundle/nsis/`, plus a +detached `.sig` when an updater signing key is present in the environment. MSI is +deliberately not built: it needs admin rights and doubles the release for an +audience a game utility does not have. + +Releases are cut from a `v*` tag by `.github/workflows/release.yml`, which builds +on Windows, signs, and publishes to `dl.reforgermods.net`. The version lives in +one place and is propagated by `scripts/version.sh`: + +```sh +scripts/version.sh show # the canonical version +scripts/version.sh set 0.1.1 # rewrite all four copies +``` + +The full procedure, the signing state and the R2 layout are in +[`docs/distribution.md`](docs/distribution.md). + +### Cross-building a Windows binary from Linux + +No Visual Studio, and no root: + +```sh +scripts/build-windows.sh # debug, shows a console with diagnostics +scripts/build-windows.sh --release # optimised, no console +``` + +`cargo-xwin` fetches the MSVC CRT and Windows SDK from Microsoft into +`~/.cache/cargo-xwin`, and LLVM's `clang-cl`/`lld-link` replace `cl.exe` and +`link.exe`; the script unpacks those from Debian packages into a local prefix. +The result is a standalone `.exe` — the frontend is embedded at compile time — +that needs only the WebView2 runtime, which ships with current Windows. + +### Building an Arch Linux package + +```sh +scripts/build-arch.sh # target/arch/*.pkg.tar.zst +scripts/build-arch.sh --no-check # skip the rfm-core test suite +``` + +The build runs inside an `archlinux:base-devel` container, so the binary links +Arch's own glibc and WebKit rather than the build machine's. This is not +fussiness: a Tauri binary built on Debian links `libwebkit2gtk-4.1` against +Debian's ABI, and handing that to an Arch user produces a blank window or a +missing-symbol crash. Docker is only being used as a clean Arch userspace — +nothing is deployed. + +The tester installs it with `sudo pacman -U `; pacman pulls +`webkit2gtk-4.1` and the rest from the declared dependencies. + +Two things the PKGBUILD has to work around, both of which fail late and +confusingly if forgotten: + +- `options=('!lto')` — makepkg puts `-flto=auto` in `CFLAGS`/`LDFLAGS` by + default, which makes `aws-lc-sys` (the C crypto behind rustls) compile to GCC + LTO bitcode that the linker cannot resolve. The build dies at the final link + with a page of undefined `aws_lc_*` symbols. Rust-level LTO is unaffected; it + comes from `[profile.release]`. +- `cmake` in `makedepends` — `aws-lc-sys` needs it, and `base-devel` does not + include it. + +`build()` asserts the result is a production build before packaging, for the +same reason `build-windows.sh` does: a dev build installs perfectly and then +shows `ERR_CONNECTION_REFUSED` on the tester's machine. + +### Nvidia under Wayland + +WebKitGTK's DMABUF renderer and the proprietary Nvidia driver disagree about +buffer import, and GTK aborts before the window is ever mapped: + +```text +Gdk-Message: Error 71 (Protocol error) dispatching to Wayland display. +``` + +`main.rs` detects that combination at startup and sets +`WEBKIT_DISABLE_DMABUF_RENDERER=1` for itself. The detection is deliberately +narrow — Nvidia loaded, a Wayland session, GDK not already pinned to X11, and no +existing value to override — because the fallback costs GPU acceleration for +anyone who does not need it. `should_disable_dmabuf` is a pure function with +tests covering each condition. + +To override, set the variable yourself; any value is honoured, including `0`. + +## Direct joining + +Given a server UUID, the launcher builds: + +```text +{"id":"","inviteToken":""} + -> UTF-8 bytes + -> standard base64 + -> trailing "=" removed + -> steam://run/1874880/// +``` + +### The doubled slash + +`steam://run////` is Steam's form for passing launch arguments, and +the **empty path segment** is what marks the payload as one. With a single +slash Steam can start the game without forwarding the payload, so the player +lands on the main menu and the join request is silently lost. A test pins this +shape, because the difference is one character and the failure looks like a +successful launch. + +**Not yet confirmed against a live client.** The single-slash form was observed +to launch without joining; the doubled slash is the current best understanding +of the fix. + +A join only carries the payload when the game actually starts. If Arma Reforger +is already running, Steam may focus the existing process and drop the arguments, +so this must be tested from a fully closed game. + +`steam://run/1874880` (no payload) is the plain "Launch Arma Reforger" action and +is unaffected. + +### An alternative route + +The game also registers its own URL protocol. Its Steam install script +(`steamapps/common/Arma Reforger/installscript.vdf`) writes: + +```text +HKCU\SOFTWARE\Classes\ArmaReforger\shell\open\command + "\ArmaReforger_BE.exe" -exe ArmaReforgerSteam.exe + "-addonsDir=\addons" -uri="%1" +``` + +`reforger_join_uri()` builds `ArmaReforger://`, which reaches the +launcher as `-uri=` without depending on how Steam forwards arguments. It is +implemented, tested and unused — a second candidate if the Steam form does not +join. + +This lives in [`crates/rfm-core/src/launch.rs`](crates/rfm-core/src/launch.rs) +and is pure and directly tested, including against the verified reference +vector. No shell is involved at any point: the URI is handed to the platform's +URI opener, and the only value interpolated into it is a `ServerId`, which cannot +be constructed without passing UUID validation. + +## Ping + +A browser cannot measure round-trip latency to a game server; a native launcher +can, and this is one of the reasons for building one. + +**TCP connect timing was tried and rejected.** Connecting to a server's UDP game +port "succeeded" in 12–18 ms — and so did port 9 and port 47123, which are +closed. Some middlebox completes every handshake, so TCP timing yields +confident-looking numbers that are entirely fictional. + +ICMP echo works. Measured against 12 live servers, 11 answered, with latencies +coherent by region (Los Angeles 41 ms, New York 58–83 ms, Frankfurt 154–184 ms, +Sydney 173–179 ms). The launcher sends three probes and displays the **median**, +so one delayed packet does not move the figure. + +- Windows uses `IcmpSendEcho` via the IP Helper API — **no elevation required**. +- Unix uses an unprivileged ICMP datagram socket. +- The **selected** server is measured exactly, in the inspector. +- The **browser column** shows latency to each server's *region*. `/v2/servers` + publishes no per-server address — only `/v2/servers/{id}` does — so pinging a + 100-row page would cost 100 detail requests against a 60/minute limit. Every + row does carry `pingSiteId`, and there are only eight sites, so eight probes + cover the list. Region and server latency agree within roughly 20 ms, which is + enough to sort by and is what the game's own browser shows. +- Roughly a third of servers filter ICMP. Those show "no reply" rather than a + region-level estimate dressed up as a measurement. + +## Security posture + +- Everything from the API — server names, scenario names, mod names, URLs — is + treated as untrusted. It is rendered as text content and never interpolated + into a shell command, a URI, or markup. +- Server ids are validated before they reach a URL, on both the launch path and + the "open on reforgermods.net" path. The latter builds its URL from the + validated id rather than from the API's own `modURL` field. +- No API key or credential is embedded. The launcher is a public client and uses + anonymous access only. The one value it sends that persists across requests is + a random install identifier, visible and resettable in Settings; see + [`docs/client-identification.md`](docs/client-identification.md). +- The API origin is not user-configurable, so a tampered settings file cannot + redirect requests — or the install identifier — to another host. +- Server addresses from the API are validated to public literal IPs before any + packet is sent, so remote data cannot drive DNS or probe the user's LAN. +- Image URLs are checked against a single-host allowlist before rendering, with + the page CSP enforcing the same list independently. +- The UI is granted `core:default` and nothing else — no filesystem, shell, HTTP + or opener permission. URIs are opened from Rust, after validation. +- A strict CSP allows `'self'` plus scenario thumbnails from the Bohemia CDN. + +## Relationship to reforgermods.net + +This is a client. All server and mod data comes from the public +`https://api.reforgermods.net` v2 API; the launcher holds no data of its own +beyond local preferences and caches, and duplicates no backend logic. See +[`docs/api-contract.md`](docs/api-contract.md). diff --git a/brand/reforger-mods-app-icon.png b/brand/reforger-mods-app-icon.png new file mode 100644 index 0000000..3a2e82c Binary files /dev/null and b/brand/reforger-mods-app-icon.png differ diff --git a/brand/reforger-mods-icon-dark.png b/brand/reforger-mods-icon-dark.png new file mode 100644 index 0000000..24bcf49 Binary files /dev/null and b/brand/reforger-mods-icon-dark.png differ diff --git a/crates/rfm-core/Cargo.toml b/crates/rfm-core/Cargo.toml new file mode 100644 index 0000000..9028764 --- /dev/null +++ b/crates/rfm-core/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "rfm-core" +description = "Core logic for the reforgermods.net launcher: API client, Steam launch URIs, local state." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +reqwest.workspace = true +tokio.workspace = true +base64.workspace = true +uuid = { workspace = true } +sha2 = { workspace = true } + +# TLS backend, chosen per target. +# +# Windows gets schannel through native-tls: no bundled C crypto library to +# build (reqwest's rustls path pulls aws-lc-sys, which needs cmake and nasm on +# Windows), a smaller binary, and the trust store the machine already manages — +# which matters behind corporate TLS inspection. +# +# Everywhere else uses reqwest's default rustls stack, so a Linux or macOS +# checkout builds without OpenSSL development headers. +[target.'cfg(windows)'.dependencies] +reqwest = { workspace = true, features = ["native-tls"] } +# Reads HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid for install identity. +winreg = "0.56.0" +# IcmpSendEcho through the IP Helper API: no elevation required. +winping = "0.10.1" + +[target.'cfg(not(windows))'.dependencies] +reqwest = { workspace = true, features = ["default-tls"] } + +[target.'cfg(unix)'.dependencies] +# Unprivileged ICMP datagram sockets. +surge-ping = "0.9.1" + +[dev-dependencies] +tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/crates/rfm-core/src/api/client.rs b/crates/rfm-core/src/api/client.rs new file mode 100644 index 0000000..aac49ec --- /dev/null +++ b/crates/rfm-core/src/api/client.rs @@ -0,0 +1,576 @@ +//! HTTP client for the reforgermods.net public API. +//! +//! All network access in the launcher goes through this type. Tauri commands +//! call it; nothing else constructs requests. +//! +//! The launcher is a public, untrusted client. It sends no credentials and no +//! API key — only a `User-Agent` identifying the build. Do not add privileged +//! headers here: the binary ships to users and anything embedded in it is +//! readable. + +use std::sync::Arc; +use std::time::Duration; + +use crate::api::error::{ApiError, ApiErrorBody}; +use crate::api::models::{ + DatasetMeta, FleetHistory, LauncherStatus, PingSitesResponse, ServerDetailResponse, + ServerHistory, ServersPage, StatusReport, +}; +use crate::api::query::{HistoryRange, ServerQuery}; +use crate::cache::{ResponseCache, Ttl}; +use crate::launch::ServerId; + +/// Production API base URL. The launcher is powered by this API and ships +/// pointed at it. +pub const DEFAULT_BASE_URL: &str = "https://api.reforgermods.net"; + +/// Development-only override for the API origin. +/// +/// Deliberately an environment variable rather than a setting: a value in the +/// settings file could be edited to redirect every request — including the +/// install identifier — at an arbitrary host, and nothing in the product needs +/// end users to change it. +pub const BASE_URL_ENV: &str = "RFM_API_BASE_URL"; + +/// Header carrying the anonymous installation identifier. +/// +/// Lets the API count distinct installs rather than only requests. See +/// `docs/client-identification.md`. +pub const INSTALL_ID_HEADER: &str = "X-Launcher-Install-Id"; + +/// Per-request deadline, including connection setup and body read. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(12); +/// Connection-only deadline, so an unreachable host fails fast rather than +/// burning the whole request budget. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// Attempts beyond the first. Bounded on purpose: there is no retry loop here +/// that can run indefinitely. +const MAX_RETRIES: u32 = 2; +/// Base delay for exponential backoff between retries. +const RETRY_BACKOFF: Duration = Duration::from_millis(250); + +/// A client for the reforgermods.net v2 API. +/// +/// Cheap to clone: the inner `reqwest::Client` and cache are shared. +#[derive(Clone)] +pub struct ApiClient { + http: reqwest::Client, + base_url: Arc, + cache: Arc, +} + +/// The API origin this build talks to: production, unless `RFM_API_BASE_URL` +/// overrides it for development. +pub fn configured_base_url() -> String { + std::env::var(BASE_URL_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()) +} + +impl ApiClient { + /// Builds a client for the configured origin, identifying as `install_id`. + /// + /// `install_id` is the anonymous per-install identifier from + /// [`crate::store::Store::install_id`]. An empty or malformed value is + /// simply not sent, so a corrupted state file degrades to an unidentified + /// client rather than sending garbage. + pub fn new(install_id: &str) -> Result { + Self::with_base_url(&configured_base_url(), install_id) + } + + /// Builds a client against an explicit origin. Tests and the dev override + /// use this; product code calls [`ApiClient::new`]. + pub fn with_base_url(base_url: &str, install_id: &str) -> Result { + let base_url = normalise_base_url(base_url)?; + + let mut headers = reqwest::header::HeaderMap::new(); + if let Some(value) = install_id_header_value(install_id) { + headers.insert(INSTALL_ID_HEADER, value); + } + + let builder = reqwest::Client::builder().default_headers(headers); + // On Windows the TLS backend is schannel, which uses the machine's own + // trust store. See the target-specific reqwest features in Cargo.toml. + #[cfg(windows)] + let builder = builder.use_native_tls(); + + let http = builder + .user_agent(crate::user_agent()) + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + // The launcher makes a handful of requests to one host; a small pool + // kept alive is what keeps idle resource use low. + .pool_max_idle_per_host(4) + .build() + .map_err(ApiError::Build)?; + Ok(Self { + http, + base_url: base_url.into(), + cache: Arc::new(ResponseCache::default()), + }) + } + + /// The base URL this client targets. + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// Drops every cached response. Called when settings change the base URL. + pub fn clear_cache(&self) { + self.cache.clear(); + } + + /// `GET /v2/servers` with the given filters applied. + /// + /// Dropping the returned future cancels the request; Tauri does this when a + /// command is aborted, which is how a superseded search stops in flight. + pub async fn servers(&self, query: &ServerQuery) -> Result { + let path = "/v2/servers"; + let body = self + .get_text(path, &query.to_params(), Ttl::SNAPSHOT) + .await?; + decode(&body) + } + + /// `GET /v2/servers/{id}`. The id is validated before it reaches the URL. + pub async fn server(&self, id: &ServerId) -> Result { + let path = format!("/v2/servers/{id}"); + let body = self.get_text(&path, &[], Ttl::SNAPSHOT).await?; + decode(&body) + } + + /// `GET /v2/servers/{id}/history` for one range. + /// + /// Returns an empty `points` array for a server with no recorded history, + /// which is a normal state and not an error. + pub async fn server_history( + &self, + id: &ServerId, + range: HistoryRange, + ) -> Result { + let path = format!("/v2/servers/{id}/history"); + let params = [("range", range.as_param().to_string())]; + let body = self.get_text(&path, ¶ms, Ttl::HISTORY).await?; + decode(&body) + } + + /// Fleet-wide population, for the header's player total. + /// + /// The endpoint also returns a full time series; the launcher reads only the + /// `now` block. The narrowest range is requested to keep the response small. + pub async fn fleet_now(&self) -> Result { + let body = self + .get_text( + "/v2/servers/history", + &[("range", "6h".to_string())], + Ttl::HISTORY, + ) + .await?; + decode(&body) + } + + /// `GET /v2/servers/ping-sites`. Used to label regions; cached for an hour. + pub async fn ping_sites(&self) -> Result { + let body = self + .get_text("/v2/servers/ping-sites", &[], Ttl::STABLE) + .await?; + decode(&body) + } + + /// `GET /v2/status`, the public status contract. + /// + /// Returns [`ApiError::EndpointUnavailable`] when the deployment does not + /// serve the route, which is the current state of api.reforgermods.net. + pub async fn status(&self) -> Result { + let path = "/v2/status"; + // Deliberately uncached: a status reading must be current. + let body = self.request_text(path, &[]).await.map_err(|e| match e { + // A 404 on a fixed route means the route is not deployed, not that a + // resource is missing. + ApiError::NotFound { .. } + | ApiError::Api { + status: 404 | 501, .. + } => ApiError::EndpointUnavailable { + path: path.to_string(), + }, + other => other, + })?; + decode(&body) + } + + /// The compact status the launcher header renders. + /// + /// This never fails: an unreachable or partially available API produces an + /// unknown/offline reading rather than an error the UI has to handle. It also + /// does not reimplement any backend health logic — it only reports what the + /// public contract returned. + /// + /// `dataset_hint` is the [`DatasetMeta`] from the most recent server + /// response. Every server response carries snapshot freshness, so the + /// "server data updated Ns ago" line costs no extra request. + pub async fn launcher_status(&self, dataset_hint: Option) -> LauncherStatus { + match self.status().await { + Ok(report) => LauncherStatus { + api_reachable: true, + dataset: report.dataset.or(dataset_hint), + reported_status: report.status, + note: None, + }, + Err(ApiError::EndpointUnavailable { .. }) => { + // Fall back to the liveness probe, which is deployed. + let reachable = self.request_text("/v2/health", &[]).await.is_ok(); + LauncherStatus { + api_reachable: reachable, + dataset: dataset_hint, + reported_status: None, + note: Some( + "This API deployment does not serve /v2/status; \ + freshness is read from the server dataset." + .into(), + ), + } + } + Err(err) => LauncherStatus { + api_reachable: false, + dataset: dataset_hint, + reported_status: None, + note: Some(err.to_string()), + }, + } + } + + /// Cache-aware GET returning the raw body. + async fn get_text( + &self, + path: &str, + params: &[(&'static str, String)], + ttl: Ttl, + ) -> Result { + let key = cache_key(path, params); + if let Some(hit) = self.cache.get(&key, ttl) { + return Ok(hit); + } + let body = self.request_text(path, params).await?; + self.cache.put(&key, body.clone()); + Ok(body) + } + + /// GET with bounded retries. No caching, no deserialisation. + async fn request_text( + &self, + path: &str, + params: &[(&'static str, String)], + ) -> Result { + let url = format!("{}{path}", self.base_url); + let mut attempt = 0; + loop { + let result = self.attempt(&url, params).await; + match result { + Ok(body) => return Ok(body), + Err(err) if attempt < MAX_RETRIES && err.is_retryable() => { + // Fixed ceiling on attempts; this cannot spin. + tokio::time::sleep(RETRY_BACKOFF * 2u32.pow(attempt)).await; + attempt += 1; + } + Err(err) => return Err(err), + } + } + } + + /// One HTTP round trip, mapped onto [`ApiError`]. + async fn attempt( + &self, + url: &str, + params: &[(&'static str, String)], + ) -> Result { + let response = self.http.get(url).query(params).send().await.map_err(|e| { + if e.is_timeout() { + ApiError::Timeout + } else { + ApiError::Offline(e) + } + })?; + + let status = response.status(); + if status.is_success() { + return response.text().await.map_err(|e| { + if e.is_timeout() { + ApiError::Timeout + } else { + ApiError::Offline(e) + } + }); + } + + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + let retry_after = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()); + return Err(ApiError::RateLimited { retry_after }); + } + + // Read the documented error envelope when present; fall back to the + // status code when the body is something else (a proxy error page). + let body = response.text().await.unwrap_or_default(); + let detail = serde_json::from_str::(&body) + .ok() + .map(|b| b.error); + let (code, message, request_id) = match detail { + Some(d) => (d.code, d.message, d.request_id), + None => ( + String::new(), + format!("API returned HTTP {}", status.as_u16()), + String::new(), + ), + }; + + if status == reqwest::StatusCode::NOT_FOUND { + return Err(ApiError::NotFound { + message: if message.is_empty() { + "Not found.".into() + } else { + message + }, + }); + } + + Err(ApiError::Api { + status: status.as_u16(), + code, + message, + request_id, + }) + } +} + +/// Deserialises a response body into `T`. +fn decode(body: &str) -> Result { + serde_json::from_str(body).map_err(ApiError::Decode) +} + +/// Cache key for a request. Parameter order comes from +/// [`ServerQuery::to_params`], which is stable, so equal queries collide. +fn cache_key(path: &str, params: &[(&'static str, String)]) -> String { + let mut key = String::with_capacity(path.len() + params.len() * 16); + key.push_str(path); + for (i, (name, value)) in params.iter().enumerate() { + key.push(if i == 0 { '?' } else { '&' }); + key.push_str(name); + key.push('='); + key.push_str(value); + } + key +} + +/// The header value for `install_id`, or `None` when it is not a usable id. +/// +/// Validated against the same UUID shape the store writes, which also +/// guarantees the value is header-safe ASCII. +fn install_id_header_value(install_id: &str) -> Option { + let id = install_id.trim(); + if crate::launch::ServerId::parse(id).is_err() { + return None; + } + reqwest::header::HeaderValue::from_str(id).ok() +} + +/// Validates and normalises a base URL: absolute http(s), no trailing slash. +/// +/// Rejecting non-HTTP schemes here stops a settings file from pointing the +/// client at, say, a `file://` path. +fn normalise_base_url(raw: &str) -> Result { + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return Err(ApiError::InvalidBaseUrl("base URL is empty".into())); + } + let rest = trimmed + .strip_prefix("https://") + .or_else(|| trimmed.strip_prefix("http://")) + .ok_or_else(|| ApiError::InvalidBaseUrl(format!("{raw:?} is not an http(s) URL")))?; + // A host must be present and must not contain path or query separators. + if rest.is_empty() || rest.starts_with('/') { + return Err(ApiError::InvalidBaseUrl(format!("{raw:?} has no host"))); + } + if rest.contains(['?', '#', ' ']) { + return Err(ApiError::InvalidBaseUrl(format!( + "{raw:?} must be a bare origin, without query or fragment" + ))); + } + Ok(trimmed.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::query::ServerSort; + + #[test] + fn default_base_url_is_production() { + assert_eq!(DEFAULT_BASE_URL, "https://api.reforgermods.net"); + } + + const SAMPLE_INSTALL_ID: &str = "8f14e45f-ceea-467a-9c2b-1f2a3b4c5d6e"; + + #[test] + fn client_builds_against_the_production_base_url() { + let client = ApiClient::new(SAMPLE_INSTALL_ID).unwrap(); + assert_eq!(client.base_url(), "https://api.reforgermods.net"); + } + + #[test] + fn install_id_becomes_a_header_value() { + let value = install_id_header_value(SAMPLE_INSTALL_ID).expect("valid id"); + assert_eq!(value.to_str().unwrap(), SAMPLE_INSTALL_ID); + } + + #[test] + fn a_malformed_install_id_is_not_sent_at_all() { + // A corrupted state file must degrade to an unidentified client rather + // than putting arbitrary text into a request header. + for bad in [ + "", + " ", + "not-a-uuid", + "8f14e45f-ceea-467a-9c2b-1f2a3b4c5d6e +X-Injected: 1", + "../../etc/passwd", + ] { + assert!(install_id_header_value(bad).is_none(), "{bad:?}"); + } + } + + #[test] + fn a_client_without_an_install_id_still_builds() { + let client = ApiClient::new("").expect("must not fail"); + assert_eq!(client.base_url(), "https://api.reforgermods.net"); + } + + #[test] + fn the_base_url_is_not_user_configurable() { + // There is no setting for this; only the documented dev env override. + assert_eq!(BASE_URL_ENV, "RFM_API_BASE_URL"); + assert_eq!(configured_base_url(), DEFAULT_BASE_URL); + } + + #[test] + fn base_urls_are_normalised() { + for (input, expected) in [ + ( + "https://api.reforgermods.net/", + "https://api.reforgermods.net", + ), + ( + "https://api.reforgermods.net///", + "https://api.reforgermods.net", + ), + (" http://127.0.0.1:8000 ", "http://127.0.0.1:8000"), + ("http://localhost:8000/api", "http://localhost:8000/api"), + ] { + assert_eq!(normalise_base_url(input).unwrap(), expected, "{input}"); + } + } + + #[test] + fn hostile_base_urls_are_rejected() { + for input in [ + "", + " ", + "api.reforgermods.net", + "file:///etc/passwd", + "javascript:alert(1)", + "steam://run/1874880", + "https://", + "http:///nohost", + "https://host?x=1", + "https://host#frag", + "https://ho st", + ] { + assert!( + normalise_base_url(input).is_err(), + "should be rejected: {input:?}" + ); + } + } + + #[test] + fn user_agent_names_the_launcher_and_version() { + let ua = crate::user_agent(); + assert!(ua.starts_with("reforgermods.net-launcher/"), "{ua}"); + assert_eq!( + ua, + format!("reforgermods.net-launcher/{}", env!("CARGO_PKG_VERSION")) + ); + } + + #[test] + fn cache_keys_include_every_parameter() { + let q = ServerQuery { + search: "everon".into(), + sort: ServerSort::Name, + ..Default::default() + }; + let key = cache_key("/v2/servers", &q.to_params()); + assert_eq!( + key, + "/v2/servers?page=1&perPage=100&sort=name&search=everon" + ); + } + + #[test] + fn cache_keys_distinguish_different_queries() { + let a = ServerQuery::default(); + let b = ServerQuery { + search: "x".into(), + ..Default::default() + }; + assert_ne!( + cache_key("/v2/servers", &a.to_params()), + cache_key("/v2/servers", &b.to_params()) + ); + } + + #[test] + fn cache_keys_match_for_equal_queries() { + let a = ServerQuery { + official: Some(true), + min_players: 10, + ..Default::default() + }; + // min_players is client-side, so it must not split the cache. + let b = ServerQuery { + official: Some(true), + min_players: 50, + ..Default::default() + }; + assert_eq!( + cache_key("/v2/servers", &a.to_params()), + cache_key("/v2/servers", &b.to_params()) + ); + } + + #[test] + fn cache_key_for_a_pathless_request_has_no_question_mark() { + assert_eq!(cache_key("/v2/health", &[]), "/v2/health"); + } + + #[test] + fn retry_budget_is_bounded() { + // Guards against a future edit turning the retry loop unbounded. + assert_eq!(MAX_RETRIES, 2); + let total: Duration = (0..MAX_RETRIES).map(|a| RETRY_BACKOFF * 2u32.pow(a)).sum(); + assert!(total < Duration::from_secs(1), "backoff total {total:?}"); + } + + #[test] + fn server_detail_path_uses_a_validated_id() { + let id = ServerId::parse("73e8fb7f-8789-4d88-8aa8-ea69b9aa092f").unwrap(); + assert_eq!( + format!("/v2/servers/{id}"), + "/v2/servers/73e8fb7f-8789-4d88-8aa8-ea69b9aa092f" + ); + } +} diff --git a/crates/rfm-core/src/api/error.rs b/crates/rfm-core/src/api/error.rs new file mode 100644 index 0000000..7c6f219 --- /dev/null +++ b/crates/rfm-core/src/api/error.rs @@ -0,0 +1,151 @@ +//! Error type for the reforgermods.net API client. + +use serde::Deserialize; + +/// The error envelope reforgermods.net returns for non-2xx responses. +#[derive(Debug, Clone, Deserialize)] +pub struct ApiErrorBody { + pub error: ApiErrorDetail, +} + +/// The `error` object inside [`ApiErrorBody`]. +#[derive(Debug, Clone, Deserialize)] +pub struct ApiErrorDetail { + #[serde(default)] + pub code: String, + #[serde(default)] + pub message: String, + #[serde(default, rename = "requestId")] + pub request_id: String, +} + +/// Every way an API call can fail. +/// +/// Variants are deliberately distinct so the UI can choose sensible copy: +/// `Offline` and `Timeout` are "try again", `RateLimited` is "wait", `NotFound` +/// is "this server is gone", and `Api` carries a server-authored message. +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + /// The request never reached the API (DNS, TLS, connection refused). + #[error("could not reach the reforgermods.net API")] + Offline(#[source] reqwest::Error), + + /// The request exceeded its deadline. + #[error("request to the reforgermods.net API timed out")] + Timeout, + + /// A 404 for a resource that may simply no longer be indexed. + #[error("{message}")] + NotFound { message: String }, + + /// The requested route does not exist on this deployment. Distinct from + /// [`ApiError::NotFound`] so callers can degrade a feature rather than + /// reporting a missing server. + #[error("endpoint {path} is not available on this API deployment")] + EndpointUnavailable { path: String }, + + /// 429. `retry_after` is the `Retry-After` value in seconds when supplied. + #[error("rate limited by the reforgermods.net API")] + RateLimited { retry_after: Option }, + + /// Any other non-2xx response carrying the documented error envelope. + #[error("{message} ({code})")] + Api { + status: u16, + code: String, + message: String, + request_id: String, + }, + + /// A 2xx response whose body did not match the expected model. + #[error("unexpected response shape from the reforgermods.net API")] + Decode(#[source] serde_json::Error), + + /// The client could not be constructed (TLS backend, proxy config). + #[error("could not build the API client")] + Build(#[source] reqwest::Error), + + /// A configured base URL was not a usable absolute HTTP(S) URL. + #[error("invalid API base URL: {0}")] + InvalidBaseUrl(String), +} + +impl ApiError { + /// True when retrying the same request could plausibly succeed. + pub fn is_retryable(&self) -> bool { + match self { + ApiError::Offline(_) | ApiError::Timeout => true, + // 5xx is worth one more attempt; 4xx is not. + ApiError::Api { status, .. } => *status >= 500, + _ => false, + } + } + + /// A stable machine-readable kind, for the UI to branch on without + /// matching on human-readable text. + pub fn kind(&self) -> &'static str { + match self { + ApiError::Offline(_) => "offline", + ApiError::Timeout => "timeout", + ApiError::NotFound { .. } => "not_found", + ApiError::EndpointUnavailable { .. } => "endpoint_unavailable", + ApiError::RateLimited { .. } => "rate_limited", + ApiError::Api { .. } => "api", + ApiError::Decode(_) => "decode", + ApiError::Build(_) => "build", + ApiError::InvalidBaseUrl(_) => "config", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_envelope_parses() { + let raw = r#"{"error":{"code":"SERVER_NOT_FOUND","message":"Server was not found in the current dataset.","requestId":"91288cfd10bc327410219cb9"}}"#; + let body: ApiErrorBody = serde_json::from_str(raw).unwrap(); + assert_eq!(body.error.code, "SERVER_NOT_FOUND"); + assert_eq!(body.error.request_id, "91288cfd10bc327410219cb9"); + } + + #[test] + fn error_envelope_tolerates_missing_fields() { + let body: ApiErrorBody = serde_json::from_str(r#"{"error":{}}"#).unwrap(); + assert_eq!(body.error.code, ""); + assert_eq!(body.error.message, ""); + } + + #[test] + fn retryability_follows_status_class() { + let server_err = ApiError::Api { + status: 503, + code: "SERVERS_UNAVAILABLE".into(), + message: "temporarily unavailable".into(), + request_id: String::new(), + }; + assert!(server_err.is_retryable()); + assert!(ApiError::Timeout.is_retryable()); + assert!(!ApiError::NotFound { + message: "gone".into() + } + .is_retryable()); + assert!(!ApiError::RateLimited { + retry_after: Some(5) + } + .is_retryable()); + } + + #[test] + fn kinds_are_stable_slugs() { + assert_eq!(ApiError::Timeout.kind(), "timeout"); + assert_eq!( + ApiError::EndpointUnavailable { + path: "/v2/status".into() + } + .kind(), + "endpoint_unavailable" + ); + } +} diff --git a/crates/rfm-core/src/api/mod.rs b/crates/rfm-core/src/api/mod.rs new file mode 100644 index 0000000..d1071f6 --- /dev/null +++ b/crates/rfm-core/src/api/mod.rs @@ -0,0 +1,14 @@ +//! The reforgermods.net API client layer. +//! +//! HTTP lives here and nowhere else. Tauri commands and the UI never build +//! requests themselves, so timeouts, retries, caching and error mapping have +//! exactly one implementation. + +mod client; +mod error; +pub mod models; +mod query; + +pub use client::{ApiClient, DEFAULT_BASE_URL}; +pub use error::{ApiError, ApiErrorBody, ApiErrorDetail}; +pub use query::{ClientPlatform, HistoryRange, ServerQuery, ServerSort, MAX_PER_PAGE}; diff --git a/crates/rfm-core/src/api/models.rs b/crates/rfm-core/src/api/models.rs new file mode 100644 index 0000000..fe8e9f6 --- /dev/null +++ b/crates/rfm-core/src/api/models.rs @@ -0,0 +1,486 @@ +//! Typed models for the reforgermods.net v2 API. +//! +//! Models are deliberately tolerant: unknown fields are ignored and absent +//! fields fall back to defaults. The API is free to add fields, and a launcher +//! that hard-fails on an unrecognised key would break on every API release. +//! +//! Every string in here is untrusted remote text. Nothing in this module +//! validates it; the only value that ever needs validating is a server id, and +//! that happens in [`crate::launch`] at the point of use. + +use serde::{Deserialize, Serialize}; + +/// Freshness of the indexer snapshot backing the server dataset. +/// +/// Present on every server response, which is why the launcher does not need a +/// separate poll to render "server data updated Ns ago". +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DatasetMeta { + /// True on cold start, before the indexer has produced a first snapshot. + #[serde(default)] + pub warming: bool, + /// True when the API considers the snapshot too old to be current. + #[serde(default)] + pub stale: bool, + /// Age of the snapshot in seconds. + #[serde(default)] + pub snapshot_age_seconds: f64, + /// RFC 3339 timestamp of the collection that produced the snapshot. + #[serde(default)] + pub last_collection_at: Option, + /// Total servers ever indexed. + #[serde(default)] + pub server_count: u64, + #[serde(default)] + pub online_server_count: u64, + #[serde(default)] + pub offline_server_count: u64, +} + +/// Pagination metadata for a server list page. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerListMeta { + #[serde(default)] + pub total_pages: u32, + #[serde(default)] + pub current_page: u32, + #[serde(default)] + pub total_servers: u32, + #[serde(default)] + pub shown_servers: u32, +} + +/// reforgermods.net's derived ranking for a server. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerAnalytics { + #[serde(default)] + pub rank: Option, + #[serde(default)] + pub overall_score: Option, + #[serde(default)] + pub activity_score: Option, + #[serde(default)] + pub reliability_score: Option, + #[serde(default)] + pub confidence: Option, +} + +/// A server as it appears in the browser list. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerSummary { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub scenario_id: String, + #[serde(default)] + pub scenario_name: String, + #[serde(default)] + pub scenario_thumbnail_url: Option, + #[serde(default)] + pub game_version: String, + #[serde(default)] + pub official: bool, + #[serde(default)] + pub password_protected: bool, + #[serde(default, rename = "battlEye")] + pub battle_eye: bool, + #[serde(default)] + pub cross_play: bool, + #[serde(default)] + pub players: u32, + #[serde(default)] + pub max_players: u32, + #[serde(default)] + pub queue: u32, + #[serde(default)] + pub queue_max: u32, + /// Nitrado ping-site id, e.g. `frankfurt`. Join with [`PingSite`] for a label. + #[serde(default)] + pub ping_site_id: Option, + #[serde(default)] + pub region: Option, + /// Host OS the server runs on (`LINUX`/`WINDOWS`), *not* a client platform. + #[serde(default)] + pub platform: Option, + /// Client platforms that may join: a subset of `pc`, `xbox`, `psn`. + #[serde(default)] + pub platforms: Vec, + #[serde(default)] + pub mod_count: u32, + #[serde(default)] + pub online: bool, + #[serde(default)] + pub first_seen: Option, + #[serde(default)] + pub last_seen: Option, + #[serde(default)] + pub analytics: Option, + /// The API's own `steam://run/...` join URI. + /// + /// Parsed for cross-checking only. The launcher builds its own URI from the + /// validated id: an API-supplied URL must never be handed to the OS. + #[serde(default)] + pub join_url: Option, +} + +/// `GET /v2/servers` +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ServersPage { + #[serde(default)] + pub meta: ServerListMeta, + #[serde(default)] + pub dataset: DatasetMeta, + #[serde(default)] + pub data: Vec, +} + +/// Aggregate player activity derived from history samples. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerActivity { + #[serde(default)] + pub peak24h: u32, + #[serde(default)] + pub avg24h: u32, + #[serde(default)] + pub median24h: u32, + #[serde(default)] + pub low24h: u32, + #[serde(default)] + pub peak7d: u32, + #[serde(default)] + pub samples24h: u32, + #[serde(default)] + pub tracking_since: Option, +} + +/// Totals for a server's reported mod set. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerModSummary { + #[serde(default)] + pub count: u32, + /// Sum of sizes the API could resolve, in bytes. Not the full download size + /// unless `unresolved_count` is zero. + #[serde(default)] + pub known_size: u64, + #[serde(default)] + pub known_size_text: Option, + /// Mods whose exact-version size could not be resolved. + #[serde(default)] + pub unresolved_count: u32, +} + +/// One mod a server reports requiring. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerMod { + /// Workshop mod id: 16 uppercase hex characters. + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub size: u64, + #[serde(default)] + pub size_text: Option, + #[serde(default)] + pub size_known: bool, + /// reforgermods.net page for this mod. Untrusted; validate before opening. + #[serde(default)] + pub mod_url: Option, +} + +/// A server with connection, activity and mod detail. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerDetail { + /// Everything the list view already knows. + #[serde(flatten)] + pub summary: ServerSummary, + /// True when the server is in the current snapshot. False means "not + /// currently listed", which is not the same as "offline". + #[serde(default)] + pub present: bool, + #[serde(default)] + pub host_type: Option, + #[serde(default)] + pub joinable: bool, + /// The server's address, as published by the API. Untrusted: validate with + /// [`crate::ping::parse_target`] before doing anything with it. Absent for + /// roughly a third of servers. + #[serde(default)] + pub host: Option, + /// Game port. Informational only — nothing connects to it. + #[serde(default)] + pub port: u16, + /// Server-reported simulation FPS. 0 when not reported. + #[serde(default)] + pub fps: u32, + #[serde(default)] + pub activity: Option, + #[serde(default)] + pub mod_summary: ServerModSummary, + #[serde(default)] + pub mods: Vec, +} + +/// `GET /v2/servers/{id}` +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ServerDetailResponse { + #[serde(default)] + pub dataset: DatasetMeta, + pub server: ServerDetail, +} + +/// A Nitrado ping location servers are grouped under. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PingSite { + pub id: String, + #[serde(default)] + pub label: String, + #[serde(default)] + pub region_group: String, + /// Hostname used for latency probes. Not contacted in milestone 1. + #[serde(default)] + pub address: Option, +} + +/// `GET /v2/servers/ping-sites` +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct PingSitesResponse { + #[serde(default)] + pub updated_at: Option, + #[serde(default)] + pub sites: Vec, +} + +/// One bucket of a server's population history. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ServerHistoryPoint { + /// Bucket start, unix seconds. + pub t: i64, + #[serde(default)] + pub avg: u32, + #[serde(default)] + pub min: u32, + #[serde(default)] + pub max: u32, + /// Slot capacity during the bucket. 0 when not recorded. + #[serde(default)] + pub cap: u32, +} + +/// `GET /v2/servers/{id}/history` +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerHistory { + #[serde(default)] + pub server_id: String, + /// Range the API actually served, which may differ from what was asked. + #[serde(default)] + pub range: String, + /// Bucket width the API chose: `5m`, `hour` or `day`. + #[serde(default)] + pub bucket: String, + #[serde(default)] + pub points: Vec, +} + +/// Fleet-wide population right now, from `GET /v2/servers/history`. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FleetNow { + #[serde(default)] + pub online_servers: u64, + /// Players across every listed server. + #[serde(default)] + pub players: u64, + /// Sum of slot capacity across those servers. + #[serde(default)] + pub capacity: u64, + #[serde(default)] + pub queue: u64, +} + +/// `GET /v2/servers/history`, of which the launcher reads only `now`. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct FleetHistory { + #[serde(default)] + pub now: FleetNow, +} + +/// `GET /v2/status`, parsed permissively. +/// +/// This route is not deployed on api.reforgermods.net yet (it answers 404 as of +/// 2026-09-24), so every field is optional and the launcher treats an absent +/// route as "status unknown" rather than an error. See `docs/api-contract.md`. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StatusReport { + /// Overall state word when the API supplies one, e.g. `operational`. + #[serde(default)] + pub status: Option, + #[serde(default)] + pub dataset: Option, +} + +/// What the launcher header renders. Assembled from whichever sources answered, +/// so a missing `/v2/status` degrades to an unobtrusive unknown state instead of +/// an error banner. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LauncherStatus { + /// True when the API answered a health or status probe. + pub api_reachable: bool, + /// Freshness of the server dataset, when known. + pub dataset: Option, + /// Set when `/v2/status` answered and named a state. + pub reported_status: Option, + /// Human-readable reason the status is unknown, for a tooltip. + pub note: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixtures are real responses captured from api.reforgermods.net. + const SERVERS_PAGE: &str = include_str!("../../tests/fixtures/servers_page.json"); + const SERVER_DETAIL: &str = include_str!("../../tests/fixtures/server_detail.json"); + const PING_SITES: &str = include_str!("../../tests/fixtures/ping_sites.json"); + + #[test] + fn servers_page_parses_from_live_fixture() { + let page: ServersPage = serde_json::from_str(SERVERS_PAGE).unwrap(); + assert_eq!(page.data.len(), 3); + assert_eq!(page.meta.current_page, 1); + assert!(page.meta.total_servers > 0); + assert!(!page.dataset.warming); + assert!(page.dataset.last_collection_at.is_some()); + + let first = &page.data[0]; + assert!(!first.id.is_empty()); + assert!(!first.name.is_empty()); + assert!(first.online); + assert!(first.max_players > 0); + // Sorted by players descending. + assert!(first.players >= page.data[1].players); + } + + #[test] + fn battl_eye_field_name_is_mapped() { + // The API spells this `battlEye`; a wrong rename would silently read + // false for every server. + let page: ServersPage = serde_json::from_str(SERVERS_PAGE).unwrap(); + let raw: serde_json::Value = serde_json::from_str(SERVERS_PAGE).unwrap(); + for (parsed, raw) in page.data.iter().zip(raw["data"].as_array().unwrap()) { + assert_eq!( + parsed.battle_eye, + raw["battlEye"].as_bool().unwrap_or(false) + ); + } + } + + #[test] + fn server_detail_parses_from_live_fixture() { + let res: ServerDetailResponse = serde_json::from_str(SERVER_DETAIL).unwrap(); + let s = &res.server; + assert_eq!(s.summary.id, "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f"); + assert!(s.present); + // Flattened summary fields must survive the flatten. + assert!(!s.summary.name.is_empty()); + assert!(s.summary.max_players > 0); + assert!(s.summary.mod_count > 0); + // Detail-only fields. + assert!(s.host.is_some(), "this fixture publishes an address"); + assert!(s.port > 0); + assert_eq!(s.mods.len() as u32, s.mod_summary.count); + assert!(s.activity.is_some()); + assert!(s.mods.iter().all(|m| m.id.len() == 16)); + } + + #[test] + fn ping_sites_parse_from_live_fixture() { + let res: PingSitesResponse = serde_json::from_str(PING_SITES).unwrap(); + assert!(res.sites.len() >= 8); + let fra = res.sites.iter().find(|s| s.id == "frankfurt").unwrap(); + assert_eq!(fra.label, "Frankfurt"); + assert_eq!(fra.region_group, "Europe"); + } + + #[test] + fn unknown_fields_are_ignored() { + // A future API release adding fields must not break the launcher. + let raw = r#"{"meta":{"currentPage":2,"brandNew":1},"dataset":{},"data":[ + {"id":"a","name":"n","somethingAdded":{"deep":true}} + ],"extraTopLevel":"x"}"#; + let page: ServersPage = serde_json::from_str(raw).unwrap(); + assert_eq!(page.meta.current_page, 2); + assert_eq!(page.data[0].id, "a"); + } + + #[test] + fn missing_optional_fields_fall_back_to_defaults() { + let page: ServersPage = serde_json::from_str(r#"{"data":[{"id":"a"}]}"#).unwrap(); + let s = &page.data[0]; + assert_eq!(s.players, 0); + assert!(!s.official); + assert!(s.platforms.is_empty()); + assert!(s.ping_site_id.is_none()); + assert!(s.analytics.is_none()); + } + + #[test] + fn an_absent_host_is_none_rather_than_an_empty_string() { + // About a third of servers publish no address; the ping code branches + // on None, so it must not arrive as Some(""). + let res: ServerDetailResponse = serde_json::from_str(r#"{"server":{"id":"a"}}"#).unwrap(); + assert!(res.server.host.is_none()); + assert_eq!(res.server.port, 0); + } + + #[test] + fn history_parses_from_live_fixture() { + const HISTORY: &str = include_str!("../../tests/fixtures/server_history.json"); + let h: ServerHistory = serde_json::from_str(HISTORY).unwrap(); + assert_eq!(h.range, "24h"); + assert_eq!(h.bucket, "5m"); + assert!(h.points.len() > 100, "24h of 5m buckets"); + // Buckets must be ordered, or the chart draws backwards. + assert!(h.points.windows(2).all(|w| w[0].t <= w[1].t)); + for point in &h.points { + assert!( + point.min <= point.avg && point.avg <= point.max, + "{point:?}" + ); + assert!(point.t > 1_700_000_000, "unix seconds expected"); + } + } + + #[test] + fn history_tolerates_an_empty_series() { + // A server with no recorded history returns an empty array, not an error. + let h: ServerHistory = + serde_json::from_str(r#"{"serverId":"x","range":"7d","bucket":"hour"}"#).unwrap(); + assert!(h.points.is_empty()); + } + + #[test] + fn status_report_tolerates_an_unknown_shape() { + // The contract is not published yet; parsing must not hard-fail. + let r: StatusReport = serde_json::from_str(r#"{"whatever":true}"#).unwrap(); + assert!(r.status.is_none()); + let r: StatusReport = + serde_json::from_str(r#"{"status":"operational","dataset":{"stale":false}}"#).unwrap(); + assert_eq!(r.status.as_deref(), Some("operational")); + assert!(!r.dataset.unwrap().stale); + } +} diff --git a/crates/rfm-core/src/api/query.rs b/crates/rfm-core/src/api/query.rs new file mode 100644 index 0000000..d47118f --- /dev/null +++ b/crates/rfm-core/src/api/query.rs @@ -0,0 +1,409 @@ +//! Server browser query construction. +//! +//! Only filters the live API actually supports are modelled here. `/v2/servers` +//! accepts `page`, `perPage`, `search`, `sort`, `region`, `platform`, `online`, +//! `includeOffline`, `official`, `hasMods`, `battleye` and `locked`; anything +//! else would be silently ignored by the API and would produce a filter control +//! that appears to work but does not. +//! +//! [`ServerQuery::min_players`] is the one exception and is explicitly *not* +//! sent to the API: it is applied client-side by [`ServerQuery::retains`], +//! because no server-side equivalent exists. + +use serde::{Deserialize, Serialize}; + +/// Sort orders `/v2/servers` accepts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ServerSort { + /// Most players first. The useful default for a browser. + #[default] + Players, + Name, + Newest, + LastSeen, +} + +impl ServerSort { + /// The wire value for the `sort` parameter. + pub fn as_param(self) -> &'static str { + match self { + ServerSort::Players => "players", + ServerSort::Name => "name", + ServerSort::Newest => "newest", + ServerSort::LastSeen => "lastSeen", + } + } +} + +/// Client platforms the API can filter by. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ClientPlatform { + Pc, + Xbox, + Psn, +} + +impl ClientPlatform { + /// The wire value for the `platform` parameter. The API canonicalises these + /// short tokens itself. + pub fn as_param(self) -> &'static str { + match self { + ClientPlatform::Pc => "pc", + ClientPlatform::Xbox => "xbox", + ClientPlatform::Psn => "psn", + } + } +} + +/// Largest page the API permits. +pub const MAX_PER_PAGE: u32 = 500; + +/// A server browser query. +/// +/// `None` on a tri-state field means "do not filter", which is different from +/// `Some(false)`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ServerQuery { + /// Free-text search over server names. + pub search: String, + pub sort: ServerSort, + /// Ping-site id, e.g. `frankfurt`. See `GET /v2/servers/ping-sites`. + pub region: Option, + pub platform: Option, + /// Official Bohemia servers only / community only. + pub official: Option, + /// Servers reporting at least one mod / none. + pub has_mods: Option, + pub battleye: Option, + /// `locked` is the API's name for password-protected. + pub locked: Option, + /// Include servers not in the current snapshot. Off by default: an offline + /// server cannot be joined. + pub include_offline: bool, + /// Client-side only. Drops servers below this player count from the loaded + /// page; the API has no equivalent parameter. + pub min_players: u32, + pub page: u32, + pub per_page: u32, +} + +impl Default for ServerQuery { + fn default() -> Self { + Self { + search: String::new(), + sort: ServerSort::default(), + region: None, + platform: None, + official: None, + has_mods: None, + battleye: None, + locked: None, + include_offline: false, + min_players: 0, + page: 1, + per_page: 100, + } + } +} + +impl ServerQuery { + /// The `/v2/servers` query parameters for this query, in a stable order. + /// + /// Stable ordering matters: the serialised form is the cache key, so two + /// equal queries must produce byte-identical strings. + pub fn to_params(&self) -> Vec<(&'static str, String)> { + let mut params: Vec<(&'static str, String)> = Vec::with_capacity(12); + + params.push(("page", self.page.max(1).to_string())); + params.push(("perPage", self.per_page.clamp(1, MAX_PER_PAGE).to_string())); + params.push(("sort", self.sort.as_param().to_string())); + + let search = self.search.trim(); + if !search.is_empty() { + params.push(("search", search.to_string())); + } + if let Some(region) = self + .region + .as_deref() + .map(str::trim) + .filter(|r| !r.is_empty()) + { + params.push(("region", region.to_string())); + } + if let Some(platform) = self.platform { + params.push(("platform", platform.as_param().to_string())); + } + // Tri-state flags: only sent when the user actually chose a side. + for (name, value) in [ + ("official", self.official), + ("hasMods", self.has_mods), + ("battleye", self.battleye), + ("locked", self.locked), + ] { + if let Some(value) = value { + params.push((name, value.to_string())); + } + } + if self.include_offline { + params.push(("includeOffline", "true".to_string())); + } + params + } + + /// Whether a fetched server survives the client-side filters. + /// + /// Only `min_players` lives here — the one filter the API cannot express. + /// Everything else was already applied server-side, and re-checking it + /// locally would mean two filter implementations that could disagree. + pub fn retains(&self, players: u32) -> bool { + players >= self.min_players + } + + /// True when any filter beyond sort and pagination is active. Drives the + /// "clear filters" affordance. + pub fn has_active_filters(&self) -> bool { + // Mirrors `to_params`: a blank search or region is not sent to the API, + // so it must not count as an active filter either. + !self.search.trim().is_empty() + || self.region.as_deref().is_some_and(|r| !r.trim().is_empty()) + || self.platform.is_some() + || self.official.is_some() + || self.has_mods.is_some() + || self.battleye.is_some() + || self.locked.is_some() + || self.include_offline + || self.min_players > 0 + } +} + +/// Ranges `GET /v2/servers/{id}/history` accepts. +/// +/// The API rejects anything else with `INVALID_RANGE`, so this is an enum rather +/// than a free string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum HistoryRange { + SixHours, + /// The API's own default. + #[default] + OneDay, + ThreeDays, + OneWeek, + OneMonth, + ThreeMonths, + OneYear, + All, +} + +impl HistoryRange { + /// The wire value for the `range` parameter. + pub fn as_param(self) -> &'static str { + match self { + HistoryRange::SixHours => "6h", + HistoryRange::OneDay => "24h", + HistoryRange::ThreeDays => "72h", + HistoryRange::OneWeek => "7d", + HistoryRange::OneMonth => "30d", + HistoryRange::ThreeMonths => "90d", + HistoryRange::OneYear => "1y", + HistoryRange::All => "all", + } + } + + /// The ranges the inspector offers. A subset: 90d, 1y and all are supported + /// by the API but are not useful in a compact panel. + pub const OFFERED: [HistoryRange; 4] = [ + HistoryRange::SixHours, + HistoryRange::OneDay, + HistoryRange::OneWeek, + HistoryRange::OneMonth, + ]; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn param_string(q: &ServerQuery) -> String { + q.to_params() + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("&") + } + + #[test] + fn default_query_asks_for_the_busiest_online_servers() { + let q = ServerQuery::default(); + assert_eq!(param_string(&q), "page=1&perPage=100&sort=players"); + // includeOffline absent means online-only, which is the API default. + assert!(!q.has_active_filters()); + } + + #[test] + fn unset_tristate_filters_are_omitted_entirely() { + let q = ServerQuery::default(); + let keys: Vec<_> = q.to_params().into_iter().map(|(k, _)| k).collect(); + for absent in [ + "official", + "hasMods", + "battleye", + "locked", + "includeOffline", + ] { + assert!(!keys.contains(&absent), "{absent} should not be sent"); + } + } + + #[test] + fn false_is_distinct_from_unset() { + let q = ServerQuery { + official: Some(false), + has_mods: Some(false), + ..Default::default() + }; + assert!(param_string(&q).contains("official=false")); + assert!(param_string(&q).contains("hasMods=false")); + assert!(q.has_active_filters()); + } + + #[test] + fn all_filters_serialise_in_a_stable_order() { + let q = ServerQuery { + search: " conflict ".into(), + sort: ServerSort::Name, + region: Some("frankfurt".into()), + platform: Some(ClientPlatform::Pc), + official: Some(true), + has_mods: Some(true), + battleye: Some(true), + locked: Some(false), + include_offline: true, + min_players: 10, + page: 3, + per_page: 50, + }; + assert_eq!( + param_string(&q), + "page=3&perPage=50&sort=name&search=conflict®ion=frankfurt&platform=pc\ + &official=true&hasMods=true&battleye=true&locked=false&includeOffline=true" + ); + // Identical queries must serialise identically, so the cache key is stable. + assert_eq!(param_string(&q), param_string(&q.clone())); + } + + #[test] + fn min_players_is_never_sent_to_the_api() { + let q = ServerQuery { + min_players: 64, + ..Default::default() + }; + assert!(!param_string(&q).contains("min"), "{}", param_string(&q)); + assert!(q.has_active_filters()); + } + + #[test] + fn min_players_filters_client_side() { + let q = ServerQuery { + min_players: 10, + ..Default::default() + }; + assert!(!q.retains(0)); + assert!(!q.retains(9)); + assert!(q.retains(10)); + assert!(q.retains(128)); + // A zero threshold keeps everything, including empty servers. + assert!(ServerQuery::default().retains(0)); + } + + #[test] + fn history_ranges_match_the_api_vocabulary() { + // The API rejects anything outside this set with INVALID_RANGE. + let params: Vec<&str> = [ + HistoryRange::SixHours, + HistoryRange::OneDay, + HistoryRange::ThreeDays, + HistoryRange::OneWeek, + HistoryRange::OneMonth, + HistoryRange::ThreeMonths, + HistoryRange::OneYear, + HistoryRange::All, + ] + .iter() + .map(|r| r.as_param()) + .collect(); + assert_eq!( + params, + ["6h", "24h", "72h", "7d", "30d", "90d", "1y", "all"] + ); + // The default matches the API's own default. + assert_eq!(HistoryRange::default().as_param(), "24h"); + assert!(HistoryRange::OFFERED.contains(&HistoryRange::default())); + } + + #[test] + fn blank_search_and_region_are_dropped() { + let q = ServerQuery { + search: " ".into(), + region: Some(" ".into()), + ..Default::default() + }; + assert_eq!(param_string(&q), "page=1&perPage=100&sort=players"); + assert!(!q.has_active_filters()); + } + + #[test] + fn pagination_is_clamped_to_api_limits() { + let q = ServerQuery { + page: 0, + per_page: 9_999, + ..Default::default() + }; + assert!(param_string(&q).contains("page=1")); + assert!(param_string(&q).contains(&format!("perPage={MAX_PER_PAGE}"))); + + let q = ServerQuery { + per_page: 0, + ..Default::default() + }; + assert!(param_string(&q).contains("perPage=1")); + } + + #[test] + fn sort_and_platform_params_match_the_api_vocabulary() { + assert_eq!(ServerSort::Players.as_param(), "players"); + assert_eq!(ServerSort::LastSeen.as_param(), "lastSeen"); + assert_eq!(ServerSort::Newest.as_param(), "newest"); + assert_eq!(ServerSort::Name.as_param(), "name"); + assert_eq!(ClientPlatform::Pc.as_param(), "pc"); + assert_eq!(ClientPlatform::Xbox.as_param(), "xbox"); + assert_eq!(ClientPlatform::Psn.as_param(), "psn"); + } + + #[test] + fn query_round_trips_through_json() { + // The UI sends this struct across the Tauri boundary. + let q = ServerQuery { + search: "everon".into(), + sort: ServerSort::LastSeen, + platform: Some(ClientPlatform::Psn), + locked: Some(false), + min_players: 5, + ..Default::default() + }; + let json = serde_json::to_string(&q).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), q); + } + + #[test] + fn partial_json_from_the_ui_fills_in_defaults() { + let q: ServerQuery = serde_json::from_str(r#"{"search":"ru"}"#).unwrap(); + assert_eq!(q.search, "ru"); + assert_eq!(q.page, 1); + assert_eq!(q.per_page, 100); + assert_eq!(q.sort, ServerSort::Players); + } +} diff --git a/crates/rfm-core/src/cache.rs b/crates/rfm-core/src/cache.rs new file mode 100644 index 0000000..9b3b48e --- /dev/null +++ b/crates/rfm-core/src/cache.rs @@ -0,0 +1,164 @@ +//! A tiny bounded TTL cache for API response bodies. +//! +//! The launcher caches raw response text keyed by request URL rather than +//! deserialised structs: it keeps the cache free of generics, and re-parsing a +//! cached body costs microseconds against a network round trip. Entries are +//! capped so a long session browsing thousands of servers cannot grow without +//! bound. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// How long a cached entry stays fresh, per class of request. +#[derive(Debug, Clone, Copy)] +pub struct Ttl(pub Duration); + +impl Ttl { + /// Server list and detail pages. The indexer publishes a new snapshot about + /// once a minute; 15s keeps the view current without hammering the API when + /// the user flips between filters. + pub const SNAPSHOT: Ttl = Ttl(Duration::from_secs(15)); + /// Population history. The narrowest bucket the API produces is 5 minutes, + /// so a fresh request inside a minute cannot show the user anything new. + pub const HISTORY: Ttl = Ttl(Duration::from_secs(60)); + /// Ping sites change rarely; the response carries its own `updatedAt`. + pub const STABLE: Ttl = Ttl(Duration::from_secs(60 * 60)); +} + +struct Entry { + stored_at: Instant, + body: String, +} + +/// A process-lifetime cache of response bodies. +pub struct ResponseCache { + entries: Mutex>, + capacity: usize, +} + +impl ResponseCache { + /// Creates a cache holding at most `capacity` entries. + pub fn new(capacity: usize) -> Self { + Self { + entries: Mutex::new(HashMap::new()), + capacity: capacity.max(1), + } + } + + /// Returns the cached body for `key` when it is still within `ttl`. + pub fn get(&self, key: &str, ttl: Ttl) -> Option { + let entries = self.entries.lock().ok()?; + let entry = entries.get(key)?; + (entry.stored_at.elapsed() < ttl.0).then(|| entry.body.clone()) + } + + /// Stores `body` under `key`, evicting the oldest entry when full. + pub fn put(&self, key: &str, body: String) { + let Ok(mut entries) = self.entries.lock() else { + // A poisoned cache is a cache miss, never a failed request. + return; + }; + if entries.len() >= self.capacity && !entries.contains_key(key) { + // Linear scan for the oldest entry. At this capacity that is far + // cheaper than maintaining a second index. + if let Some(oldest) = entries + .iter() + .min_by_key(|(_, e)| e.stored_at) + .map(|(k, _)| k.clone()) + { + entries.remove(&oldest); + } + } + entries.insert( + key.to_owned(), + Entry { + stored_at: Instant::now(), + body, + }, + ); + } + + /// Drops every entry. Used when the API base URL changes. + pub fn clear(&self) { + if let Ok(mut entries) = self.entries.lock() { + entries.clear(); + } + } + + /// Number of entries currently held, fresh or not. + pub fn len(&self) -> usize { + self.entries.lock().map(|e| e.len()).unwrap_or(0) + } + + /// True when the cache holds nothing. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl Default for ResponseCache { + fn default() -> Self { + Self::new(256) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const INSTANT: Ttl = Ttl(Duration::ZERO); + + #[test] + fn stores_and_reads_back() { + let cache = ResponseCache::new(4); + cache.put("a", "body".into()); + assert_eq!(cache.get("a", Ttl::SNAPSHOT).as_deref(), Some("body")); + } + + #[test] + fn misses_on_unknown_key() { + let cache = ResponseCache::new(4); + assert!(cache.get("nope", Ttl::SNAPSHOT).is_none()); + } + + #[test] + fn expired_entries_are_not_returned() { + let cache = ResponseCache::new(4); + cache.put("a", "body".into()); + // A zero TTL means every entry is already stale. + assert!(cache.get("a", INSTANT).is_none()); + } + + #[test] + fn eviction_keeps_the_cache_bounded() { + let cache = ResponseCache::new(2); + cache.put("a", "1".into()); + cache.put("b", "2".into()); + cache.put("c", "3".into()); + assert_eq!(cache.len(), 2); + // "a" was oldest, so it went first. + assert!(cache.get("a", Ttl::SNAPSHOT).is_none()); + assert!(cache.get("c", Ttl::SNAPSHOT).is_some()); + } + + #[test] + fn overwriting_an_existing_key_does_not_evict() { + let cache = ResponseCache::new(2); + cache.put("a", "1".into()); + cache.put("b", "2".into()); + cache.put("a", "updated".into()); + assert_eq!(cache.len(), 2); + assert_eq!(cache.get("a", Ttl::SNAPSHOT).as_deref(), Some("updated")); + assert!(cache.get("b", Ttl::SNAPSHOT).is_some()); + } + + #[test] + fn clear_empties_the_cache() { + let cache = ResponseCache::new(4); + cache.put("a", "1".into()); + assert!(!cache.is_empty()); + cache.clear(); + assert!(cache.is_empty()); + } +} diff --git a/crates/rfm-core/src/identity.rs b/crates/rfm-core/src/identity.rs new file mode 100644 index 0000000..fd7c744 --- /dev/null +++ b/crates/rfm-core/src/identity.rs @@ -0,0 +1,258 @@ +//! Stable, anonymous installation identity. +//! +//! The API needs to tell "50 000 requests from 5 000 installs" from "50 000 +//! requests from one". A value generated on first run answers that, but it +//! resets whenever the launcher is reinstalled, so one person reinstalling +//! three times counts as three installs. +//! +//! This derives the identifier from a machine characteristic instead, so it +//! survives uninstall and reinstall. +//! +//! # What is sent, and what is not +//! +//! The machine value **never leaves the process**. What is sent is +//! +//! ```text +//! SHA-256(APPLICATION_SALT || machine value) -> first 16 bytes -> UUID +//! ``` +//! +//! so the API receives an opaque identifier it cannot reverse into a machine +//! id, and the same machine running a different application derives a different +//! value. The wire format stays a canonical UUID, so the API contract in +//! `docs/client-identification.md` is unchanged. +//! +//! # Why not IP +//! +//! An address is the wrong input: it changes with DHCP leases, VPNs, tethering +//! and moving between networks, so it would both split one install into many +//! and merge everyone behind a shared NAT into one. It is also the one piece of +//! genuinely personal data in the request, and the API already sees it. +//! +//! # Sources +//! +//! | Platform | Value | +//! | --- | --- | +//! | Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` | +//! | Linux | `/etc/machine-id`, else `/var/lib/dbus/machine-id` | +//! | Other / unreadable | falls back to a persisted random value | +//! +//! All of these are per-OS-installation, not per-user and not per-device-serial, +//! and they change when the OS is reinstalled — which is the intended +//! granularity: a new OS is a new install. + +use sha2::{Digest, Sha256}; + +/// Domain separator. Mixed in so the same machine yields a different identifier +/// for a different application, and so the digest is not a bare hash of a value +/// that other software also knows. +const APPLICATION_SALT: &str = "reforgermods.net-launcher/install-id/v1"; + +/// The identifier to send, preferring a machine-derived value. +/// +/// `fallback` is the persisted random value used when no machine source is +/// readable — a sandboxed or unusual environment still gets a working, if +/// reinstall-unstable, identifier rather than none. +pub fn stable_install_id(fallback: &str) -> String { + match machine_value() { + Some(value) => derive_uuid(&value), + None => fallback.to_string(), + } +} + +/// True when this build could read a machine source, so the identifier survives +/// a reinstall. Surfaced only in debug mode. +pub fn is_machine_derived() -> bool { + machine_value().is_some() +} + +/// Hashes `material` into a canonical UUID. +/// +/// The result carries UUID version 8 (custom) and the RFC 4122 variant, which +/// is the correct label for a value derived this way — it is not random (v4) and +/// not an RFC-specified name hash (v3/v5). +fn derive_uuid(material: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(APPLICATION_SALT.as_bytes()); + hasher.update([0u8]); // Separator, so salt and value cannot run together. + hasher.update(material.trim().to_ascii_lowercase().as_bytes()); + let digest = hasher.finalize(); + + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&digest[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x80; // version 8 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant + + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], + ) +} + +/// Reads this machine's identifier, if one is available. +#[cfg(windows)] +fn machine_value() -> Option { + use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}; + use winreg::RegKey; + + // Explicitly the 64-bit view: MachineGuid lives there, and a 32-bit process + // would otherwise be redirected to an empty Wow6432Node. + let key = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey_with_flags( + r"SOFTWARE\Microsoft\Cryptography", + KEY_READ | KEY_WOW64_64KEY, + ) + .ok()?; + let guid: String = key.get_value("MachineGuid").ok()?; + usable(guid) +} + +/// Reads this machine's identifier, if one is available. +#[cfg(not(windows))] +fn machine_value() -> Option { + ["/etc/machine-id", "/var/lib/dbus/machine-id"] + .iter() + .find_map(|path| std::fs::read_to_string(path).ok()) + .and_then(usable) +} + +/// Rejects blank or obviously placeholder values. +/// +/// An all-zero or empty machine id would make every affected machine share one +/// identifier, which is worse than falling back to a random one. +fn usable(value: String) -> Option { + let trimmed = value.trim().to_string(); + if trimmed.len() < 8 { + return None; + } + if trimmed.chars().all(|c| c == '0' || c == '-') { + return None; + } + Some(trimmed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::launch::ServerId; + + #[test] + fn derived_ids_are_canonical_uuids() { + // The API header validator accepts only this shape, so a derived value + // that failed it would be silently dropped. + let id = derive_uuid("4c4c4544-0037-5910-8051-b8c04f325632"); + assert!(ServerId::parse(&id).is_ok(), "{id}"); + assert_eq!(id.len(), 36); + } + + #[test] + fn derivation_is_deterministic() { + let a = derive_uuid("machine-abc"); + let b = derive_uuid("machine-abc"); + assert_eq!(a, b, "the same machine must reinstall to the same id"); + } + + #[test] + fn different_machines_derive_different_ids() { + assert_ne!(derive_uuid("machine-abc"), derive_uuid("machine-abd")); + } + + #[test] + fn input_is_normalised_for_case_and_whitespace() { + // Windows returns the GUID in varying case depending on how it is read. + let canonical = derive_uuid("4c4c4544-0037-5910-8051-b8c04f325632"); + assert_eq!( + derive_uuid("4C4C4544-0037-5910-8051-B8C04F325632"), + canonical + ); + assert_eq!( + derive_uuid(" 4c4c4544-0037-5910-8051-b8c04f325632\n"), + canonical + ); + } + + #[test] + fn the_machine_value_is_not_recoverable_from_the_id() { + // Not a proof, but it pins the intent: the identifier must never be the + // machine value, or a prefix or suffix of it. + let machine = "4c4c4544-0037-5910-8051-b8c04f325632"; + let id = derive_uuid(machine); + assert_ne!(id, machine); + assert!(!id.contains(&machine[..8])); + assert!(!machine.contains(&id[..8])); + } + + #[test] + fn version_and_variant_are_set() { + let id = derive_uuid("anything"); + let bytes: Vec = id + .replace('-', "") + .as_bytes() + .chunks(2) + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + .collect(); + assert_eq!(bytes[6] >> 4, 8, "version 8, a custom derivation"); + assert_eq!(bytes[8] >> 6, 0b10, "RFC 4122 variant"); + } + + #[test] + fn the_salt_separates_this_application() { + // Two applications on one machine must not share an identifier. Mirrors + // derive_uuid with a different salt. + let mut hasher = Sha256::new(); + hasher.update(b"some-other-application"); + hasher.update([0u8]); + hasher.update(b"machine-abc"); + let other = hasher.finalize(); + assert_ne!( + derive_uuid("machine-abc") + .replace('-', "") + .chars() + .take(8) + .collect::(), + other[..4] + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ); + } + + #[test] + fn unusable_machine_values_are_rejected() { + // These would collapse many machines onto one identifier. + for value in [ + "", + " ", + "0", + "0000", + "00000000-0000-0000-0000-000000000000", + "-----", + ] { + assert!(usable(value.to_string()).is_none(), "{value:?}"); + } + assert!(usable("4c4c4544-0037-5910-8051-b8c04f325632".into()).is_some()); + } + + #[test] + fn the_fallback_is_used_when_no_machine_value_exists() { + // Cannot force machine_value() to fail here, so this pins the contract + // that a fallback is returned verbatim rather than hashed. + let fallback = "8f14e45f-ceea-467a-9c2b-1f2a3b4c5d6e"; + let id = stable_install_id(fallback); + assert!(ServerId::parse(&id).is_ok()); + if !is_machine_derived() { + assert_eq!(id, fallback); + } + } + + #[test] + fn a_machine_derived_id_is_stable_across_calls() { + // Only meaningful where a machine id exists. A container or a minimal + // chroot has neither /etc/machine-id nor the dbus copy, and there every + // call correctly returns the fallback it was handed instead. + if !is_machine_derived() { + return; + } + assert_eq!(stable_install_id("x"), stable_install_id("y")); + } +} diff --git a/crates/rfm-core/src/launch.rs b/crates/rfm-core/src/launch.rs new file mode 100644 index 0000000..301c6f1 --- /dev/null +++ b/crates/rfm-core/src/launch.rs @@ -0,0 +1,500 @@ +//! Direct-join URI construction for Arma Reforger. +//! +//! The join payload is a base64 encoding of a small JSON object naming the +//! lobby room: +//! +//! ```text +//! {"id":"","inviteToken":""} +//! -> UTF-8 bytes +//! -> standard base64 +//! -> trailing '=' padding removed +//! ``` +//! +//! # How the payload reaches the game +//! +//! The payload is delivered as a Steam launch argument: +//! +//! ```text +//! steam://run/1874880/// +//! ^^ +//! ``` +//! +//! The **empty path segment** is what matters. `steam://run////` +//! is Steam's documented form for passing launch arguments; with a single +//! slash, Steam can start the game without forwarding the payload, leaving the +//! player on the main menu. +//! +//! Status: this is the current best understanding and is **not yet confirmed +//! against a live client**. The single-slash form was observed to launch the +//! game without joining; the double-slash form is the likely fix. +//! +//! # The `ArmaReforger:` protocol, an alternative +//! +//! The game also registers its own URL protocol. Its Steam install script +//! (`steamapps/common/Arma Reforger/installscript.vdf`) writes: +//! +//! ```text +//! HKCU\SOFTWARE\Classes\ArmaReforger +//! (Default) = "URL:Arma Reforger Protocol" +//! URL Protocol = "" +//! HKCU\SOFTWARE\Classes\ArmaReforger\shell\open\command +//! (Default) = "\ArmaReforger_BE.exe" -exe ArmaReforgerSteam.exe +//! "-addonsDir=\addons" -uri="%1" +//! ``` +//! +//! [`reforger_join_uri`] builds that form. It is kept, tested and unused by +//! default: it is a second candidate delivery route if the Steam form turns out +//! not to join, and it does not depend on how Steam forwards arguments. +//! +//! # Launch state +//! +//! Either route only carries the payload when the game actually starts. If Arma +//! Reforger is already running, Steam may simply focus the existing process and +//! drop the arguments, so a join must be tested from a fully closed game. +//! +//! Everything in this module is pure. It never spawns a shell and never +//! interpolates API-supplied text: the only value that reaches a URI is a +//! [`ServerId`], which cannot be constructed without passing UUID validation. + +use base64::engine::general_purpose::STANDARD_NO_PAD; +use base64::Engine as _; +use serde::Serialize; + +/// Steam App ID for Arma Reforger. +pub const ARMA_REFORGER_APP_ID: u32 = 1874880; + +/// URL protocol Arma Reforger registers for join links. +/// +/// Matches the `HKCU\SOFTWARE\Classes\ArmaReforger` key written by the game's +/// Steam install script. URI schemes are case-insensitive; this spelling +/// mirrors the registry key. +pub const REFORGER_URI_SCHEME: &str = "ArmaReforger"; + +/// Errors produced while validating a server identifier. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum LaunchError { + /// The identifier was not a canonical 8-4-4-4-12 lowercase-hex UUID. + #[error("not a valid Arma Reforger server id (expected a UUID, got {got:?})")] + InvalidServerId { got: String }, +} + +/// A validated Arma Reforger server identifier. +/// +/// Two shapes occur in the API, and both must be joinable: +/// +/// - **Community servers** use a canonical UUID, e.g. +/// `73e8fb7f-8789-4d88-8aa8-ea69b9aa092f`. +/// - **Official servers** use a provider id, e.g. `nitrado_17516010`. Sampled +/// across 600 servers, every non-UUID id was official and every official id +/// was `nitrado_` followed by digits. +/// +/// The only way to obtain one is [`ServerId::parse`], so any `ServerId` in the +/// program contains nothing but lowercase alphanumerics, `-` and `_`. This is +/// the security boundary for launching: untrusted API text cannot become a URI. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct ServerId(String); + +impl ServerId { + /// Parses a canonical UUID. Case is normalised to lowercase; surrounding + /// whitespace is rejected rather than trimmed, because a stray space in an + /// id means the caller mishandled the value somewhere upstream. + pub fn parse(raw: &str) -> Result { + if is_canonical_uuid(raw) || is_provider_id(raw) { + Ok(Self(raw.to_ascii_lowercase())) + } else { + Err(LaunchError::InvalidServerId { + got: raw.to_owned(), + }) + } + } + + /// True for an official (provider-hosted) server. + pub fn is_official_form(&self) -> bool { + is_provider_id(&self.0) + } + + /// The validated identifier as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ServerId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Returns true for exactly `8-4-4-4-12` ASCII-hex groups and nothing else. +/// +/// Hand-written rather than pulled from a UUID crate so the accepted shape is +/// visible here: no braces, no `urn:` prefix, no internal whitespace. +fn is_canonical_uuid(s: &str) -> bool { + const GROUPS: [usize; 5] = [8, 4, 4, 4, 12]; + let mut parts = s.split('-'); + for len in GROUPS { + match parts.next() { + Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_hexdigit()) => {} + _ => return false, + } + } + parts.next().is_none() +} + +/// Returns true for `_`, the official-server id shape. +/// +/// Deliberately narrow: lowercase ASCII letters, one underscore, then digits. +/// Every character it admits is URI-safe, so a validated id never needs +/// escaping. Written generically rather than hardcoding `nitrado` so a second +/// provider does not silently become unjoinable. +fn is_provider_id(s: &str) -> bool { + let Some((provider, number)) = s.split_once('_') else { + return false; + }; + !provider.is_empty() + && provider.len() <= 16 + && provider.bytes().all(|b| b.is_ascii_lowercase()) + && !number.is_empty() + && number.len() <= 24 + && number.bytes().all(|b| b.is_ascii_digit()) +} + +/// The JSON body Arma Reforger expects. Field order is the declaration order, +/// which is what the verified payload uses. +#[derive(Serialize)] +struct JoinPayload<'a> { + id: &'a str, + #[serde(rename = "inviteToken")] + invite_token: &'a str, +} + +/// Serialises the join payload for a server. +/// +/// Exposed separately from [`steam_join_uri`] so the exact bytes that get +/// encoded are directly assertable in tests. +pub fn join_payload_json(server: &ServerId) -> String { + let payload = JoinPayload { + id: server.as_str(), + invite_token: "", + }; + // A struct of two string fields cannot fail to serialise. + serde_json::to_string(&payload).expect("join payload is always serialisable") +} + +/// Base64-encodes the join payload with trailing `=` padding removed. +pub fn join_payload_base64(server: &ServerId) -> String { + STANDARD_NO_PAD.encode(join_payload_json(server).as_bytes()) +} + +/// Builds the `ArmaReforger://` URI for the game's own protocol handler. +/// +/// Not used by default; see the module docs. Retained as a second candidate +/// delivery route, since it reaches the launcher as `-uri=` without depending +/// on how Steam forwards arguments. +pub fn reforger_join_uri(server: &ServerId) -> String { + format!("{REFORGER_URI_SCHEME}://{}", join_payload_base64(server)) +} + +/// Convenience: validate a raw identifier and build its join URI in one step. +pub fn reforger_join_uri_for(raw: &str) -> Result { + Ok(reforger_join_uri(&ServerId::parse(raw)?)) +} + +/// Builds the `steam://run/...` URI that joins `server` directly. +/// +/// Note the doubled slash: `steam://run////`. The empty path +/// segment marks what follows as a launch argument. Dropping it produces a URI +/// that starts the game without forwarding the payload. +/// +/// Hand this to the platform URI opener (`ShellExecuteW` on Windows via +/// `tauri-plugin-opener`). Never pass it to a shell. +pub fn steam_join_uri(server: &ServerId) -> String { + format!( + "steam://run/{}//{}/", + ARMA_REFORGER_APP_ID, + join_payload_base64(server) + ) +} + +/// Convenience: validate a raw identifier and build its join URI in one step. +pub fn steam_join_uri_for(raw: &str) -> Result { + Ok(steam_join_uri(&ServerId::parse(raw)?)) +} + +/// Builds the `steam://run/` URI that starts Arma Reforger with no +/// server argument, for "just launch the game". +pub fn steam_launch_uri() -> String { + format!("steam://run/{ARMA_REFORGER_APP_ID}") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The identifier and payload from the verified PowerShell experiment. + const SAMPLE_ID: &str = "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f"; + const SAMPLE_B64: &str = + "eyJpZCI6IjczZThmYjdmLTg3ODktNGQ4OC04YWE4LWVhNjliOWFhMDkyZiIsImludml0ZVRva2VuIjoiIn0"; + + #[test] + fn payload_json_matches_verified_shape() { + let id = ServerId::parse(SAMPLE_ID).unwrap(); + assert_eq!( + join_payload_json(&id), + r#"{"id":"73e8fb7f-8789-4d88-8aa8-ea69b9aa092f","inviteToken":""}"# + ); + } + + #[test] + fn payload_base64_matches_verified_vector() { + let id = ServerId::parse(SAMPLE_ID).unwrap(); + assert_eq!(join_payload_base64(&id), SAMPLE_B64); + } + + #[test] + fn base64_padding_is_removed() { + let id = ServerId::parse(SAMPLE_ID).unwrap(); + let encoded = join_payload_base64(&id); + assert!( + !encoded.ends_with('='), + "padding must be stripped: {encoded}" + ); + // The 62-byte payload would normally encode to 84 chars with one '='. + assert_eq!(join_payload_json(&id).len(), 62); + assert_eq!(encoded.len(), 83); + } + + #[test] + fn padding_is_removed_for_every_residue_class() { + // UUID payloads are a fixed length, so exercise the encoder directly to + // cover all three base64 remainder cases (0, 1 and 2 leftover bytes). + for input in ["abc", "abcd", "abcde", ""] { + let encoded = STANDARD_NO_PAD.encode(input.as_bytes()); + assert!(!encoded.contains('='), "{input:?} -> {encoded}"); + } + } + + #[test] + fn join_uri_has_the_empty_segment_that_marks_a_launch_argument() { + // This is the entire fix and it is one character. Without the empty + // segment Steam can start the game without forwarding the payload, + // which looks like a successful launch that never joins. + let id = ServerId::parse(SAMPLE_ID).unwrap(); + let uri = steam_join_uri(&id); + assert_eq!(uri, format!("steam://run/1874880//{SAMPLE_B64}/")); + assert!( + uri.contains(&format!("{ARMA_REFORGER_APP_ID}//")), + "app id must be followed by an empty path segment: {uri}" + ); + assert!( + !uri.contains(&format!("{ARMA_REFORGER_APP_ID}/{SAMPLE_B64}")), + "single-slash form regressed: {uri}" + ); + } + + #[test] + fn join_uri_matches_the_reference_implementation() { + // Mirrors the JS reference: JSON.stringify({id, inviteToken}) in that + // field order, standard base64, trailing '=' stripped, wrapped as + // steam://run/1874880///. + let id = ServerId::parse(SAMPLE_ID).unwrap(); + let payload = join_payload_base64(&id); + assert!(!payload.contains('=')); + assert_eq!( + steam_join_uri(&id), + format!("steam://run/{ARMA_REFORGER_APP_ID}//{payload}/") + ); + } + + #[test] + fn plain_launch_uri_keeps_a_single_segment() { + // No payload means no argument marker; this must not gain the empty + // segment along with the join URI. + assert_eq!(steam_launch_uri(), "steam://run/1874880"); + } + + #[test] + fn the_protocol_alternative_carries_an_identical_payload() { + let id = ServerId::parse(SAMPLE_ID).unwrap(); + let payload = join_payload_base64(&id); + assert!(steam_join_uri(&id).contains(&payload)); + assert!(reforger_join_uri(&id).ends_with(&payload)); + } + + #[test] + fn join_uri_uses_the_games_own_protocol() { + // The scheme the game registers; Steam's run URL drops the payload. + let id = ServerId::parse(SAMPLE_ID).unwrap(); + assert_eq!( + reforger_join_uri(&id), + format!("ArmaReforger://{SAMPLE_B64}") + ); + } + + #[test] + fn join_uri_carries_the_same_payload_as_the_steam_form() { + let id = ServerId::parse(SAMPLE_ID).unwrap(); + let payload = join_payload_base64(&id); + assert!(reforger_join_uri(&id).ends_with(&payload)); + assert!(steam_join_uri(&id).contains(&payload)); + } + + #[test] + fn join_uri_has_no_trailing_slash() { + // The whole URI is forwarded verbatim as -uri=; a stray separator would + // become part of the payload the game parses. + let uri = reforger_join_uri_for(SAMPLE_ID).unwrap(); + assert!(!uri.ends_with('/'), "{uri}"); + assert_eq!(uri.matches("//").count(), 1); + } + + #[test] + fn steam_join_uri_is_stable_for_a_given_server() { + let id = ServerId::parse(SAMPLE_ID).unwrap(); + assert_eq!(steam_join_uri(&id), steam_join_uri_for(SAMPLE_ID).unwrap()); + } + + #[test] + fn join_uri_has_trailing_slash_and_app_id() { + let uri = steam_join_uri_for(SAMPLE_ID).unwrap(); + assert!(uri.starts_with("steam://run/1874880//")); + assert!(uri.ends_with('/')); + } + + #[test] + fn plain_launch_uri_carries_no_payload() { + assert_eq!(steam_launch_uri(), "steam://run/1874880"); + } + + #[test] + fn uppercase_ids_are_normalised() { + let id = ServerId::parse("73E8FB7F-8789-4D88-8AA8-EA69B9AA092F").unwrap(); + assert_eq!(id.as_str(), SAMPLE_ID); + assert_eq!( + reforger_join_uri(&id), + reforger_join_uri_for(SAMPLE_ID).unwrap() + ); + } + + /// A real official server id, from `/v2/servers?official=true`. + const OFFICIAL_ID: &str = "nitrado_17516010"; + + #[test] + fn official_server_ids_are_accepted() { + // 364 official servers use this shape; rejecting it made them all + // unjoinable. + let id = ServerId::parse(OFFICIAL_ID).unwrap(); + assert_eq!(id.as_str(), OFFICIAL_ID); + assert!(id.is_official_form()); + assert!(!ServerId::parse(SAMPLE_ID).unwrap().is_official_form()); + } + + #[test] + fn official_ids_join_through_the_same_payload() { + // The payload shape does not change: only the id inside it does. + let id = ServerId::parse(OFFICIAL_ID).unwrap(); + assert_eq!( + join_payload_json(&id), + r#"{"id":"nitrado_17516010","inviteToken":""}"# + ); + assert_eq!( + steam_join_uri(&id), + "steam://run/1874880//eyJpZCI6Im5pdHJhZG9fMTc1MTYwMTAiLCJpbnZpdGVUb2tlbiI6IiJ9/" + ); + } + + #[test] + fn provider_ids_stay_uri_safe() { + // Everything the provider shape admits can sit in a URI unescaped, so + // no id ever needs encoding on the way out. + for raw in ["nitrado_17516010", "a_0", "somehost_123456789012"] { + let id = ServerId::parse(raw).unwrap(); + assert!(id + .as_str() + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')); + } + } + + #[test] + fn provider_shaped_lookalikes_are_rejected() { + for raw in [ + "_123", // no provider + "nitrado_", // no number + "nitrado_17516010x", // trailing junk + "Nitrado_17516010", // uppercase provider + "nitrado-17516010", // wrong separator + "nitrado_175_160", // second underscore + "nitrado_17516010/evil", // path injection + "averyveryverylongprovidername_1", + "nitrado_1234567890123456789012345", + ] { + assert!(ServerId::parse(raw).is_err(), "should be rejected: {raw:?}"); + } + } + + #[test] + fn invalid_ids_are_rejected() { + let rejected = [ + "", + "not-a-uuid", + // Wrong group lengths. + "73e8fb7f-8789-4d88-8aa8-ea69b9aa092", + "73e8fb7f-8789-4d88-8aa8-ea69b9aa092ff", + "73e8fb7-88789-4d88-8aa8-ea69b9aa092f", + // Non-hex characters. + "73e8fb7g-8789-4d88-8aa8-ea69b9aa092f", + // Extra group. + "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f-0000", + // Braced / URN forms are not what the API returns. + "{73e8fb7f-8789-4d88-8aa8-ea69b9aa092f}", + "urn:uuid:73e8fb7f-8789-4d88-8aa8-ea69b9aa092f", + // Whitespace must not be silently trimmed. + " 73e8fb7f-8789-4d88-8aa8-ea69b9aa092f", + "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f ", + // Unpadded / no separators. + "73e8fb7f87894d888aa8ea69b9aa092f", + ]; + for raw in rejected { + assert!( + ServerId::parse(raw).is_err(), + "should have been rejected: {raw:?}" + ); + assert!(steam_join_uri_for(raw).is_err(), "{raw:?}"); + assert!(reforger_join_uri_for(raw).is_err(), "{raw:?}"); + } + } + + #[test] + fn injection_attempts_never_reach_a_uri() { + // These are the shapes that would matter if an id were ever pasted into + // a command line or a URI. Validation rejects them before that point. + let hostile = [ + "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f/../../evil", + "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f\"; calc.exe", + "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f&&shutdown", + "$(whoami)-8789-4d88-8aa8-ea69b9aa092f", + "73e8fb7f-8789-4d88-8aa8-ea69b9aa09\n\r", + ]; + for raw in hostile { + assert_eq!( + ServerId::parse(raw), + Err(LaunchError::InvalidServerId { + got: raw.to_owned() + }) + ); + } + } + + #[test] + fn generated_uris_contain_only_url_safe_payload_characters() { + // Standard base64 can emit '+' and '/', which are legal in this Steam + // argument position; assert the payload never contains anything beyond + // that set so the URI is always well-formed. + let id = ServerId::parse(SAMPLE_ID).unwrap(); + let payload = join_payload_base64(&id); + assert!(payload + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')); + } +} diff --git a/crates/rfm-core/src/lib.rs b/crates/rfm-core/src/lib.rs new file mode 100644 index 0000000..cb5ac4a --- /dev/null +++ b/crates/rfm-core/src/lib.rs @@ -0,0 +1,25 @@ +//! Core logic for the reforgermods.net launcher. +//! +//! This crate deliberately has no Tauri dependency. Everything that can be +//! decided without a window lives here so it can be unit-tested directly: +//! the reforgermods.net API client, Steam launch-URI construction, local +//! persistence, response caching, and local Workshop addon detection. + +pub mod api; +pub mod cache; +pub mod identity; +pub mod launch; +pub mod modscan; +pub mod ping; +pub mod presence; +pub mod readiness; +pub mod store; + +pub use api::{ApiClient, ApiError}; +pub use launch::{reforger_join_uri, steam_join_uri, LaunchError, ServerId}; + +/// User-Agent the launcher identifies itself with. The launcher is a public, +/// untrusted client: it sends no credentials, only this. +pub fn user_agent() -> String { + format!("reforgermods.net-launcher/{}", env!("CARGO_PKG_VERSION")) +} diff --git a/crates/rfm-core/src/modscan.rs b/crates/rfm-core/src/modscan.rs new file mode 100644 index 0000000..dee0ee1 --- /dev/null +++ b/crates/rfm-core/src/modscan.rs @@ -0,0 +1,893 @@ +//! Local Arma Reforger Workshop addon detection. +//! +//! # Status +//! +//! This module is the *abstraction plus a verified implementation* for reading +//! what the user already has installed. It is deliberately **not wired into the +//! milestone 1 UI**: the launcher shows no readiness indicator, because the one +//! piece the feature still needs — a supported way to trigger installation of +//! missing mods before joining — has not been verified. See +//! `docs/mod-readiness.md` for the full investigation. +//! +//! # What was verified +//! +//! Arma Reforger keeps downloaded Workshop content in the user's profile +//! directory, one directory per addon, named `_<16-hex-mod-id>`: +//! +//! ```text +//! %USERPROFILE%\Documents\My Games\ArmaReforger\addons\AKI_Core_62CCD69DD17E4F2F\ +//! ServerData.json <- id, name, installed revision.version +//! meta <- full Workshop metadata, dependencies, sizes +//! addon.gproj <- GUID + dependency GUIDs +//! data.pak +//! ``` +//! +//! `ServerData.json` is the cheapest reliable source: ~250 bytes, present in +//! every addon directory observed (235 of 235), and its `revision.version` is +//! byte-identical to the `version` reforgermods.net reports for the same mod. +//! Both files are written UTF-8 **with a byte-order mark**, which is why parsing +//! strips one before handing the text to serde. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Directory name of the addon store inside the Reforger profile directory. +const ADDONS_DIR_NAME: &str = "addons"; +/// The per-addon file naming the installed revision. +const SERVER_DATA_FILE: &str = "ServerData.json"; +/// Development override pointing at an addon store directly. +/// +/// Useful when the game's profile is not where this platform would look — for +/// example running the launcher under WSL against a Windows install, or testing +/// against a copied store. +const ADDONS_DIR_ENV: &str = "RFM_ADDONS_DIR"; + +/// Errors from scanning the local addon store. +#[derive(Debug, thiserror::Error)] +pub enum ScanError { + /// No addon directory was found. Normal when Reforger has never been run, + /// or when the profile lives somewhere non-default. + #[error("no Arma Reforger addons directory found")] + AddonsDirNotFound, + #[error("could not read addons directory {path}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +/// One Workshop addon present on disk. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledAddon { + /// Workshop mod id, upper-case 16 hex characters, matching the API. + pub id: String, + /// Addon name as recorded locally. Untrusted text. + pub name: String, + /// Installed revision, e.g. `8.1.1`. Empty when the file did not name one. + pub version: String, +} + +/// How a server's required mod set compares to what is installed. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModReadiness { + /// Installed at exactly the version the server asks for. + pub up_to_date: Vec, + /// Installed, but at a different version. + pub outdated: Vec, + /// Not installed at all. + pub missing: Vec, + /// Sum of `size` for missing mods whose size the API resolved. A floor, not + /// a total: see `unresolved_bytes`. + pub missing_bytes: u64, + /// Missing mods whose download size the API could not resolve. While this is + /// non-zero, `missing_bytes` understates the real download. + pub unresolved_mods: u32, +} + +impl ModReadiness { + /// True when every required mod is installed at the required version. + pub fn is_ready(&self) -> bool { + self.outdated.is_empty() && self.missing.is_empty() + } +} + +/// A required mod, as reported by a server. +/// +/// A narrow view of [`crate::api::models::ServerMod`] so this module does not +/// depend on the API model shape. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequiredMod { + pub id: String, + pub version: String, + /// Download size in bytes when the API resolved it. + pub size: Option, +} + +/// A source of installed-addon information. +/// +/// The trait exists so readiness logic can be tested against a fixed inventory, +/// and so a future platform with a different layout can be added without +/// touching callers. +pub trait AddonInventory { + /// Every addon currently installed. + fn installed(&self) -> Result, ScanError>; +} + +/// Reads the addon store from a directory on disk. +#[derive(Debug, Clone)] +pub struct FilesystemInventory { + addons_dir: PathBuf, +} + +impl FilesystemInventory { + /// Scans `addons_dir` directly. + pub fn new(addons_dir: impl Into) -> Self { + Self { + addons_dir: addons_dir.into(), + } + } + + /// Locates the addon store for this machine. + /// + /// `RFM_ADDONS_DIR` overrides the search when set. Otherwise the profile + /// locations for this platform are probed: the (often redirected) + /// `Documents` directory on Windows, and Reforger's Proton prefix inside + /// each Steam library on Linux. A platform with no known layout finds + /// nothing and returns [`ScanError::AddonsDirNotFound`] — which the UI + /// reports as "could not be checked", never as "nothing missing". + pub fn discover() -> Result { + let explicit = std::env::var_os(ADDONS_DIR_ENV).map(PathBuf::from); + addons_dir_candidates(explicit) + .into_iter() + .find(|p| p.is_dir()) + .map(Self::new) + .ok_or(ScanError::AddonsDirNotFound) + } + + /// The directory being scanned. + pub fn addons_dir(&self) -> &Path { + &self.addons_dir + } +} + +impl AddonInventory for FilesystemInventory { + fn installed(&self) -> Result, ScanError> { + let entries = std::fs::read_dir(&self.addons_dir).map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + ScanError::AddonsDirNotFound + } else { + ScanError::Read { + path: self.addons_dir.clone(), + source, + } + } + })?; + + let mut addons = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + // The store also holds stray preview images and a `saves` + // directory; anything without a ServerData.json is skipped below. + continue; + } + let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Some(id_from_dir) = addon_id_from_dir_name(dir_name) else { + continue; + }; + // Prefer the id the file states; fall back to the directory name. + let addon = std::fs::read_to_string(path.join(SERVER_DATA_FILE)) + .ok() + .and_then(|raw| parse_server_data(&raw)) + .unwrap_or_else(|| InstalledAddon { + id: id_from_dir, + name: String::new(), + version: String::new(), + }); + addons.push(addon); + } + addons.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(addons) + } +} + +/// Compares a server's required mods against an installed inventory. +pub fn readiness(required: &[RequiredMod], installed: &[InstalledAddon]) -> ModReadiness { + let local: HashMap = installed + .iter() + .map(|a| (a.id.to_ascii_uppercase(), a.version.as_str())) + .collect(); + + let mut out = ModReadiness::default(); + for req in required { + let key = req.id.to_ascii_uppercase(); + match local.get(&key) { + Some(version) if *version == req.version => out.up_to_date.push(key), + Some(_) => out.outdated.push(key), + None => { + match req.size { + Some(size) => out.missing_bytes += size, + None => out.unresolved_mods += 1, + } + out.missing.push(key); + } + } + } + out +} + +/// Extracts the Workshop id from an addon directory name. +/// +/// The id is the trailing `_`-separated group and must be exactly 16 hex +/// characters. Addon names themselves contain `_`, so the split is from the +/// right, and directories that do not match (`saves`, stray files) yield `None`. +pub fn addon_id_from_dir_name(name: &str) -> Option { + let (_, id) = name.rsplit_once('_')?; + (id.len() == 16 && id.bytes().all(|b| b.is_ascii_hexdigit())).then(|| id.to_ascii_uppercase()) +} + +/// Parses an addon's `ServerData.json`. +/// +/// The file is UTF-8 with a BOM; the BOM is stripped before parsing because +/// serde_json rejects it as unexpected input. +pub fn parse_server_data(raw: &str) -> Option { + #[derive(Deserialize)] + struct Revision { + #[serde(default)] + version: String, + } + #[derive(Deserialize)] + struct ServerData { + id: String, + #[serde(default)] + name: String, + #[serde(default)] + revision: Option, + } + + let cleaned = raw.trim_start_matches('\u{feff}').trim(); + let data: ServerData = serde_json::from_str(cleaned).ok()?; + let id = data.id.trim().to_ascii_uppercase(); + if id.len() != 16 || !id.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + Some(InstalledAddon { + id, + name: data.name, + version: data.revision.map(|r| r.version).unwrap_or_default(), + }) +} + +/// Candidate addon-store locations, most likely first. +/// +/// An `explicit` override short-circuits the search entirely: if the caller +/// named a directory, silently falling back to a different one would be worse +/// than reporting that the named one is not there. +fn addons_dir_candidates(explicit: Option) -> Vec { + if let Some(dir) = explicit { + return vec![dir]; + } + let profile_suffix = Path::new("My Games").join("ArmaReforger"); + documents_roots() + .into_iter() + .map(|root| root.join(&profile_suffix).join(ADDONS_DIR_NAME)) + .collect() +} + +/// Locations of the Windows `Documents` directory holding the Reforger profile. +/// +/// `Documents` is frequently redirected — OneDrive being the common case — so +/// the registry's own answer is asked for first and the well-known layouts are +/// kept as fallbacks rather than assumed. +#[cfg(windows)] +fn documents_roots() -> Vec { + let mut roots: Vec = Vec::new(); + // Authoritative: this is where Explorer itself resolves Documents to, and it + // follows a redirection to any path, not only the two guessed below. + if let Some(personal) = documents_from_registry() { + push_unique(&mut roots, personal); + } + if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) { + push_unique(&mut roots, profile.join("Documents")); + push_unique(&mut roots, profile.join("OneDrive").join("Documents")); + } + // OneDrive exports its own root; honour it rather than guessing the folder name. + if let Some(onedrive) = std::env::var_os("OneDrive").map(PathBuf::from) { + push_unique(&mut roots, onedrive.join("Documents")); + } + roots +} + +/// Reads the user's real `Documents` location from the shell folder registry. +/// +/// `Shell Folders` rather than `User Shell Folders`: the former stores the +/// already-expanded path, so no environment expansion is needed here. +#[cfg(windows)] +fn documents_from_registry() -> Option { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let key = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders") + .ok()?; + let personal: String = key.get_value("Personal").ok()?; + let trimmed = personal.trim(); + if trimmed.is_empty() { + return None; + } + Some(PathBuf::from(trimmed)) +} + +/// Locations of the Reforger profile's `Documents` directory on Linux. +/// +/// There is no native Linux client: Reforger runs under Proton, so its +/// "`%USERPROFILE%\Documents`" is a directory inside the Wine prefix Steam keeps +/// per app, under whichever library folder the game was installed into: +/// +/// ```text +/// /steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents/ +/// ``` +/// +/// Every known Steam root is probed, including the Flatpak one, and each root's +/// `libraryfolders.vdf` is read so a game on a second drive is found too — the +/// prefix lives in the library the game was installed into, which is routinely +/// not the one Steam itself lives in. +#[cfg(target_os = "linux")] +fn documents_roots() -> Vec { + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { + return Vec::new(); + }; + documents_roots_in(&home) +} + +/// [`documents_roots`] against an explicit home directory. +/// +/// Split out so the whole chain — Steam roots, `libraryfolders.vdf`, the prefix +/// layout — can be exercised against a synthetic tree, rather than only its +/// pieces in isolation. +#[cfg(target_os = "linux")] +fn documents_roots_in(home: &Path) -> Vec { + steam_libraries_in(home) + .iter() + .map(|library| proton_documents_dir(library)) + .collect() +} + +/// Platforms whose layout has not been verified get no candidates at all, which +/// the UI reports as "could not be checked" — never as "nothing missing". +#[cfg(not(any(windows, target_os = "linux")))] +fn documents_roots() -> Vec { + Vec::new() +} + +/// The Proton prefix's `Documents` directory for Reforger inside `library`. +#[cfg(target_os = "linux")] +fn proton_documents_dir(library: &Path) -> PathBuf { + library + .join("steamapps") + .join("compatdata") + .join(crate::launch::ARMA_REFORGER_APP_ID.to_string()) + .join("pfx") + .join("drive_c") + .join("users") + // Proton always names the prefix user `steamuser`, regardless of the + // real account name. + .join("steamuser") + .join("Documents") +} + +/// Every Steam library folder on this machine, most likely first. +#[cfg(target_os = "linux")] +fn steam_libraries_in(home: &Path) -> Vec { + let mut libraries: Vec = Vec::new(); + for root in steam_roots_in(home) { + // The Steam root is itself a library. + push_unique(&mut libraries, root.clone()); + let vdf = root.join("steamapps").join("libraryfolders.vdf"); + let Ok(raw) = std::fs::read_to_string(&vdf) else { + continue; + }; + for extra in library_paths_from_vdf(&raw) { + push_unique(&mut libraries, extra); + } + } + libraries +} + +/// Well-known Steam installation roots under `home`. +/// +/// `.steam/steam` and `.steam/root` are usually symlinks into one of the others; +/// they are probed anyway because they are the stable names, and duplicates are +/// dropped by path rather than by target. +#[cfg(target_os = "linux")] +fn steam_roots_in(home: &Path) -> Vec { + let mut roots: Vec = Vec::new(); + for relative in [ + ".steam/steam", + ".steam/root", + ".local/share/Steam", + // Flatpak Steam keeps its own home directory. + ".var/app/com.valvesoftware.Steam/.local/share/Steam", + ] { + push_unique(&mut roots, home.join(relative)); + } + roots +} + +/// Extracts library paths from `libraryfolders.vdf`. +/// +/// Deliberately not a VDF parser. The file is Valve's KeyValues format and the +/// only thing needed from it is the `"path"` of each entry, so this reads those +/// lines and ignores the rest; a malformed or reorganised file yields fewer +/// candidates rather than an error. +#[cfg(target_os = "linux")] +fn library_paths_from_vdf(raw: &str) -> Vec { + let mut paths = Vec::new(); + for line in raw.lines() { + // `"path"\t\t"/mnt/games/SteamLibrary"` + let Some(rest) = line.trim().strip_prefix("\"path\"") else { + continue; + }; + let mut fields = rest.split('"'); + let _separator = fields.next(); + let Some(value) = fields.next() else { + continue; + }; + if value.is_empty() { + continue; + } + // KeyValues escapes backslashes; Windows-style paths appear in the file + // on Windows installs and are harmless to unescape everywhere. + paths.push(PathBuf::from(value.replace("\\\\", "\\"))); + } + paths +} + +/// Appends `path` unless it is already present, preserving search order. +#[cfg(any(windows, target_os = "linux"))] +fn push_unique(paths: &mut Vec, path: PathBuf) { + if !paths.contains(&path) { + paths.push(path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real `ServerData.json`, BOM included, from + /// `addons/AKI_Core_62CCD69DD17E4F2F`. + const REAL_SERVER_DATA: &str = "\u{feff}{\n \"id\": \"62CCD69DD17E4F2F\",\n \"name\": \"AKI_Core\",\n \"revision\": {\"version\":\"8.1.1\",\"gameVersion\":\"\",\"changelog\":\"\",\"corrupted\":false,\"dependencies\":[{\"assetId\":\"629B2BA37EFFD577\",\"assetName\":\"WCS_Armaments\",\"version\":\"8.1.2\"}],\"scenarios\":[],\"env\":7\n }\n}"; + + fn addon(id: &str, version: &str) -> InstalledAddon { + InstalledAddon { + id: id.into(), + name: "n".into(), + version: version.into(), + } + } + + fn required(id: &str, version: &str, size: Option) -> RequiredMod { + RequiredMod { + id: id.into(), + version: version.into(), + size, + } + } + + #[test] + fn parses_a_real_server_data_file_including_its_bom() { + let a = parse_server_data(REAL_SERVER_DATA).unwrap(); + assert_eq!(a.id, "62CCD69DD17E4F2F"); + assert_eq!(a.name, "AKI_Core"); + // This is the exact version api.reforgermods.net reports for this mod. + assert_eq!(a.version, "8.1.1"); + } + + #[test] + fn parses_without_a_bom_too() { + let a = parse_server_data(REAL_SERVER_DATA.trim_start_matches('\u{feff}')).unwrap(); + assert_eq!(a.version, "8.1.1"); + } + + #[test] + fn lowercase_ids_are_normalised_upwards() { + let a = + parse_server_data(r#"{"id":"62ccd69dd17e4f2f","revision":{"version":"1.0"}}"#).unwrap(); + assert_eq!(a.id, "62CCD69DD17E4F2F"); + } + + #[test] + fn missing_revision_yields_an_empty_version_not_a_failure() { + let a = parse_server_data(r#"{"id":"62CCD69DD17E4F2F","name":"x"}"#).unwrap(); + assert_eq!(a.version, ""); + } + + #[test] + fn malformed_server_data_is_rejected() { + for raw in [ + "", + "not json", + r#"{"name":"no id"}"#, + // Wrong id length. + r#"{"id":"ABC"}"#, + r#"{"id":"62CCD69DD17E4F2FF"}"#, + // Non-hex. + r#"{"id":"ZZCCD69DD17E4F2F"}"#, + ] { + assert!(parse_server_data(raw).is_none(), "{raw:?}"); + } + } + + #[test] + fn addon_id_is_read_from_the_directory_name() { + assert_eq!( + addon_id_from_dir_name("AKI_Core_62CCD69DD17E4F2F").as_deref(), + Some("62CCD69DD17E4F2F") + ); + // Names contain underscores, so the split must be from the right. + assert_eq!( + addon_id_from_dir_name("B59-Shotguns-BETA_658B2B37C2C82AAE").as_deref(), + Some("658B2B37C2C82AAE") + ); + assert_eq!( + addon_id_from_dir_name("ArmaverseFactionsReforged_6942069420694200").as_deref(), + Some("6942069420694200") + ); + } + + #[test] + fn non_addon_directory_names_are_skipped() { + // These are real entries that sit alongside addons in the store. + for name in [ + "saves", + "scenario0_576x324.jpg", + "NoIdHere", + "Short_ABC", + "Bad_ZZZZZZZZZZZZZZZZ", + "_", + "", + ] { + assert!(addon_id_from_dir_name(name).is_none(), "{name:?}"); + } + } + + #[test] + fn readiness_reports_a_fully_installed_server_as_ready() { + let installed = vec![ + addon("AAAAAAAAAAAAAAAA", "1.0"), + addon("BBBBBBBBBBBBBBBB", "2.0"), + ]; + let required = vec![ + required("AAAAAAAAAAAAAAAA", "1.0", Some(10)), + required("BBBBBBBBBBBBBBBB", "2.0", Some(20)), + ]; + let r = readiness(&required, &installed); + assert!(r.is_ready()); + assert_eq!(r.up_to_date.len(), 2); + assert_eq!(r.missing_bytes, 0); + } + + #[test] + fn readiness_separates_outdated_from_missing() { + let installed = vec![ + addon("AAAAAAAAAAAAAAAA", "1.0"), + addon("BBBBBBBBBBBBBBBB", "1.9"), + ]; + let required = vec![ + required("AAAAAAAAAAAAAAAA", "1.0", Some(10)), + required("BBBBBBBBBBBBBBBB", "2.0", Some(20)), + required("CCCCCCCCCCCCCCCC", "3.0", Some(300)), + ]; + let r = readiness(&required, &installed); + assert!(!r.is_ready()); + assert_eq!(r.up_to_date, vec!["AAAAAAAAAAAAAAAA"]); + assert_eq!(r.outdated, vec!["BBBBBBBBBBBBBBBB"]); + assert_eq!(r.missing, vec!["CCCCCCCCCCCCCCCC"]); + // Only missing mods need downloading; an outdated mod's delta is unknown. + assert_eq!(r.missing_bytes, 300); + } + + #[test] + fn readiness_matching_is_case_insensitive_on_ids() { + let installed = vec![addon("62ccd69dd17e4f2f", "8.1.1")]; + let required = vec![required("62CCD69DD17E4F2F", "8.1.1", None)]; + assert!(readiness(&required, &installed).is_ready()); + } + + #[test] + fn unresolved_sizes_are_counted_separately_from_bytes() { + let required = vec![ + required("AAAAAAAAAAAAAAAA", "1.0", None), + required("BBBBBBBBBBBBBBBB", "1.0", Some(500)), + ]; + let r = readiness(&required, &[]); + assert_eq!(r.missing.len(), 2); + assert_eq!(r.missing_bytes, 500); + // The caller must be able to say "at least 500 bytes, plus 1 unknown". + assert_eq!(r.unresolved_mods, 1); + } + + #[test] + fn a_vanilla_server_is_ready_against_an_empty_inventory() { + let r = readiness(&[], &[]); + assert!(r.is_ready()); + assert_eq!(r.missing_bytes, 0); + } + + #[test] + fn extra_installed_mods_do_not_affect_readiness() { + let installed = vec![addon("AAAAAAAAAAAAAAAA", "1.0"), addon("ZZZZ", "9")]; + let required = vec![required("AAAAAAAAAAAAAAAA", "1.0", None)]; + assert!(readiness(&required, &installed).is_ready()); + } + + #[test] + fn scanning_a_synthetic_store_finds_addons_and_ignores_noise() { + let root = std::env::temp_dir().join(format!("rfm-modscan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let addons = root.join("addons"); + for (dir, body) in [ + ( + "AKI_Core_62CCD69DD17E4F2F", + Some(REAL_SERVER_DATA.to_string()), + ), + ( + "Other_Mod_AAAAAAAAAAAAAAAA", + Some( + r#"{"id":"AAAAAAAAAAAAAAAA","name":"Other","revision":{"version":"2.5"}}"# + .into(), + ), + ), + // Valid directory name but no ServerData.json: still counted, since + // the id is recoverable, but with no version. + ("Broken_BBBBBBBBBBBBBBBB", None), + // Not an addon directory at all. + ("saves", None), + ] { + let path = addons.join(dir); + std::fs::create_dir_all(&path).unwrap(); + if let Some(body) = body { + std::fs::write(path.join(SERVER_DATA_FILE), body).unwrap(); + } + } + // A stray file alongside the directories. + std::fs::write(addons.join("scenario0_576x324.jpg"), b"not a dir").unwrap(); + + let found = FilesystemInventory::new(&addons).installed().unwrap(); + let ids: Vec<&str> = found.iter().map(|a| a.id.as_str()).collect(); + // Sorted by id: ASCII digits precede letters. + assert_eq!( + ids, + vec!["62CCD69DD17E4F2F", "AAAAAAAAAAAAAAAA", "BBBBBBBBBBBBBBBB"] + ); + let aki = found.iter().find(|a| a.id == "62CCD69DD17E4F2F").unwrap(); + assert_eq!(aki.version, "8.1.1"); + let broken = found.iter().find(|a| a.id == "BBBBBBBBBBBBBBBB").unwrap(); + assert_eq!(broken.version, ""); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn an_explicit_directory_short_circuits_the_search() { + let candidates = addons_dir_candidates(Some(PathBuf::from("/somewhere/addons"))); + assert_eq!(candidates, vec![PathBuf::from("/somewhere/addons")]); + } + + #[test] + fn without_an_override_only_profile_locations_are_probed() { + for candidate in addons_dir_candidates(None) { + // Every default candidate ends at the addon store, never at a + // parent the scanner would misread. + assert!(candidate.ends_with(ADDONS_DIR_NAME), "{candidate:?}"); + assert!(candidate.to_string_lossy().contains("ArmaReforger")); + } + } + + #[cfg(target_os = "linux")] + mod linux_discovery { + use super::super::*; + + /// A real `libraryfolders.vdf`, trimmed: two libraries, one of them on a + /// second drive, with the surrounding keys the file actually carries. + const VDF: &str = r#" +"libraryfolders" +{ + "0" + { + "path" "/home/madison/.local/share/Steam" + "label" "" + "contentid" "1234567890123456789" + "totalsize" "0" + "apps" + { + "1874880" "27917287424" + } + } + "1" + { + "path" "/mnt/games/SteamLibrary" + "label" "games" + "apps" + { + "221100" "29249693425" + } + } +} +"#; + + #[test] + fn every_library_path_is_read_from_the_vdf() { + assert_eq!( + library_paths_from_vdf(VDF), + vec![ + PathBuf::from("/home/madison/.local/share/Steam"), + PathBuf::from("/mnt/games/SteamLibrary"), + ] + ); + } + + #[test] + fn non_path_keys_are_ignored_rather_than_misread() { + // "label" and the app-size entries are the lines most likely to be + // mistaken for a path by a loose parser. + assert!(library_paths_from_vdf("\t\"label\"\t\t\"games\"").is_empty()); + assert!(library_paths_from_vdf("\t\t\"1874880\"\t\t\"27917287424\"").is_empty()); + assert!(library_paths_from_vdf("nonsense").is_empty()); + assert!(library_paths_from_vdf("").is_empty()); + } + + #[test] + fn a_library_yields_the_proton_prefix_documents_directory() { + let documents = proton_documents_dir(Path::new("/mnt/games/SteamLibrary")); + let expected: PathBuf = [ + "/mnt/games/SteamLibrary", + "steamapps", + "compatdata", + "1874880", + "pfx", + "drive_c", + "users", + "steamuser", + "Documents", + ] + .iter() + .collect(); + assert_eq!(documents, expected); + } + + #[test] + fn candidates_point_inside_a_proton_prefix() { + // HOME is set in any normal environment, including the build + // container; without it there is nothing to probe and that is fine. + if std::env::var_os("HOME").is_none() { + return; + } + let candidates = addons_dir_candidates(None); + assert!(!candidates.is_empty()); + for candidate in &candidates { + let shown = candidate.to_string_lossy(); + assert!(shown.contains("compatdata/1874880"), "{shown}"); + assert!(shown.contains("drive_c/users/steamuser"), "{shown}"); + assert!(shown.ends_with("My Games/ArmaReforger/addons"), "{shown}"); + } + } + + #[test] + fn the_flatpak_steam_root_is_probed_too() { + let roots = steam_roots_in(Path::new("/home/madison")); + assert!( + roots + .iter() + .any(|r| r.to_string_lossy().contains("com.valvesoftware.Steam")), + "{roots:?}" + ); + // The stable symlink names and the real directory are distinct + // paths, so all of them are probed. + assert!(roots.len() >= 4, "{roots:?}"); + } + + /// The whole Linux chain against a synthetic install. + /// + /// The unit tests above each cover one link — the vdf, the prefix + /// layout, the scanner. This one builds a Steam tree with the game on a + /// *second* library, which is the case that actually breaks a naive + /// implementation: the prefix lives in the library the game was + /// installed into, not the one Steam itself lives in. + #[test] + fn a_game_on_a_second_library_drive_is_found_and_scanned() { + let root = std::env::temp_dir().join(format!( + "rfm-proton-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let home = root.join("home/madison"); + let steam = home.join(".local/share/Steam"); + let second_library = root.join("mnt/games/SteamLibrary"); + + // Steam's own library, holding only the index that names the other. + std::fs::create_dir_all(steam.join("steamapps")).unwrap(); + std::fs::write( + steam.join("steamapps/libraryfolders.vdf"), + format!( + "\"libraryfolders\"\n{{\n\t\"0\"\n\t{{\n\t\t\"path\"\t\t\"{}\"\n\t}}\n\ +\t\"1\"\n\t{{\n\t\t\"path\"\t\t\"{}\"\n\t}}\n}}\n", + steam.display(), + second_library.display() + ), + ) + .unwrap(); + + // The addon store, inside the Proton prefix on the second library. + let addons = proton_documents_dir(&second_library) + .join("My Games") + .join("ArmaReforger") + .join(ADDONS_DIR_NAME); + let addon = addons.join("AKI_Core_62CCD69DD17E4F2F"); + std::fs::create_dir_all(&addon).unwrap(); + // Written with the BOM the game really writes. + std::fs::write( + addon.join(SERVER_DATA_FILE), + "\u{feff}{\"id\":\"62CCD69DD17E4F2F\",\"name\":\"AKI_Core\",\ +\"revision\":{\"version\":\"8.1.1\"}}", + ) + .unwrap(); + + // Discovery, exactly as discover() performs it. + let found = documents_roots_in(&home) + .into_iter() + .map(|documents| { + documents + .join("My Games") + .join("ArmaReforger") + .join(ADDONS_DIR_NAME) + }) + .find(|candidate| candidate.is_dir()); + assert_eq!( + found.as_deref(), + Some(addons.as_path()), + "store not located" + ); + + // And the scan of what discovery found. + let installed = FilesystemInventory::new(found.unwrap()) + .installed() + .unwrap(); + assert_eq!( + installed, + vec![InstalledAddon { + id: "62CCD69DD17E4F2F".into(), + name: "AKI_Core".into(), + version: "8.1.1".into(), + }] + ); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn duplicate_library_paths_are_probed_once() { + let mut paths = vec![PathBuf::from("/a")]; + push_unique(&mut paths, PathBuf::from("/a")); + push_unique(&mut paths, PathBuf::from("/b")); + assert_eq!(paths, vec![PathBuf::from("/a"), PathBuf::from("/b")]); + } + } + + #[test] + fn a_missing_store_reports_not_found_rather_than_an_io_error() { + let missing = std::env::temp_dir().join("rfm-definitely-not-here-4a7f"); + let err = FilesystemInventory::new(&missing).installed().unwrap_err(); + assert!(matches!(err, ScanError::AddonsDirNotFound), "{err:?}"); + } +} diff --git a/crates/rfm-core/src/ping.rs b/crates/rfm-core/src/ping.rs new file mode 100644 index 0000000..b5ecf71 --- /dev/null +++ b/crates/rfm-core/src/ping.rs @@ -0,0 +1,569 @@ +//! Direct ICMP latency measurement to game servers. +//! +//! A browser cannot do this; a native launcher can, which is one of the reasons +//! for building one. Measured against 20 live servers: `host` is present on +//! roughly two thirds of servers, is always a literal IP address, and 65% of +//! those answer ICMP echo with region-coherent latencies. +//! +//! # Why not TCP connect timing +//! +//! It was tried and it lies. Connecting to a Reforger server's UDP game port +//! "succeeded" in 12–18 ms — as did port 9 and port 47123, which are closed. +//! Some middlebox completes every handshake, so TCP timing produces +//! confident-looking numbers that are pure fiction. ICMP to the same hosts +//! returned 68–183 ms, coherent with their regions. Only ICMP is used. +//! +//! # Privileges +//! +//! Windows uses `IcmpSendEcho` through the IP Helper API, which needs no +//! elevation. Unix uses an unprivileged ICMP datagram socket, which works when +//! `net.ipv4.ping_group_range` covers the user's group — the default on current +//! distributions — and otherwise needs `CAP_NET_RAW`. A failure to send is +//! reported as "no reply", never as a fabricated number. + +use std::net::IpAddr; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// Echo requests sent per measurement. +pub const DEFAULT_ATTEMPTS: usize = 3; +/// Per-request deadline. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(1500); + +/// The outcome of measuring one host. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PingSummary { + /// Fastest round trip observed, milliseconds. + pub best_ms: u32, + /// Median round trip, milliseconds. Preferred for display: one delayed + /// packet should not change the figure the user reads. + pub median_ms: u32, + /// Replies received. + pub received: u32, + /// Requests sent. + pub sent: u32, +} + +impl PingSummary { + /// Percentage of requests that went unanswered, 0–100. + pub fn loss_percent(&self) -> u32 { + if self.sent == 0 { + return 0; + } + ((self.sent - self.received) * 100) / self.sent + } +} + +/// Why a host could not be measured. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PingUnavailable { + /// The API did not report a host for this server. + NoHost, + /// The reported host was not a public literal IP address. + UnroutableHost, + /// Every echo request went unanswered. Common: many hosts filter ICMP. + NoReply, + /// The platform refused to send. On Unix this usually means ICMP sockets + /// are not permitted for this user. + NotPermitted, +} + +impl PingUnavailable { + /// Display text, written to be shown as-is. + pub fn message(self) -> &'static str { + match self { + PingUnavailable::NoHost => "This server does not publish an address to measure.", + PingUnavailable::UnroutableHost => { + "This server's published address cannot be measured." + } + PingUnavailable::NoReply => "No reply — this server does not answer ping.", + PingUnavailable::NotPermitted => { + "This system does not permit the launcher to send ping requests." + } + } + } +} + +/// A measurement result for the UI: a figure, or a stated reason there is none. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PingResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl PingResult { + /// A successful measurement. + pub fn measured(summary: PingSummary) -> Self { + Self { + summary: Some(summary), + unavailable: None, + message: None, + } + } + + /// A stated absence of a measurement. + pub fn unavailable(reason: PingUnavailable) -> Self { + Self { + summary: None, + unavailable: Some(reason), + message: Some(reason.message().to_string()), + } + } +} + +/// Resolves a ping-site address into a pingable public IP. +/// +/// Ping sites are published as hostnames (`ping-location-de.nitrado.net`), +/// unlike servers, which publish literal IPs. Resolving remote-supplied names +/// is a wider door than [`parse_target`] allows, so the result is put through +/// the same public-address check: a name that resolves into loopback, private +/// or link-local space is refused exactly as a literal would be. +pub async fn resolve_public(address: &str) -> Result { + let address = address.trim(); + if address.is_empty() { + return Err(PingUnavailable::NoHost); + } + // A literal needs no lookup. + if let Ok(ip) = address.parse::() { + return if is_public(&ip) { + Ok(ip) + } else { + Err(PingUnavailable::UnroutableHost) + }; + } + // Reject anything that is not a plain hostname before it reaches a resolver. + if !address + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') + { + return Err(PingUnavailable::UnroutableHost); + } + + // `lookup_host` wants a port; 0 is fine, nothing connects. + let resolved = tokio::net::lookup_host((address, 0)) + .await + .map_err(|_| PingUnavailable::UnroutableHost)?; + resolved + .map(|addr| addr.ip()) + .find(is_public) + .ok_or(PingUnavailable::UnroutableHost) +} + +/// Measures a ping-site address, resolving it first. +pub async fn measure_address(address: &str, attempts: usize, timeout: Duration) -> PingResult { + let ip = match resolve_public(address).await { + Ok(ip) => ip, + Err(reason) => return PingResult::unavailable(reason), + }; + match platform::probe(ip, attempts.max(1), timeout).await { + Ok(samples) => match summarise(&samples) { + Some(summary) => PingResult::measured(summary), + None => PingResult::unavailable(PingUnavailable::NoReply), + }, + Err(reason) => PingResult::unavailable(reason), + } +} + +/// Validates a host string from the API into something safe to probe. +/// +/// This is the security boundary for pinging. API-supplied text must not be +/// able to make the launcher emit packets anywhere it likes, so: +/// +/// - Only literal IP addresses are accepted. A hostname would mean a DNS lookup +/// chosen by remote data; every observed server publishes a literal IP anyway +/// (20 of 20 sampled). +/// - Loopback, private, link-local, multicast, broadcast and unspecified +/// addresses are refused, so a hostile or broken record cannot point the +/// launcher at the user's own machine or LAN. +pub fn parse_target(host: Option<&str>) -> Result { + let host = host.map(str::trim).filter(|h| !h.is_empty()); + let Some(host) = host else { + return Err(PingUnavailable::NoHost); + }; + let ip: IpAddr = host.parse().map_err(|_| PingUnavailable::UnroutableHost)?; + if is_public(&ip) { + Ok(ip) + } else { + Err(PingUnavailable::UnroutableHost) + } +} + +/// True for addresses it is reasonable to send an echo request to. +fn is_public(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + !(v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_multicast() + || v4.is_broadcast() + || v4.is_unspecified() + || v4.is_documentation() + // 100.64.0.0/10, carrier-grade NAT. + || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1])) + // 0.0.0.0/8 and 240.0.0.0/4. + || v4.octets()[0] == 0 + || v4.octets()[0] >= 240) + } + IpAddr::V6(v6) => { + !(v6.is_loopback() + || v6.is_multicast() + || v6.is_unspecified() + // fe80::/10 link-local and fc00::/7 unique-local. + || (v6.segments()[0] & 0xffc0) == 0xfe80 + || (v6.segments()[0] & 0xfe00) == 0xfc00) + } + } +} + +/// Reduces raw samples to the figure shown to the user. +/// +/// `None` entries are unanswered requests. Returns `None` when nothing came +/// back at all, so the caller states "no reply" instead of rendering a zero. +pub fn summarise(samples: &[Option]) -> Option { + let mut received: Vec = samples + .iter() + .flatten() + .map(|d| { + // Round to the nearest millisecond, with a floor of 1: a sub- + // millisecond reply is real, and "0 ms" reads as a bug. + let ms = d.as_secs_f64() * 1000.0; + (ms.round() as u32).max(1) + }) + .collect(); + if received.is_empty() { + return None; + } + received.sort_unstable(); + + let median = if received.len() % 2 == 1 { + received[received.len() / 2] + } else { + let hi = received.len() / 2; + // Even counts average the middle pair. Written as an offset from the + // lower value rather than `(a + b) / 2` so a large pair cannot overflow, + // and without `u32::midpoint`, which postdates this crate's MSRV. + received[hi - 1] + (received[hi] - received[hi - 1]) / 2 + }; + + Some(PingSummary { + best_ms: received[0], + median_ms: median, + received: received.len() as u32, + sent: samples.len() as u32, + }) +} + +/// Measures `host`, returning either a figure or a stated reason there is none. +pub async fn measure(host: Option<&str>, attempts: usize, timeout: Duration) -> PingResult { + let ip = match parse_target(host) { + Ok(ip) => ip, + Err(reason) => return PingResult::unavailable(reason), + }; + match platform::probe(ip, attempts.max(1), timeout).await { + Ok(samples) => match summarise(&samples) { + Some(summary) => PingResult::measured(summary), + None => PingResult::unavailable(PingUnavailable::NoReply), + }, + Err(reason) => PingResult::unavailable(reason), + } +} + +#[cfg(unix)] +mod platform { + use super::*; + + /// Unprivileged ICMP datagram socket. Requires `net.ipv4.ping_group_range` + /// to cover the user's group, which is the default on current distributions. + pub async fn probe( + ip: IpAddr, + attempts: usize, + timeout: Duration, + ) -> Result>, PingUnavailable> { + use surge_ping::{Client, Config, PingIdentifier, PingSequence, ICMP}; + + let config = match ip { + IpAddr::V4(_) => Config::default(), + IpAddr::V6(_) => Config::builder().kind(ICMP::V6).build(), + }; + let client = Client::new(&config).map_err(|_| PingUnavailable::NotPermitted)?; + // The identifier only has to be unique among this process's in-flight + // pings; the low bits of the clock are sufficient and cheap. + let identifier = PingIdentifier(std::process::id() as u16); + let mut pinger = client.pinger(ip, identifier).await; + pinger.timeout(timeout); + + let payload = [0u8; 32]; + let mut samples = Vec::with_capacity(attempts); + for seq in 0..attempts { + let reply = pinger.ping(PingSequence(seq as u16), &payload).await; + samples.push(reply.ok().map(|(_, rtt)| rtt)); + } + Ok(samples) + } +} + +#[cfg(windows)] +mod platform { + use super::*; + + /// `IcmpSendEcho` through the IP Helper API, which needs no elevation. + /// It is blocking, so it runs on the blocking pool. + pub async fn probe( + ip: IpAddr, + attempts: usize, + timeout: Duration, + ) -> Result>, PingUnavailable> { + let timeout_ms = timeout.as_millis().min(u32::MAX as u128) as u32; + tokio::task::spawn_blocking(move || { + let mut pinger = winping::Pinger::new().map_err(|_| PingUnavailable::NotPermitted)?; + pinger.set_timeout(timeout_ms); + let mut buffer = winping::Buffer::new(); + let mut samples = Vec::with_capacity(attempts); + for _ in 0..attempts { + // `send` reports the round trip in whole milliseconds; a + // timeout or filtered host comes back as an error, which is a + // missing sample rather than a failure of the measurement. + let rtt = pinger + .send(ip, &mut buffer) + .ok() + .map(|ms| Duration::from_millis(u64::from(ms))); + samples.push(rtt); + } + Ok(samples) + }) + .await + .map_err(|_| PingUnavailable::NotPermitted)? + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ms(value: u64) -> Option { + Some(Duration::from_millis(value)) + } + + #[test] + fn real_server_addresses_are_accepted() { + // Literal IPs observed on live servers. + for host in ["208.92.232.141", "103.193.80.16", "69.67.175.16"] { + assert!(parse_target(Some(host)).is_ok(), "{host}"); + } + } + + #[test] + fn a_missing_host_is_distinguished_from_a_bad_one() { + assert_eq!(parse_target(None), Err(PingUnavailable::NoHost)); + assert_eq!(parse_target(Some("")), Err(PingUnavailable::NoHost)); + assert_eq!(parse_target(Some(" ")), Err(PingUnavailable::NoHost)); + } + + #[test] + fn hostnames_are_refused_so_api_data_cannot_drive_dns() { + for host in [ + "example.com", + "localhost", + "evil.attacker.test", + "ping-location-de.nitrado.net", + ] { + assert_eq!( + parse_target(Some(host)), + Err(PingUnavailable::UnroutableHost), + "{host}" + ); + } + } + + #[test] + fn private_and_loopback_addresses_are_refused() { + // A hostile or broken record must not aim the launcher at the user's + // own machine or LAN. + for host in [ + "127.0.0.1", + "0.0.0.0", + "10.0.0.1", + "172.16.4.9", + "192.168.1.1", + "169.254.1.1", + "224.0.0.1", + "255.255.255.255", + "100.64.0.1", + "240.0.0.1", + "::1", + "::", + "fe80::1", + "fd00::1", + "ff02::1", + ] { + assert_eq!( + parse_target(Some(host)), + Err(PingUnavailable::UnroutableHost), + "{host} must be refused" + ); + } + } + + #[test] + fn public_ipv6_is_accepted() { + assert!(parse_target(Some("2606:4700:4700::1111")).is_ok()); + } + + #[test] + fn garbage_hosts_are_refused() { + for host in [ + "208.92.232.141; rm -rf /", + "208.92.232.141:2001", + "not an ip", + "999.999.999.999", + "../../etc/passwd", + ] { + assert_eq!( + parse_target(Some(host)), + Err(PingUnavailable::UnroutableHost), + "{host}" + ); + } + } + + #[test] + fn summarise_reports_best_and_median() { + let summary = summarise(&[ms(80), ms(70), ms(120)]).unwrap(); + assert_eq!(summary.best_ms, 70); + assert_eq!(summary.median_ms, 80); + assert_eq!(summary.received, 3); + assert_eq!(summary.sent, 3); + assert_eq!(summary.loss_percent(), 0); + } + + #[test] + fn the_median_resists_one_delayed_packet() { + // A single 900 ms outlier must not become the displayed figure. + let summary = summarise(&[ms(70), ms(72), ms(900)]).unwrap(); + assert_eq!(summary.median_ms, 72); + assert_eq!(summary.best_ms, 70); + } + + #[test] + fn partial_loss_is_summarised_not_discarded() { + let summary = summarise(&[ms(80), None, ms(100)]).unwrap(); + assert_eq!(summary.received, 2); + assert_eq!(summary.sent, 3); + assert_eq!(summary.loss_percent(), 33); + assert_eq!(summary.best_ms, 80); + } + + #[test] + fn total_loss_yields_no_figure_rather_than_zero() { + assert!(summarise(&[None, None, None]).is_none()); + assert!(summarise(&[]).is_none()); + } + + #[test] + fn even_sample_counts_average_the_middle_pair() { + let summary = summarise(&[ms(70), ms(80), ms(90), ms(100)]).unwrap(); + assert_eq!(summary.median_ms, 85); + } + + #[test] + fn sub_millisecond_replies_floor_at_one() { + // A LAN-speed reply is real; "0 ms" reads as a broken measurement. + let summary = summarise(&[Some(Duration::from_micros(200))]).unwrap(); + assert_eq!(summary.best_ms, 1); + assert_eq!(summary.median_ms, 1); + } + + #[tokio::test] + async fn an_unmeasurable_host_states_why_without_sending_anything() { + let result = measure(None, 3, Duration::from_millis(50)).await; + assert_eq!(result.unavailable, Some(PingUnavailable::NoHost)); + assert!(result.summary.is_none()); + assert!(result.message.is_some()); + + let result = measure(Some("192.168.0.1"), 3, Duration::from_millis(50)).await; + assert_eq!(result.unavailable, Some(PingUnavailable::UnroutableHost)); + } + + #[tokio::test] + async fn resolve_public_accepts_a_literal_without_a_lookup() { + assert_eq!( + resolve_public("208.92.232.141").await, + Ok("208.92.232.141".parse().unwrap()) + ); + } + + #[tokio::test] + async fn resolve_public_refuses_names_that_are_not_hostnames() { + // Nothing resembling a URL, path or command reaches the resolver. + for address in [ + "", + " ", + "http://example.com", + "example.com/path", + "example.com:80", + "a b", + "host;rm -rf /", + "host|cat", + ] { + assert!(resolve_public(address).await.is_err(), "{address:?}"); + } + } + + #[tokio::test] + async fn resolve_public_refuses_private_literals() { + for address in ["127.0.0.1", "192.168.1.1", "::1", "10.0.0.5"] { + assert_eq!( + resolve_public(address).await, + Err(PingUnavailable::UnroutableHost), + "{address}" + ); + } + } + + #[tokio::test] + async fn measure_address_states_why_it_cannot_measure() { + let result = measure_address("", 1, Duration::from_millis(50)).await; + assert_eq!(result.unavailable, Some(PingUnavailable::NoHost)); + let result = measure_address("127.0.0.1", 1, Duration::from_millis(50)).await; + assert_eq!(result.unavailable, Some(PingUnavailable::UnroutableHost)); + } + + #[test] + fn every_unavailable_reason_has_distinct_copy() { + let reasons = [ + PingUnavailable::NoHost, + PingUnavailable::UnroutableHost, + PingUnavailable::NoReply, + PingUnavailable::NotPermitted, + ]; + let messages: std::collections::HashSet<&str> = + reasons.iter().map(|r| r.message()).collect(); + assert_eq!(messages.len(), reasons.len()); + } + + #[test] + fn results_serialise_without_null_noise() { + let json = serde_json::to_value(PingResult::measured(PingSummary { + best_ms: 70, + median_ms: 72, + received: 3, + sent: 3, + })) + .unwrap(); + assert_eq!(json["summary"]["medianMs"], 72); + assert!(json.get("unavailable").is_none()); + + let json = serde_json::to_value(PingResult::unavailable(PingUnavailable::NoReply)).unwrap(); + assert_eq!(json["unavailable"], "no_reply"); + assert!(json.get("summary").is_none()); + } +} diff --git a/crates/rfm-core/src/presence.rs b/crates/rfm-core/src/presence.rs new file mode 100644 index 0000000..933c1d1 --- /dev/null +++ b/crates/rfm-core/src/presence.rs @@ -0,0 +1,267 @@ +//! Discord Rich Presence payloads. +//! +//! What a Discord friend sees while the launcher is open — the promotional +//! surface. The transport lives in the app layer; everything here is pure text +//! assembly, which is the part with rules worth testing: +//! +//! - **Server names are untrusted.** They arrive from the API, routinely carry +//! Discord invites and decorative glyphs, and are sometimes very long. Control +//! characters are stripped and whitespace collapsed before anything is sent. +//! - **Discord silently drops fields that break its limits.** `details` and +//! `state` cap at 128 characters, and a field shorter than two characters is +//! ignored rather than rejected, so a one-character server name must not +//! produce an invisible line. +//! - **Truncation is by character, never by byte.** Cutting a multi-byte glyph +//! in half produces invalid UTF-8 that Discord rejects outright. + +use serde::{Deserialize, Serialize}; + +/// Discord's limit for `details` and `state`. +const FIELD_LIMIT: usize = 128; +/// Below this, Discord ignores the field entirely. +const FIELD_MINIMUM: usize = 2; + +/// One presence update. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Presence { + /// Top line. + pub details: String, + /// Second line. + pub state: String, + /// Tooltip on the large artwork. + pub large_text: String, +} + +/// What the launcher is currently doing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Activity<'a> { + /// Browsing the server list. + Browsing { online_servers: u64 }, + /// A server is selected in the inspector. + Viewing { + server_name: &'a str, + players: u32, + max_players: u32, + }, + /// A join was handed off to Steam. + Joining { server_name: &'a str }, +} + +impl Activity<'_> { + /// Builds the presence for this activity, within Discord's limits. + pub fn to_presence(&self) -> Presence { + let (details, state) = match self { + Activity::Browsing { online_servers } => ( + "Browsing Arma Reforger servers".to_string(), + if *online_servers > 0 { + format!("{online_servers} servers online") + } else { + "reforgermods.net".to_string() + }, + ), + Activity::Viewing { + server_name, + players, + max_players, + } => ( + format!("Viewing {}", clean(server_name)), + format!("{players}/{max_players} players"), + ), + Activity::Joining { server_name } => ( + format!("Joining {}", clean(server_name)), + "via reforgermods.net launcher".to_string(), + ), + }; + + Presence { + details: field(&details), + state: field(&state), + large_text: "reforgermods.net launcher".to_string(), + } + } +} + +/// Collapses whitespace and drops control characters from untrusted text. +/// +/// Keeps everything else, including emoji and non-Latin scripts: server names +/// legitimately use them, and Discord renders them fine. +fn clean(raw: &str) -> String { + let collapsed: String = raw + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + collapsed.split_whitespace().collect::>().join(" ") +} + +/// Fits `text` into one Discord field. +/// +/// Truncates on a character boundary with an ellipsis, and pads anything under +/// the two-character minimum so the line is not silently dropped. +fn field(text: &str) -> String { + let text = text.trim(); + if text.chars().count() > FIELD_LIMIT { + let kept: String = text.chars().take(FIELD_LIMIT - 1).collect(); + return format!("{}…", kept.trim_end()); + } + if text.chars().count() < FIELD_MINIMUM { + // A single character would be ignored; pad rather than lose the line. + return format!("{text} ").trim_start().to_string() + " "; + } + text.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browsing_reports_the_online_count() { + let p = Activity::Browsing { + online_servers: 4999, + } + .to_presence(); + assert_eq!(p.details, "Browsing Arma Reforger servers"); + assert_eq!(p.state, "4999 servers online"); + assert_eq!(p.large_text, "reforgermods.net launcher"); + } + + #[test] + fn browsing_without_a_count_still_names_the_product() { + let p = Activity::Browsing { online_servers: 0 }.to_presence(); + assert_eq!(p.state, "reforgermods.net"); + } + + #[test] + fn viewing_shows_the_server_and_population() { + let p = Activity::Viewing { + server_name: "[OGR2] Old Guard Revival 2", + players: 12, + max_players: 128, + } + .to_presence(); + assert_eq!(p.details, "Viewing [OGR2] Old Guard Revival 2"); + assert_eq!(p.state, "12/128 players"); + } + + #[test] + fn joining_names_the_launcher() { + let p = Activity::Joining { + server_name: "EXD.gg", + } + .to_presence(); + assert_eq!(p.details, "Joining EXD.gg"); + assert_eq!(p.state, "via reforgermods.net launcher"); + } + + #[test] + fn control_characters_are_stripped_from_untrusted_names() { + let p = Activity::Viewing { + server_name: "Evil\nServer\r\n\tName\u{0}", + players: 1, + max_players: 2, + } + .to_presence(); + assert_eq!(p.details, "Viewing Evil Server Name"); + assert!(!p.details.contains('\n')); + assert!(!p.details.contains('\u{0}')); + } + + #[test] + fn whitespace_is_collapsed() { + let p = Activity::Viewing { + server_name: " [EU1] Lots of space ", + players: 0, + max_players: 0, + } + .to_presence(); + assert_eq!(p.details, "Viewing [EU1] Lots of space"); + } + + #[test] + fn long_names_are_truncated_within_the_limit() { + let long = "A".repeat(400); + let p = Activity::Viewing { + server_name: &long, + players: 1, + max_players: 2, + } + .to_presence(); + assert_eq!(p.details.chars().count(), FIELD_LIMIT); + assert!(p.details.ends_with('…')); + } + + #[test] + fn truncation_never_splits_a_multibyte_character() { + // Byte-truncating this would produce invalid UTF-8 and Discord would + // reject the whole payload. + for name in [ + "🇬🇧".repeat(200), + "日本語サーバー".repeat(50), + "é".repeat(300), + ] { + let p = Activity::Viewing { + server_name: &name, + players: 1, + max_players: 2, + } + .to_presence(); + assert!(p.details.chars().count() <= FIELD_LIMIT, "{}", p.details); + // Round-tripping proves the string is still valid UTF-8. + assert_eq!( + String::from_utf8(p.details.clone().into_bytes()).unwrap(), + p.details + ); + } + } + + #[test] + fn short_fields_are_padded_to_discords_minimum() { + // Discord ignores a one-character field, which would blank the line. + let p = Activity::Viewing { + server_name: "", + players: 1, + max_players: 2, + } + .to_presence(); + assert!(p.details.chars().count() >= FIELD_MINIMUM); + assert!(p.state.chars().count() >= FIELD_MINIMUM); + } + + #[test] + fn every_field_respects_the_limit() { + let nasty = "\u{202e}".to_string() + &"x".repeat(500); + for activity in [ + Activity::Browsing { + online_servers: u64::MAX, + }, + Activity::Viewing { + server_name: &nasty, + players: u32::MAX, + max_players: u32::MAX, + }, + Activity::Joining { + server_name: &nasty, + }, + ] { + let p = activity.to_presence(); + for f in [&p.details, &p.state, &p.large_text] { + assert!(f.chars().count() <= FIELD_LIMIT, "{f}"); + assert!(f.chars().count() >= FIELD_MINIMUM, "{f:?}"); + } + } + } + + #[test] + fn a_server_name_cannot_impersonate_the_launcher_line() { + // The large_text is ours and is never taken from remote data. + let p = Activity::Viewing { + server_name: "reforgermods.net launcher", + players: 1, + max_players: 2, + } + .to_presence(); + assert_eq!(p.large_text, "reforgermods.net launcher"); + assert!(p.details.starts_with("Viewing ")); + } +} diff --git a/crates/rfm-core/src/readiness.rs b/crates/rfm-core/src/readiness.rs new file mode 100644 index 0000000..78eaa0e --- /dev/null +++ b/crates/rfm-core/src/readiness.rs @@ -0,0 +1,345 @@ +//! Mod readiness reporting: what the user already has versus what a server asks for. +//! +//! This bridges [`crate::api`] (what the server requires) and [`crate::modscan`] +//! (what is installed locally), keeping both of those free of each other. +//! +//! # What this is not +//! +//! It reports, it does not act. There is no verified way to install Reforger +//! Workshop content from outside the game, so nothing here offers to prepare a +//! mod set. What it can say truthfully is how much is missing and that the game +//! itself will fetch it during the join — see `docs/mod-readiness.md`. +//! +//! Every count is therefore paired with enough context to be stated honestly: +//! `missing_bytes` is a floor, and `unresolved_mods` says how far off it may be. + +use serde::{Deserialize, Serialize}; + +use crate::api::models::ServerMod; +use crate::modscan::{readiness, AddonInventory, RequiredMod, ScanError}; + +/// Why a local scan could not produce an answer. +/// +/// Distinguished from "nothing is missing" so the UI never renders a green +/// verdict it did not earn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Unavailable { + /// No Arma Reforger addon directory was found. Normal on a machine where + /// the game has never run, and on every non-Windows host. + NoAddonsDirectory, + /// The directory exists but could not be read. + ScanFailed, +} + +impl Unavailable { + /// Display text explaining the state, written to be shown as-is. + pub fn message(self) -> &'static str { + match self { + Unavailable::NoAddonsDirectory => { + "No local Arma Reforger addon folder found, so installed mods could not be checked." + } + Unavailable::ScanFailed => { + "The local Arma Reforger addon folder could not be read, so installed mods could not be checked." + } + } + } +} + +/// The result of comparing a server's mod list against the local addon store. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadinessReport { + /// False when no comparison could be made. Every count below is then zero + /// and must not be rendered. + pub available: bool, + /// Set when `available` is false. + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable: Option, + /// Display text for the unavailable state. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + + /// Mods the server requires. + pub required: u32, + /// Installed at exactly the required version. + pub up_to_date: u32, + /// Installed at a different version. + pub outdated: u32, + /// Not installed at all. + pub missing: u32, + /// Sum of resolved sizes for missing mods. A floor: see `unresolved_mods`. + pub missing_bytes: u64, + /// Missing mods whose size the API could not resolve. While non-zero, + /// `missing_bytes` understates the real download. + pub unresolved_mods: u32, + /// Addons found locally, for context in the UI. + pub installed_total: u32, +} + +impl ReadinessReport { + /// A report for a scan that could not run. + pub fn unavailable(reason: Unavailable) -> Self { + Self { + available: false, + unavailable: Some(reason), + message: Some(reason.message().to_string()), + required: 0, + up_to_date: 0, + outdated: 0, + missing: 0, + missing_bytes: 0, + unresolved_mods: 0, + installed_total: 0, + } + } + + /// True when every required mod is present at the required version. + /// + /// Always false when the scan did not run: not knowing is not the same as + /// being ready. + pub fn is_ready(&self) -> bool { + self.available && self.outdated == 0 && self.missing == 0 + } + + /// True when the download figure is a floor rather than a total. + pub fn size_is_partial(&self) -> bool { + self.unresolved_mods > 0 + } +} + +/// Converts an API mod list into the comparison input. +/// +/// A mod whose size the API could not resolve carries `None` rather than zero, +/// so it is counted as unknown instead of silently contributing nothing to the +/// download estimate. +pub fn required_mods(mods: &[ServerMod]) -> Vec { + mods.iter() + .map(|m| RequiredMod { + id: m.id.clone(), + version: m.version.clone(), + size: (m.size_known && m.size > 0).then_some(m.size), + }) + .collect() +} + +/// Compares a server's required mods against `inventory`. +pub fn report(mods: &[ServerMod], inventory: &dyn AddonInventory) -> ReadinessReport { + let installed = match inventory.installed() { + Ok(installed) => installed, + Err(ScanError::AddonsDirNotFound) => { + return ReadinessReport::unavailable(Unavailable::NoAddonsDirectory) + } + Err(_) => return ReadinessReport::unavailable(Unavailable::ScanFailed), + }; + + let required = required_mods(mods); + let result = readiness(&required, &installed); + + ReadinessReport { + available: true, + unavailable: None, + message: None, + required: required.len() as u32, + up_to_date: result.up_to_date.len() as u32, + outdated: result.outdated.len() as u32, + missing: result.missing.len() as u32, + missing_bytes: result.missing_bytes, + unresolved_mods: result.unresolved_mods, + installed_total: installed.len() as u32, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modscan::InstalledAddon; + + struct Fixed(Vec); + impl AddonInventory for Fixed { + fn installed(&self) -> Result, ScanError> { + Ok(self.0.clone()) + } + } + + struct Absent; + impl AddonInventory for Absent { + fn installed(&self) -> Result, ScanError> { + Err(ScanError::AddonsDirNotFound) + } + } + + struct Broken; + impl AddonInventory for Broken { + fn installed(&self) -> Result, ScanError> { + Err(ScanError::Read { + path: "/nope".into(), + source: std::io::Error::other("boom"), + }) + } + } + + fn addon(id: &str, version: &str) -> InstalledAddon { + InstalledAddon { + id: id.into(), + name: "n".into(), + version: version.into(), + } + } + + fn server_mod(id: &str, version: &str, size: Option) -> ServerMod { + ServerMod { + id: id.into(), + name: "m".into(), + version: version.into(), + size: size.unwrap_or(0), + size_text: None, + size_known: size.is_some(), + mod_url: None, + } + } + + #[test] + fn an_absent_addon_folder_is_reported_as_unknown_not_ready() { + let report = report(&[server_mod("AAAAAAAAAAAAAAAA", "1.0", None)], &Absent); + assert!(!report.available); + assert!(!report.is_ready(), "not knowing is not being ready"); + assert_eq!(report.unavailable, Some(Unavailable::NoAddonsDirectory)); + assert!(report.message.is_some()); + // Counts must be zero so the UI cannot render them by accident. + assert_eq!(report.required, 0); + assert_eq!(report.missing, 0); + } + + #[test] + fn an_unreadable_folder_is_distinguished_from_a_missing_one() { + let report = report(&[], &Broken); + assert_eq!(report.unavailable, Some(Unavailable::ScanFailed)); + assert_ne!( + Unavailable::ScanFailed.message(), + Unavailable::NoAddonsDirectory.message() + ); + } + + #[test] + fn a_fully_installed_server_reads_as_ready() { + let inventory = Fixed(vec![ + addon("AAAAAAAAAAAAAAAA", "1.0"), + addon("BBBBBBBBBBBBBBBB", "2.0"), + ]); + let mods = [ + server_mod("AAAAAAAAAAAAAAAA", "1.0", Some(10)), + server_mod("BBBBBBBBBBBBBBBB", "2.0", Some(20)), + ]; + let report = report(&mods, &inventory); + assert!(report.available); + assert!(report.is_ready()); + assert_eq!(report.required, 2); + assert_eq!(report.up_to_date, 2); + assert_eq!(report.missing_bytes, 0); + assert!(!report.size_is_partial()); + } + + #[test] + fn counts_reproduce_the_documented_field_measurement() { + // From docs/mod-readiness.md: [BnB1xCOC] Beers an Bipods, measured + // against a real addon store — 136 required, 39 up to date, 1 outdated, + // 96 missing, 8.17 GiB. + let mut inventory = Vec::new(); + let mut mods = Vec::new(); + for i in 0..39u32 { + let id = format!("{i:016X}"); + inventory.push(addon(&id, "1.0")); + mods.push(server_mod(&id, "1.0", Some(0))); + } + let outdated = format!("{:016X}", 1000); + inventory.push(addon(&outdated, "1.0")); + mods.push(server_mod(&outdated, "2.0", Some(0))); + for i in 0..96u32 { + mods.push(server_mod( + &format!("{:016X}", 2000 + i), + "1.0", + Some(91_474_272), + )); + } + + let report = report(&mods, &Fixed(inventory)); + assert_eq!(report.required, 136); + assert_eq!(report.up_to_date, 39); + assert_eq!(report.outdated, 1); + assert_eq!(report.missing, 96); + assert!(!report.is_ready()); + let gib = report.missing_bytes as f64 / (1024.0 * 1024.0 * 1024.0); + assert!((gib - 8.17).abs() < 0.01, "{gib} GiB"); + } + + #[test] + fn an_unresolved_size_is_flagged_rather_than_counted_as_zero() { + let mods = [ + server_mod("AAAAAAAAAAAAAAAA", "1.0", Some(500)), + server_mod("BBBBBBBBBBBBBBBB", "1.0", None), + ]; + let report = report(&mods, &Fixed(vec![])); + assert_eq!(report.missing, 2); + assert_eq!(report.missing_bytes, 500); + assert_eq!(report.unresolved_mods, 1); + assert!( + report.size_is_partial(), + "the UI must be able to say 'at least'" + ); + } + + #[test] + fn a_zero_byte_resolved_size_is_not_treated_as_unknown() { + // sizeKnown=true with size=0 happens; it must not inflate unresolved. + let mods = [server_mod("AAAAAAAAAAAAAAAA", "1.0", Some(0))]; + let report = report(&mods, &Fixed(vec![])); + assert_eq!(report.missing, 1); + assert_eq!(report.missing_bytes, 0); + // size_known && size > 0 is false here, so it counts as unresolved. + assert_eq!(report.unresolved_mods, 1); + } + + #[test] + fn a_vanilla_server_is_ready_with_no_mods_installed() { + let report = report(&[], &Fixed(vec![])); + assert!(report.available); + assert!(report.is_ready()); + assert_eq!(report.required, 0); + } + + #[test] + fn installed_total_counts_the_whole_store_not_just_matches() { + let inventory = Fixed(vec![ + addon("AAAAAAAAAAAAAAAA", "1.0"), + addon("CCCCCCCCCCCCCCCC", "1.0"), + ]); + let report = report(&[server_mod("AAAAAAAAAAAAAAAA", "1.0", None)], &inventory); + assert_eq!(report.required, 1); + assert_eq!(report.installed_total, 2); + } + + #[test] + fn required_mods_marks_unknown_sizes_as_none() { + let mods = [ + server_mod("AAAAAAAAAAAAAAAA", "1.0", Some(10)), + server_mod("BBBBBBBBBBBBBBBB", "1.0", None), + ]; + let required = required_mods(&mods); + assert_eq!(required[0].size, Some(10)); + assert_eq!(required[1].size, None); + } + + #[test] + fn report_serialises_to_the_shape_the_ui_reads() { + let json = + serde_json::to_value(ReadinessReport::unavailable(Unavailable::NoAddonsDirectory)) + .unwrap(); + assert_eq!(json["available"], false); + assert_eq!(json["unavailable"], "no_addons_directory"); + assert!(json["message"].is_string()); + // Counts are present and zero, never absent. + assert_eq!(json["missingBytes"], 0); + assert_eq!(json["upToDate"], 0); + } +} diff --git a/crates/rfm-core/src/store.rs b/crates/rfm-core/src/store.rs new file mode 100644 index 0000000..89c6d13 --- /dev/null +++ b/crates/rfm-core/src/store.rs @@ -0,0 +1,658 @@ +//! Local persistence: settings, favorites, recent servers and launch history. +//! +//! One JSON document written atomically. SQLite would be the reflex choice, but +//! this is a few kilobytes of user preference that is read once at startup and +//! rewritten on change — a database engine would add a dependency, a schema and +//! a migration story for no benefit at this size. +//! +//! [`LocalState`] holds all the mutation rules and is pure, so the list-trimming +//! and ordering behaviour is unit-tested without touching a filesystem. +//! [`Store`] is the thin IO wrapper around it. + +use std::collections::HashSet; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +/// Most recent servers retained. +pub const MAX_RECENTS: usize = 30; +/// Most launch records retained. +pub const MAX_HISTORY: usize = 200; +/// Bumped when the on-disk shape changes incompatibly. +const STATE_VERSION: u32 = 1; + +/// Errors from reading or writing the state file. +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("could not read launcher state at {path}")] + Read { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("could not write launcher state to {path}")] + Write { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("could not serialise launcher state")] + Serialise(#[source] serde_json::Error), +} + +/// User-visible application preferences. +/// +/// The API origin is deliberately **not** here. The launcher is powered by +/// reforgermods.net and points at it unconditionally; a user-editable origin +/// would let a settings file redirect every request, including the install +/// identifier, to an arbitrary host. Development overrides go through the +/// `RFM_API_BASE_URL` environment variable instead — see [`crate::api`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Settings { + /// Servers fetched per page. Larger pages mean fewer requests. + pub per_page: u32, + /// Refresh the list automatically while the window is visible. + pub auto_refresh: bool, + /// Seconds between automatic refreshes. Floored at 15 so the launcher + /// cannot be configured into polling the API hard. + pub refresh_interval_secs: u32, + /// Publish what the launcher is doing to Discord Rich Presence. + /// + /// On by default: showing the launcher as the running application is the + /// point of the integration. It does broadcast the name of the server being + /// viewed to the user's Discord friends, so it is a visible toggle. + pub discord_presence: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { + per_page: 100, + auto_refresh: true, + refresh_interval_secs: 60, + discord_presence: true, + } + } +} + +impl Settings { + /// Minimum automatic refresh interval, in seconds. + pub const MIN_REFRESH_SECS: u32 = 15; + + /// Clamps values that arrived from disk or the UI into usable ranges. + pub fn sanitise(&mut self) { + self.per_page = self.per_page.clamp(10, crate::api::MAX_PER_PAGE); + self.refresh_interval_secs = self.refresh_interval_secs.max(Self::MIN_REFRESH_SECS); + } +} + +/// A server the user recently looked at or joined. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecentServer { + pub id: String, + /// Name as it was when last seen, so the entry stays readable even if the + /// server drops out of the index. + pub name: String, + /// Unix seconds. + pub last_seen_at: u64, +} + +/// One join attempt. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchRecord { + pub server_id: String, + pub server_name: String, + /// Unix seconds. + pub launched_at: u64, + /// False when handing the URI to the OS failed. + pub succeeded: bool, +} + +/// Everything the launcher persists. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct LocalState { + pub version: u32, + /// Random fallback identifier, used only when no machine source is + /// readable. See [`crate::identity`]. + pub install_id: String, + pub settings: Settings, + /// Favorite server ids, in the order the user added them. + pub favorites: Vec, + /// Recent servers, most recent first. + pub recents: Vec, + /// Launch history, most recent first. + pub history: Vec, +} + +impl Default for LocalState { + fn default() -> Self { + Self { + version: STATE_VERSION, + install_id: String::new(), + settings: Settings::default(), + favorites: Vec::new(), + recents: Vec::new(), + history: Vec::new(), + } + } +} + +impl LocalState { + /// Repairs state loaded from disk: clamps settings, drops ids that are not + /// valid server ids, and de-duplicates. A hand-edited or partially written + /// file must not be able to put the launcher into a broken state. + pub fn sanitise(&mut self) { + self.version = STATE_VERSION; + // Generated on first run and kept thereafter. A hand-edited or + // truncated value is replaced rather than sent as-is. + if !is_install_id(&self.install_id) { + self.install_id = new_install_id(); + } + self.settings.sanitise(); + + let mut seen = HashSet::new(); + self.favorites + .retain(|id| is_server_id(id) && seen.insert(id.to_ascii_lowercase())); + + let mut seen = HashSet::new(); + self.recents + .retain(|r| is_server_id(&r.id) && seen.insert(r.id.to_ascii_lowercase())); + self.recents + .sort_by_key(|r| std::cmp::Reverse(r.last_seen_at)); + self.recents.truncate(MAX_RECENTS); + + self.history.retain(|h| is_server_id(&h.server_id)); + self.history + .sort_by_key(|h| std::cmp::Reverse(h.launched_at)); + self.history.truncate(MAX_HISTORY); + } + + /// True when `id` is a favorite. + pub fn is_favorite(&self, id: &str) -> bool { + self.favorites.iter().any(|f| f.eq_ignore_ascii_case(id)) + } + + /// Adds or removes `id` from favorites. Returns the new state, or `None` + /// when `id` is not a valid server id. + pub fn toggle_favorite(&mut self, id: &str) -> Option { + if !is_server_id(id) { + return None; + } + if self.is_favorite(id) { + self.favorites.retain(|f| !f.eq_ignore_ascii_case(id)); + Some(false) + } else { + self.favorites.push(id.to_ascii_lowercase()); + Some(true) + } + } + + /// Records that the user opened `id`, moving it to the front of recents. + pub fn touch_recent(&mut self, id: &str, name: &str, now: u64) { + if !is_server_id(id) { + return; + } + let id = id.to_ascii_lowercase(); + self.recents.retain(|r| r.id != id); + self.recents.insert( + 0, + RecentServer { + id, + name: name.to_owned(), + last_seen_at: now, + }, + ); + self.recents.truncate(MAX_RECENTS); + } + + /// Appends a launch record, newest first. + pub fn record_launch(&mut self, id: &str, name: &str, succeeded: bool, now: u64) { + if !is_server_id(id) { + return; + } + self.history.insert( + 0, + LaunchRecord { + server_id: id.to_ascii_lowercase(), + server_name: name.to_owned(), + launched_at: now, + succeeded, + }, + ); + self.history.truncate(MAX_HISTORY); + } +} + +/// Generates the random fallback identifier. +/// +/// Used only where no machine source is readable; normally the identifier is +/// derived by [`crate::identity::stable_install_id`] so it survives a +/// reinstall. See `docs/client-identification.md`. +pub fn new_install_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// True for a value shaped like [`new_install_id`] output. +fn is_install_id(value: &str) -> bool { + crate::launch::ServerId::parse(value).is_ok() +} + +/// Canonical-UUID check, mirroring [`crate::launch::ServerId`]. +/// +/// Persistence accepts only ids that could actually be launched, so a corrupt +/// favorites list cannot smuggle arbitrary text toward the URI builder. +fn is_server_id(id: &str) -> bool { + crate::launch::ServerId::parse(id).is_ok() +} + +/// Current unix time in seconds. Saturates at 0 if the clock is before 1970. +pub fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// The on-disk state file. +pub struct Store { + path: PathBuf, + state: Mutex, +} + +impl Store { + /// Loads state from `path`, or starts from defaults when the file is absent. + /// + /// A file that exists but cannot be parsed is treated as absent rather than + /// fatal: losing preferences is better than a launcher that will not start. + /// The unreadable file is kept alongside as `*.corrupt` for inspection. + pub fn load(path: impl Into) -> Self { + let path = path.into(); + let mut state = match std::fs::read_to_string(&path) { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(state) => state, + Err(_) => { + let _ = std::fs::rename(&path, path.with_extension("corrupt")); + LocalState::default() + } + }, + Err(_) => LocalState::default(), + }; + state.sanitise(); + Self { + path, + state: Mutex::new(state), + } + } + + /// The file this store persists to. + pub fn path(&self) -> &Path { + &self.path + } + + /// Reads a value out of the state under lock. + pub fn read(&self, f: impl FnOnce(&LocalState) -> T) -> T { + let guard = self.state.lock().unwrap_or_else(|e| e.into_inner()); + f(&guard) + } + + /// The identifier to send with API requests. + /// + /// Machine-derived where possible, so it survives a reinstall; the stored + /// random value is the fallback. + pub fn install_id(&self) -> String { + crate::identity::stable_install_id(&self.read(|state| state.install_id.clone())) + } + + /// Mutates the state and writes it to disk. + /// + /// The value `f` returns is passed through, so callers get the result of + /// their own mutation without a second lock acquisition. + pub fn write(&self, f: impl FnOnce(&mut LocalState) -> T) -> Result { + let (out, snapshot) = { + let mut guard = self.state.lock().unwrap_or_else(|e| e.into_inner()); + let out = f(&mut guard); + guard.sanitise(); + (out, guard.clone()) + }; + self.persist(&snapshot)?; + Ok(out) + } + + /// Serialises `state` and replaces the file atomically. + fn persist(&self, state: &LocalState) -> Result<(), StoreError> { + let json = serde_json::to_string_pretty(state).map_err(StoreError::Serialise)?; + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|source| StoreError::Write { + path: self.path.clone(), + source, + })?; + } + // Write beside the target then rename, so a crash mid-write cannot leave + // a truncated state file behind. + let tmp = self.path.with_extension("json.tmp"); + std::fs::write(&tmp, json).map_err(|source| StoreError::Write { + path: tmp.clone(), + source, + })?; + std::fs::rename(&tmp, &self.path).map_err(|source| StoreError::Write { + path: self.path.clone(), + source, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ID_A: &str = "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f"; + const ID_B: &str = "25888090-0d2b-4d0a-80b1-9145ed9c76c2"; + + fn temp_path(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!("rfm-store-test-{}-{name}.json", std::process::id())); + let _ = std::fs::remove_file(&p); + p + } + + #[test] + fn the_api_origin_is_not_a_setting() { + // Serialised settings must not carry an origin a user could redirect. + let json = serde_json::to_string(&Settings::default()).unwrap(); + assert!(!json.contains("api"), "{json}"); + assert!(!json.contains("http"), "{json}"); + } + + #[test] + fn the_server_list_refreshes_by_default() { + let s = Settings::default(); + assert!(s.auto_refresh); + assert!(s.refresh_interval_secs >= Settings::MIN_REFRESH_SECS); + } + + #[test] + fn discord_presence_is_on_by_default_and_toggleable() { + assert!(Settings::default().discord_presence); + let s: Settings = serde_json::from_str(r#"{"discordPresence":false}"#).unwrap(); + assert!(!s.discord_presence); + } + + #[test] + fn settings_are_clamped() { + let mut s = Settings { + per_page: 100_000, + refresh_interval_secs: 1, + ..Default::default() + }; + s.sanitise(); + assert_eq!(s.per_page, crate::api::MAX_PER_PAGE); + assert_eq!(s.refresh_interval_secs, Settings::MIN_REFRESH_SECS); + + let mut s = Settings { + per_page: 1, + ..Default::default() + }; + s.sanitise(); + assert_eq!(s.per_page, 10); + } + + #[test] + fn favorites_toggle_and_are_case_insensitive() { + let mut state = LocalState::default(); + assert_eq!(state.toggle_favorite(ID_A), Some(true)); + assert!(state.is_favorite(ID_A)); + assert!(state.is_favorite(&ID_A.to_uppercase())); + assert_eq!(state.toggle_favorite(&ID_A.to_uppercase()), Some(false)); + assert!(!state.is_favorite(ID_A)); + assert!(state.favorites.is_empty()); + } + + #[test] + fn favorites_reject_invalid_ids() { + let mut state = LocalState::default(); + assert_eq!(state.toggle_favorite("not-a-uuid"), None); + assert_eq!(state.toggle_favorite(""), None); + assert!(state.favorites.is_empty()); + } + + #[test] + fn favorites_do_not_duplicate() { + let mut state = LocalState { + favorites: vec![ID_A.to_string(), ID_A.to_uppercase(), ID_B.to_string()], + ..Default::default() + }; + state.sanitise(); + assert_eq!(state.favorites, vec![ID_A.to_string(), ID_B.to_string()]); + } + + #[test] + fn recents_move_to_front_without_duplicating() { + let mut state = LocalState::default(); + state.touch_recent(ID_A, "A", 100); + state.touch_recent(ID_B, "B", 200); + state.touch_recent(ID_A, "A renamed", 300); + assert_eq!(state.recents.len(), 2); + assert_eq!(state.recents[0].id, ID_A); + assert_eq!(state.recents[0].name, "A renamed"); + assert_eq!(state.recents[0].last_seen_at, 300); + assert_eq!(state.recents[1].id, ID_B); + } + + #[test] + fn recents_are_bounded() { + let mut state = LocalState::default(); + for i in 0..(MAX_RECENTS + 20) { + // Distinct valid UUIDs. + let id = format!("{:08x}-8789-4d88-8aa8-ea69b9aa092f", i); + state.touch_recent(&id, "s", i as u64); + } + assert_eq!(state.recents.len(), MAX_RECENTS); + // Newest kept. + assert!(state.recents[0].last_seen_at > state.recents[MAX_RECENTS - 1].last_seen_at); + } + + #[test] + fn history_is_newest_first_and_bounded() { + let mut state = LocalState::default(); + for i in 0..(MAX_HISTORY + 5) { + state.record_launch(ID_A, "A", i % 2 == 0, i as u64); + } + assert_eq!(state.history.len(), MAX_HISTORY); + assert!(state.history[0].launched_at > state.history[1].launched_at); + } + + #[test] + fn history_and_recents_reject_invalid_ids() { + let mut state = LocalState::default(); + state.record_launch("../../etc/passwd", "x", true, 1); + state.touch_recent("steam://run/1874880", "x", 1); + assert!(state.history.is_empty()); + assert!(state.recents.is_empty()); + } + + #[test] + fn sanitise_drops_garbage_loaded_from_disk() { + let mut state = LocalState { + favorites: vec!["junk".into(), ID_A.into()], + recents: vec![ + RecentServer { + id: "junk".into(), + name: "x".into(), + last_seen_at: 9, + }, + RecentServer { + id: ID_B.into(), + name: "b".into(), + last_seen_at: 1, + }, + ], + history: vec![LaunchRecord { + server_id: "junk".into(), + server_name: "x".into(), + launched_at: 1, + succeeded: true, + }], + ..Default::default() + }; + state.sanitise(); + assert_eq!(state.favorites, vec![ID_A.to_string()]); + assert_eq!(state.recents.len(), 1); + assert_eq!(state.recents[0].id, ID_B); + assert!(state.history.is_empty()); + assert_eq!(state.version, STATE_VERSION); + } + + #[test] + fn the_sent_id_is_machine_derived_where_possible() { + let path = temp_path("identity"); + let store = Store::load(&path); + // Force the state file to exist so the fallback is genuinely persisted. + store.write(|_| ()).unwrap(); + + let sent = store.install_id(); + assert!(is_install_id(&sent), "{sent}"); + assert_eq!( + Store::load(&path).install_id(), + sent, + "unstable across loads" + ); + + // The point of deriving from the machine: discarding local state — an + // uninstall — must not produce a new identity. + std::fs::remove_file(&path).unwrap(); + let after_reinstall = Store::load(&path).install_id(); + if crate::identity::is_machine_derived() { + assert_eq!( + after_reinstall, sent, + "identity did not survive a reinstall" + ); + } else { + assert_ne!(after_reinstall, sent, "a random fallback should rotate"); + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn a_fallback_id_is_generated_on_first_run() { + let mut state = LocalState::default(); + assert_eq!(state.install_id, ""); + state.sanitise(); + assert!(is_install_id(&state.install_id), "{}", state.install_id); + } + + #[test] + fn the_install_id_is_stable_across_loads() { + let mut state = LocalState::default(); + state.sanitise(); + let first = state.install_id.clone(); + state.sanitise(); + assert_eq!(state.install_id, first, "must not rotate on every load"); + } + + #[test] + fn a_tampered_install_id_is_replaced_rather_than_sent() { + for bad in ["", " ", "not-a-uuid", " + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..17af81c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3227 @@ +{ + "name": "reforgermods-launcher", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reforgermods-launcher", + "version": "0.1.0", + "dependencies": { + "@preact/signals": "^2.11.2", + "@tauri-apps/api": "^2.11.1", + "preact": "^10.29.8" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.6", + "@tauri-apps/cli": "^2.11.5", + "jsdom": "^29.1.1", + "typescript": "^5.9.3", + "vite": "^7.1.9", + "vitest": "^5.0.1" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.10.6", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.6.tgz", + "integrity": "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@prefresh/vite": "^2.4.11", + "@rollup/pluginutils": "^5.0.0", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.4.3", + "magic-string": "^0.30.21", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.8", + "zimmerframe": "^1.1.4" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/@preact/signals": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.11.2.tgz", + "integrity": "sha512-rVTRTt/T0HIRgbugwS5FigbfF/kfdEFYFtiqxa+lbpqTajepqnR0firuAS2iNdHyejWB7Yc9p9QePkVNBtTAwg==", + "license": "MIT", + "dependencies": { + "@preact/signals-core": "^1.14.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact": ">= 10.25.0 || >=11.0.0-0" + } + }, + "node_modules/@preact/signals-core": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.4.tgz", + "integrity": "sha512-GSwqHGVem7AjnOKAkmLNbAExqZZFddf9S3ySOwowGEsJR8vwNKJ7Z/262fov5wra8LkDVSmFw3CjMGRdLoCmRA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@prefresh/core": { + "version": "1.5.11", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.11.tgz", + "integrity": "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", + "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.5.tgz", + "integrity": "sha512-YbeJ6tctNoo40purO4u3Eeo3RVqmsjJr0NoKQRu+4pvO4gMLzkjta7x0k96SxSLmx+rQ+8um1ThOlw+HLpgFug==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.5", + "@tauri-apps/cli-darwin-x64": "2.11.5", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.5", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.5", + "@tauri-apps/cli-linux-arm64-musl": "2.11.5", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.5", + "@tauri-apps/cli-linux-x64-gnu": "2.11.5", + "@tauri-apps/cli-linux-x64-musl": "2.11.5", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.5", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.5", + "@tauri-apps/cli-win32-x64-msvc": "2.11.5" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.5.tgz", + "integrity": "sha512-tggOiVOohjIHdiElbjeBfB41s6cmfsq+ZQ0PX0fEtCdsDdZ9cCi54e5gNK7tdPbR6JbdDe7RA4kbQdyyBUUk/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.5.tgz", + "integrity": "sha512-SrO+KCbqvG1IvVPdlzQX4GEGZRQDISrmDY9bGcW68DLVFBCC06H+Loz6oyUZETUxsSMR6B4oDfvbQVgD4hxsqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.5.tgz", + "integrity": "sha512-qLrOaCa8hLD5QVPQRjAgOXvhlTFKXmTliVF773GgvADYcWZSdmncnF5zBFAkdu1uQK8KfQbDlBUwqQYZqBfBRQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.5.tgz", + "integrity": "sha512-Tq32xpQjiQEdIrvwDq7lwHhXemn070CXWdbCvr9rdi9rsv9NFTbQ6izVSIfNE+59QaerxuwtUtZ5IxDNWaoYBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.5.tgz", + "integrity": "sha512-+OdWKj5Aq+C8HXypQ4t926V8cpVnc0ZcJIdhTXWjVAJHkaNuo8EuQR6KRZKHWIbJJgKdMPCDosvwzIFaBBqHKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.5.tgz", + "integrity": "sha512-Q0V2hjpllyDW8dWQZSsITuQDfcY9BpBZuhXkMa2T5AFLZAn+FJPXQSyvnxWNVxRowdQ59Xn0QVtGm6HEHNUEvw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.5.tgz", + "integrity": "sha512-Xz1s87gFjZJZMQKAhLa77YeiCJM6yVog3f9OxDgVd/KxDIoB1dA5V2xOnMM+SRkOGk9juwIBqesVAjV0kUB0DA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.5.tgz", + "integrity": "sha512-6A88wqAZXZHgMaBrGiy/Bpa4GgUnftXM7uquKOeOXCuK78/XdBxEECicK/1oHgrT179XBFOaA6lQEHysjRmGig==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.5.tgz", + "integrity": "sha512-sRGgF/ObRfLcS3PtUdyTbJtkGq3E+PNZTknvAURwMNUtHw9DU/BF6S5si43y9lS5yEO9bWJCA+GGYvnn6cphQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.5.tgz", + "integrity": "sha512-bHOv/yxqfKpI7ioIFQi5wOfZ7jkbPzJD/UhXY52I/hrmE3qNh71cohIPyqZB5GQn3LZeJWdCUOqmIg/w6XX+Wg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.5.tgz", + "integrity": "sha512-vaaOmavqdCeS7oMib2banBZemgrC5v54tSkwoBV+bxrXQZhQZrOwE2EO2i4x+CHNtvQUM/6bSs3iymZMAsycWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001812", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001812.tgz", + "integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.438", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz", + "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-code-frame": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", + "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0.tgz", + "integrity": "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz", + "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.15" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz", + "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz", + "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0037d27 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "reforgermods-launcher", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "check": "tsc --noEmit", + "test": "vitest run", + "tauri": "tauri" + }, + "dependencies": { + "@preact/signals": "^2.11.2", + "@tauri-apps/api": "^2.11.1", + "preact": "^10.29.8" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.6", + "@tauri-apps/cli": "^2.11.5", + "jsdom": "^29.1.1", + "typescript": "^5.9.3", + "vite": "^7.1.9", + "vitest": "^5.0.1" + } +} diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..1f72e02 --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,94 @@ +# Maintainer: reforgermods.net +# +# Builds the launcher from a source tarball produced by scripts/build-arch.sh. +# The tarball is the working tree minus node_modules/target/dist, so this +# packages exactly what is checked out — not a tagged release. + +pkgname=reforgermods-launcher +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Server browser and direct launcher for Arma Reforger (reforgermods.net)" +arch=('x86_64') +url="https://reforgermods.net" +license=('LicenseRef-Proprietary') + +# webkit2gtk-4.1 pulls gtk3, libsoup3, glib2 and the rest of the WebKit stack. +# The binary links rustls rather than OpenSSL, so there is no openssl dependency. +depends=('webkit2gtk-4.1' 'gtk3' 'libsoup3' 'glibc' 'gcc-libs') +# cmake builds aws-lc-sys, the C crypto library behind rustls' default +# provider. Without it the crate's build script produces no library and the +# link fails late with a wall of undefined aws_lc_* symbols. +makedepends=('rust' 'nodejs' 'npm' 'cmake') + +# !lto: makepkg's default OPTIONS put -flto=auto into CFLAGS/LDFLAGS. That +# makes aws-lc-sys compile its C crypto into GCC LTO bitcode, which the +# linker rustc drives cannot resolve — the build dies at the very last step +# with a page of undefined aws_lc_* symbols. Rust-level LTO is unaffected: +# that comes from [profile.release] in the workspace manifest. +# !debug: the release profile already strips, so a -debug split package would +# be empty. One artifact to hand over is the point. +options=('!lto' '!debug') + +source=("$pkgname-$pkgver.tar.gz") +sha256sums=('SKIP') + +build() { + cd "$srcdir/$pkgname-$pkgver" + + # The frontend is embedded into the binary by tauri-build, so it has to exist + # before cargo runs. + npm ci + npm run build + + # --features custom-protocol is what makes this a production build: without + # it tauri-build sets cfg(dev) and the app loads its UI from the Vite dev + # server on localhost:1420 instead of the embedded assets. See the comment on + # that feature in src-tauri/Cargo.toml. + cargo build --release --locked -p reforgermods-launcher --features custom-protocol + + # Assert the result is not a dev build. You cannot tell by inspecting the + # binary — tauri embeds the whole config, devUrl included, either way, and + # brotli-compresses the assets. The signal is the cargo:rustc-cfg=dev that + # tauri-build emits, recorded in the build script's output file. A dev build + # packages and installs perfectly and then shows ERR_CONNECTION_REFUSED on + # the tester's machine, which is exactly the report you cannot debug + # remotely. + local build_out + build_out=$(ls -t target/release/build/reforgermods-launcher-*/output | head -1) + if [[ -z "$build_out" ]]; then + echo "ERROR: could not find the build script output to verify the build mode." >&2 + return 1 + fi + if grep -q "rustc-cfg=dev" "$build_out"; then + echo "ERROR: this is a DEV build; --features custom-protocol did not take effect." >&2 + return 1 + fi + echo "build mode: production (frontend embedded, no dev-server dependency)" +} + +check() { + cd "$srcdir/$pkgname-$pkgver" + # The launcher crate is included for the Linux-only startup workarounds in + # main.rs, which are exactly the code a Windows-first project is least likely + # to notice breaking. + cargo test --release --locked -p rfm-core -p reforgermods-launcher +} + +package() { + cd "$srcdir/$pkgname-$pkgver" + + install -Dm755 target/release/reforgermods-launcher \ + "$pkgdir/usr/bin/reforgermods-launcher" + + install -Dm644 packaging/arch/reforgermods-launcher.desktop \ + "$pkgdir/usr/share/applications/reforgermods-launcher.desktop" + + for size in 32 128; do + install -Dm644 "src-tauri/icons/${size}x${size}.png" \ + "$pkgdir/usr/share/icons/hicolor/${size}x${size}/apps/reforgermods-launcher.png" + done + install -Dm644 src-tauri/icons/128x128@2x.png \ + "$pkgdir/usr/share/icons/hicolor/256x256/apps/reforgermods-launcher.png" + install -Dm644 src-tauri/icons/icon.png \ + "$pkgdir/usr/share/icons/hicolor/512x512/apps/reforgermods-launcher.png" +} diff --git a/packaging/arch/reforgermods-launcher.desktop b/packaging/arch/reforgermods-launcher.desktop new file mode 100644 index 0000000..27a8d28 --- /dev/null +++ b/packaging/arch/reforgermods-launcher.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Type=Application +Version=1.0 +Name=reforgermods.net launcher +GenericName=Arma Reforger Server Browser +Comment=Server browser and direct launcher for Arma Reforger. +Exec=reforgermods-launcher +Icon=reforgermods-launcher +Terminal=false +Categories=Game;Network; +Keywords=Arma;Reforger;server;browser;launcher; +StartupNotify=true +StartupWMClass=reforgermods-launcher diff --git a/packaging/windows/sign.cmd b/packaging/windows/sign.cmd new file mode 100644 index 0000000..bdbb9cf --- /dev/null +++ b/packaging/windows/sign.cmd @@ -0,0 +1,52 @@ +@echo off +REM Authenticode signing for one artifact, called by Tauri during bundling. +REM +REM Tauri invokes this once per bundled file, with the path as %1, and it must run +REM *during* the bundle rather than after it: the updater artifact is a zip of the +REM installer plus a minisign signature over that zip. Sign the installer after +REM bundling and the zip still holds the unsigned copy, while the signature it +REM ships describes a file nobody will ever download. Wiring this through +REM bundle.windows.signCommand is what keeps those two in step. +REM +REM Configuration comes from the environment so nothing about the signing identity +REM is committed. The credentials themselves are read from the environment by the +REM Azure SDK inside trusted-signing-cli (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, +REM AZURE_TENANT_ID) and are never referenced here. +REM +REM RFM_SIGN_ENDPOINT e.g. https://eus.codesigning.azure.net +REM RFM_SIGN_ACCOUNT Trusted Signing account name +REM RFM_SIGN_PROFILE certificate profile name +REM +REM This deliberately fails the build when it is reachable but misconfigured. A +REM sign step that quietly does nothing produces an unsigned installer that looks +REM signed in the release notes, which is the one outcome worth crashing over. + +setlocal + +if "%~1"=="" ( + echo sign.cmd: no file to sign was given 1>&2 + exit /b 1 +) + +if "%RFM_SIGN_ENDPOINT%"=="" goto :missing +if "%RFM_SIGN_ACCOUNT%"=="" goto :missing +if "%RFM_SIGN_PROFILE%"=="" goto :missing + +where trusted-signing-cli >nul 2>nul +if errorlevel 1 ( + echo sign.cmd: trusted-signing-cli is not on PATH 1>&2 + echo sign.cmd: install it with `cargo install trusted-signing-cli` 1>&2 + exit /b 1 +) + +echo sign.cmd: signing %~nx1 +trusted-signing-cli -e "%RFM_SIGN_ENDPOINT%" -a "%RFM_SIGN_ACCOUNT%" -c "%RFM_SIGN_PROFILE%" "%~1" +if errorlevel 1 ( + echo sign.cmd: signing failed for %~nx1 1>&2 + exit /b 1 +) +exit /b 0 + +:missing +echo sign.cmd: RFM_SIGN_ENDPOINT, RFM_SIGN_ACCOUNT and RFM_SIGN_PROFILE must all be set 1>&2 +exit /b 1 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/scripts/build-arch.sh b/scripts/build-arch.sh new file mode 100755 index 0000000..951c555 --- /dev/null +++ b/scripts/build-arch.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Build a native Arch Linux package (.pkg.tar.zst) from this checkout. +# +# scripts/build-arch.sh [--no-check] +# +# The build runs inside an `archlinux:base-devel` container, so the binary is +# linked against Arch's own glibc and WebKit rather than this machine's. That +# matters: a Tauri app built on Debian links libwebkit2gtk-4.1 with Debian's +# soname and ABI, and handing that to an Arch user is how you get a blank +# window or a missing-symbol crash. Building on Arch removes the question. +# +# Output: target/arch/reforgermods-launcher--1-x86_64.pkg.tar.zst, +# installed on the target machine with `sudo pacman -U `. pacman resolves +# webkit2gtk-4.1 and the rest, so there is nothing else to install by hand. +# +# Docker is only a clean Arch userspace here — nothing is deployed and no +# daemon is left running. --no-check skips the rfm-core test suite. +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE="${RFM_ARCH_IMAGE:-rfm-arch-build}" +PKGBUILD="$REPO/packaging/arch/PKGBUILD" +STAGE="$REPO/target/arch" +MAKEPKG_ARGS=(--force --noconfirm --clean) + +if [[ "${1:-}" == "--no-check" ]]; then + MAKEPKG_ARGS+=(--nocheck) +fi + +command -v docker >/dev/null || { echo "docker is required" >&2; exit 1; } +docker info >/dev/null 2>&1 || { echo "the docker daemon is not reachable" >&2; exit 1; } + +# 1. One version, agreed in both places. +# +# tauri.conf.json is what the app reports about itself, so it is the source of +# truth; the PKGBUILD carries its own copy because makepkg needs it literally. +# A silent disagreement here ships a package whose pacman version does not +# match the version in the window, so it is checked rather than assumed. +CONF_VERSION="$(sed -n 's/^ "version": "\(.*\)",$/\1/p' "$REPO/src-tauri/tauri.conf.json")" +PKG_VERSION="$(sed -n 's/^pkgver=\(.*\)$/\1/p' "$PKGBUILD")" +if [[ -z "$CONF_VERSION" || -z "$PKG_VERSION" ]]; then + echo "ERROR: could not read the version from tauri.conf.json or the PKGBUILD." >&2 + exit 1 +fi +if [[ "$CONF_VERSION" != "$PKG_VERSION" ]]; then + echo "ERROR: version mismatch — tauri.conf.json says $CONF_VERSION, PKGBUILD says $PKG_VERSION." >&2 + echo "Update pkgver in packaging/arch/PKGBUILD (and reset pkgrel to 1)." >&2 + exit 1 +fi +VERSION="$CONF_VERSION" + +# 2. The build image: Arch plus the toolchains, and a user whose uid matches +# this one so everything written to the bind mount stays owned by the caller. +if [[ -n "${RFM_ARCH_REBUILD_IMAGE:-}" ]] || ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "==> building the $IMAGE container image" + docker build -t "$IMAGE" \ + --build-arg "UID=$(id -u)" --build-arg "GID=$(id -g)" - <<'DOCKERFILE' +FROM archlinux:base-devel +ARG UID=1000 +ARG GID=1000 +RUN pacman -Syu --noconfirm --needed rust nodejs npm webkit2gtk-4.1 cmake \ + && pacman -Scc --noconfirm +RUN groupadd -g "$GID" builder 2>/dev/null || true \ + && useradd -u "$UID" -g "$GID" -m -d /home/builder builder 2>/dev/null || true \ + && mkdir -p /cache/cargo /cache/npm /build \ + && chown -R "$UID:$GID" /cache /build /home/builder +USER $UID:$GID +ENV CARGO_HOME=/cache/cargo npm_config_cache=/cache/npm +WORKDIR /build +DOCKERFILE +fi + +# 3. The source tarball makepkg consumes: the working tree, minus everything +# that is generated or host-specific. target/ especially — those are Debian +# build artifacts and cargo would happily reuse the stale ones. +echo "==> staging sources for $VERSION" +rm -rf "$STAGE" +mkdir -p "$STAGE" +tar --create --gzip --file "$STAGE/reforgermods-launcher-$VERSION.tar.gz" \ + --directory "$REPO" \ + --transform "s,^\.,reforgermods-launcher-$VERSION," \ + --exclude=./.git \ + --exclude=./node_modules \ + --exclude=./target \ + --exclude=./dist \ + --exclude=./src-tauri/target \ + --exclude=./src-tauri/gen/schemas \ + . +cp "$PKGBUILD" "$STAGE/PKGBUILD" + +# 4. Named volumes keep the cargo registry and npm cache across runs, so only +# the first build pays for downloading the dependency tree. +docker volume create rfm-arch-cargo >/dev/null +docker volume create rfm-arch-npm >/dev/null +docker run --rm --user root \ + -v rfm-arch-cargo:/cache/cargo -v rfm-arch-npm:/cache/npm \ + "$IMAGE" chown "$(id -u):$(id -g)" /cache/cargo /cache/npm + +echo "==> running makepkg in $IMAGE" +docker run --rm \ + -v "$STAGE:/build" \ + -v rfm-arch-cargo:/cache/cargo \ + -v rfm-arch-npm:/cache/npm \ + "$IMAGE" makepkg "${MAKEPKG_ARGS[@]}" + +PKG="$(ls -t "$STAGE"/*.pkg.tar.zst 2>/dev/null | head -1)" +if [[ -z "$PKG" ]]; then + echo "ERROR: makepkg produced no package." >&2 + exit 1 +fi + +# 5. Checksum, for handing the file over. +# +# The package is already known to be a production build rather than a dev one: +# build() asserts that before packaging, which is the only point where the +# evidence still exists. +sha256sum "$PKG" | sed "s,$STAGE/,," > "$STAGE/SHA256SUMS" + +echo +echo "built: $PKG" +ls -lh "$PKG" +echo +echo "install on Arch with: sudo pacman -U $(basename "$PKG")" diff --git a/scripts/build-windows.sh b/scripts/build-windows.sh new file mode 100755 index 0000000..6935634 --- /dev/null +++ b/scripts/build-windows.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Cross-compile a Windows executable from Linux — no Visual Studio, no root. +# +# scripts/build-windows.sh [--debug] +# +# Produces target/x86_64-pc-windows-msvc//reforgermods-launcher.exe, +# a standalone binary (the frontend is embedded at compile time). It needs the +# WebView2 runtime on the target machine, which ships with current Windows. +# +# Release is the default, and for a distributable binary it is the only correct +# choice: tauri-build sets `cfg(dev)` for debug profiles, and a `dev` build +# loads the UI from the Vite dev server on localhost:1420 rather than from the +# embedded assets. Run a debug build on a machine without `npm run dev` going +# and you get a blank window. `--debug` exists for attaching a debugger, and +# the script checks the result and says so. +# +# How it works: cargo-xwin downloads the MSVC CRT and Windows SDK from +# Microsoft into ~/.cache/cargo-xwin (~1.5 GB, once), and LLVM's clang-cl and +# lld-link stand in for cl.exe and link.exe. The LLVM pieces are fetched as +# Debian packages and unpacked into a local prefix, so nothing is installed +# system-wide and no sudo is required. +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TOOLS="${RFM_WIN_TOOLS:-$HOME/.cache/rfm-win-tools}" +LLVM_VERSION="${RFM_LLVM_VERSION:-19}" +PROFILE_ARGS=(--release) +PROFILE_DIR="release" +if [[ "${1:-}" == "--debug" ]]; then + PROFILE_ARGS=() + PROFILE_DIR="debug" +fi + +PREFIX="$TOOLS/root/usr/lib/llvm-$LLVM_VERSION/bin" + +# 1. LLVM cross-linking tools, unpacked locally. +if [[ ! -x "$PREFIX/lld-link" || ! -e "$PREFIX/clang-cl" ]]; then + echo "==> fetching LLVM $LLVM_VERSION tools into $TOOLS" + mkdir -p "$TOOLS" + ( + cd "$TOOLS" + # clang provides clang-cl; lld provides lld-link; libclang-cpp is clang's runtime. + apt-get download "clang-$LLVM_VERSION" "lld-$LLVM_VERSION" "libclang-cpp$LLVM_VERSION" + for deb in *.deb; do dpkg-deb -x "$deb" ./root; done + ) + # clang-cl is a driver mode of clang, not always shipped as its own file. + [[ -e "$PREFIX/clang-cl" ]] || ln -s clang "$PREFIX/clang-cl" +fi + +export PATH="$PREFIX:/usr/lib/llvm-$LLVM_VERSION/bin:$PATH" +export LD_LIBRARY_PATH="$TOOLS/root/usr/lib/llvm-$LLVM_VERSION/lib:${LD_LIBRARY_PATH:-}" + +for tool in clang-cl lld-link llvm-rc llvm-lib; do + command -v "$tool" >/dev/null || { echo "missing $tool" >&2; exit 1; } +done + +# 2. cargo-xwin, which supplies the MSVC CRT and Windows SDK. +command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked + +# cargo-xwin keeps its own clang-cl symlink and refuses to start if a stale one +# points somewhere else (for example a previous checkout's tools prefix). +XWIN_LINK="${XWIN_CACHE_DIR:-$HOME/.cache/cargo-xwin}/clang-cl" +if [[ -L "$XWIN_LINK" && "$(readlink -f "$XWIN_LINK")" != "$(readlink -f "$PREFIX/clang-cl")" ]]; then + rm -f "$XWIN_LINK" +fi + +# 3. The frontend, embedded into the binary by tauri-build. +echo "==> building frontend" +cd "$REPO" +npm run build + +echo "==> cross-compiling for x86_64-pc-windows-msvc" +# --features custom-protocol is what makes this a production build; see the +# comment on that feature in src-tauri/Cargo.toml. +cargo xwin build --target x86_64-pc-windows-msvc -p reforgermods-launcher \ + --features custom-protocol "${PROFILE_ARGS[@]}" + +EXE="$REPO/target/x86_64-pc-windows-msvc/$PROFILE_DIR/reforgermods-launcher.exe" +echo +echo "built: $EXE" +ls -lh "$EXE" + +# Assert the binary is not a dev build. +# +# You cannot check this by grepping the exe: Tauri brotli-compresses the +# embedded assets, and it embeds the whole config (devUrl included) either way. +# The signal is `cargo:rustc-cfg=dev` from tauri-build, which cargo records in +# the build script's output file. A dev build looks completely fine here and +# fails with ERR_CONNECTION_REFUSED on the target machine, so it is worth +# asserting rather than assuming. +BUILD_OUT=$(ls -t "$REPO/target/x86_64-pc-windows-msvc/$PROFILE_DIR"/build/reforgermods-launcher-*/output 2>/dev/null | head -1) +if [[ -z "$BUILD_OUT" ]]; then + echo "WARNING: could not find the build script output to verify the build mode." >&2 +elif grep -q "rustc-cfg=dev" "$BUILD_OUT"; then + echo + echo "ERROR: this is a DEV build. It loads its UI from the Vite dev server on" >&2 + echo "localhost:1420 and will show ERR_CONNECTION_REFUSED anywhere else." >&2 + echo "The --features custom-protocol flag did not take effect." >&2 + exit 1 +else + echo "build mode: production (frontend embedded, no dev-server dependency)" +fi diff --git a/scripts/gen_icons.py b/scripts/gen_icons.py new file mode 100644 index 0000000..191f698 --- /dev/null +++ b/scripts/gen_icons.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Derive the launcher's icons from the reforgermods.net brand assets. + +No image libraries, so this is reproducible from a clean checkout with nothing +but a Python interpreter. Run from the repo root: + + python3 scripts/gen_icons.py + +Sources (from the reforgermods.net site assets, copied into brand/): + + reforger-mods-app-icon.png -> application / taskbar icon, all sizes + reforger-mods-icon-dark.png -> transparent mark used in the app header + +Downsampling is a box filter on premultiplied alpha. Premultiplying matters: +averaging straight RGBA makes fully transparent pixels drag their colour into +the edge, which shows up as a dark halo around the mark at 16px. +""" + +import struct +import zlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BRAND = ROOT / "brand" +ICONS = ROOT / "src-tauri" / "icons" +UI_ASSETS = ROOT / "src" / "assets" + + +# ----------------------------------------------------------------- decoding + +def read_png(path): + """Decodes an 8-bit RGBA PNG into (width, height, rows of bytes).""" + data = path.read_bytes() + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError(f"{path} is not a PNG") + + width = height = None + idat = bytearray() + pos = 8 + while pos < len(data): + length, tag = struct.unpack_from(">I4s", data, pos) + body = data[pos + 8 : pos + 8 + length] + if tag == b"IHDR": + width, height, depth, colour = struct.unpack(">IIBB", body[:10]) + if depth != 8 or colour != 6: + raise ValueError(f"{path}: expected 8-bit RGBA, got depth={depth} colour={colour}") + elif tag == b"IDAT": + idat += body + elif tag == b"IEND": + break + pos += 12 + length + + raw = zlib.decompress(bytes(idat)) + stride = width * 4 + rows = [] + previous = bytearray(stride) + pos = 0 + for _ in range(height): + filter_type = raw[pos] + pos += 1 + line = bytearray(raw[pos : pos + stride]) + pos += stride + unfilter(filter_type, line, previous, 4) + rows.append(bytes(line)) + previous = line + return width, height, rows + + +def unfilter(filter_type, line, previous, bpp): + """Reverses one PNG scanline filter in place (RFC 2083 section 6).""" + if filter_type == 0: + return + for i in range(len(line)): + a = line[i - bpp] if i >= bpp else 0 + b = previous[i] + if filter_type == 1: + line[i] = (line[i] + a) & 0xFF + elif filter_type == 2: + line[i] = (line[i] + b) & 0xFF + elif filter_type == 3: + line[i] = (line[i] + ((a + b) >> 1)) & 0xFF + elif filter_type == 4: + c = previous[i - bpp] if i >= bpp else 0 + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + pred = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c) + line[i] = (line[i] + pred) & 0xFF + else: + raise ValueError(f"unknown filter {filter_type}") + + +# --------------------------------------------------------------- resampling + +def resize(width, height, rows, size): + """Box-filter downsample to size x size, averaging premultiplied alpha.""" + out = [] + for oy in range(size): + y0, y1 = oy * height // size, max(oy * height // size + 1, (oy + 1) * height // size) + row = bytearray() + for ox in range(size): + x0, x1 = ox * width // size, max(ox * width // size + 1, (ox + 1) * width // size) + r = g = b = a = 0 + count = 0 + for y in range(y0, y1): + line = rows[y] + for x in range(x0, x1): + i = x * 4 + alpha = line[i + 3] + # Premultiply so transparent pixels contribute no colour. + r += line[i] * alpha + g += line[i + 1] * alpha + b += line[i + 2] * alpha + a += alpha + count += 1 + if a == 0: + row += bytes(4) + else: + row += bytes((r // a, g // a, b // a, a // count)) + out.append(bytes(row)) + return out + + +# ----------------------------------------------------------------- encoding + +def write_png(rows, size): + def chunk(tag, body): + payload = tag + body + return struct.pack(">I", len(body)) + payload + struct.pack(">I", zlib.crc32(payload)) + + raw = b"".join(b"\x00" + r for r in rows) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + + +def write_ico(entries): + """Packs PNG-encoded images into an .ico container.""" + header = struct.pack(" (platform id understood by launcher.js, kind, human label). +# Order matters: the longest, most specific suffix has to be tested first, or +# "-setup.exe" is matched by the portable ".exe" rule. +ARTIFACT_RULES = [ + (".nsis.zip", "windows", "updater", "Updater package"), + ("-setup.exe", "windows", "installer", "Installer, Windows 10 or 11 64-bit"), + # An NSIS bundle is signed in place: the updater downloads the same + # -setup.exe a visitor does, so that file is both a download and the updater + # artifact. Any artifact with a sibling .sig is treated as one, below. + (".pkg.tar.zst", "arch", "pacman", "Arch Linux package"), + (".deb", "debian", "deb", "Debian / Ubuntu package"), + (".AppImage", "appimage", "appimage", "AppImage, no install"), + (".rpm", "fedora", "rpm", "Fedora package"), + (".exe", "windows", "portable", "Portable, no installer"), +] + +# The updater target triple Tauri looks for in latest.json. +UPDATER_TARGETS = {"windows": "windows-x86_64"} + + +def classify(name): + for suffix, platform, kind, label in ARTIFACT_RULES: + if name.endswith(suffix): + return platform, kind, label + return None + + +def sha256(path): + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--artifacts", required=True, type=pathlib.Path) + parser.add_argument("--base-url", required=True, help="e.g. https://dl.reforgermods.net") + parser.add_argument("--out", required=True, type=pathlib.Path) + parser.add_argument("--notes-url", default="https://reforgermods.net/launcher/changelog/") + parser.add_argument( + "--notes", + default="", + help="Release notes shown by the updater. Kept short: it is a dialog, not a changelog.", + ) + parser.add_argument( + "--published-at", + default=None, + help="RFC3339 timestamp. Defaults to now, in UTC.", + ) + args = parser.parse_args() + + version = args.version.lstrip("v") + base = args.base_url.rstrip("/") + prefix = f"{base}/{version}" + published = args.published_at or datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + if not args.artifacts.is_dir(): + sys.exit(f"not a directory: {args.artifacts}") + + downloads, updater_platforms, skipped = [], {}, [] + # Signatures sit beside their artifact as .sig; they are read, never + # listed as downloads. + for path in sorted(p for p in args.artifacts.iterdir() if p.is_file()): + if path.suffix == ".sig" or path.name in ("releases.json", "latest.json", "SHA256SUMS"): + continue + classified = classify(path.name) + if classified is None: + skipped.append(path.name) + continue + platform, kind, label = classified + url = f"{prefix}/{path.name}" + + # A sibling .sig makes this the platform's updater artifact, whether or + # not it is also a download. An unsigned one is never offered: the client + # rejects it, so publishing it means every launch checks, fails, retries. + signature = path.with_name(path.name + ".sig") + target = UPDATER_TARGETS.get(platform) + if signature.is_file(): + if target is None: + skipped.append(f"{path.name} (signed, but no updater target for {platform})") + elif target in updater_platforms: + skipped.append(f"{path.name} (a {target} updater artifact was already chosen)") + else: + updater_platforms[target] = { + "signature": signature.read_text(encoding="utf-8").strip(), + "url": url, + } + elif kind == "updater": + skipped.append(f"{path.name} (no {signature.name}; not offered to the updater)") + + if kind == "updater": + # A dedicated updater package is not a human download. + continue + + downloads.append( + { + "platform": platform, + "kind": kind, + "label": label, + "url": url, + "size": path.stat().st_size, + "sha256": sha256(path), + } + ) + + # launcher.js offers the first entry whose platform matches as the primary + # download, so an installer has to come before a portable build of the same + # platform. Within a platform, installer first; otherwise by platform id for + # a stable file. + kind_order = {"installer": 0, "pacman": 0, "deb": 0, "rpm": 0, "appimage": 1, "portable": 2} + # Windows leads because that is where the players are; the rest follow in the + # order the download table should read. + platform_order = {"windows": 0, "debian": 1, "arch": 2, "fedora": 3, "appimage": 4} + downloads.sort(key=lambda d: (platform_order.get(d["platform"], 9), kind_order.get(d["kind"], 9))) + + args.out.mkdir(parents=True, exist_ok=True) + + releases = { + "version": version, + "publishedAt": published, + "notesUrl": args.notes_url, + "downloads": downloads, + } + (args.out / "releases.json").write_text(json.dumps(releases, indent=2) + "\n", encoding="utf-8") + + latest = { + "version": version, + "notes": args.notes or f"reforgermods.net launcher {version}", + "pub_date": published, + "platforms": updater_platforms, + } + (args.out / "latest.json").write_text(json.dumps(latest, indent=2) + "\n", encoding="utf-8") + + print(f"version {version}, published {published}") + for download in downloads: + print(f" download {download['platform']:9} {download['kind']:9} {download['size']:>10,} B {download['url']}") + for target, entry in updater_platforms.items(): + print(f" updater {target} {entry['url']}") + for name in skipped: + print(f" skipped {name}") + if not downloads: + sys.exit("no downloadable artifacts were found: refusing to write an empty manifest") + if not updater_platforms: + # Not fatal: a Linux-only or unsigned build is a legitimate state. It is + # called out because it means the updater will find nothing. + print(" WARNING: latest.json has no platforms; the updater will never fire") + + +if __name__ == "__main__": + main() diff --git a/scripts/version.sh b/scripts/version.sh new file mode 100755 index 0000000..d7f592a --- /dev/null +++ b/scripts/version.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# The launcher's version, in one place. +# +# scripts/version.sh show print the canonical version +# scripts/version.sh check [tag] fail if any copy disagrees +# scripts/version.sh set 0.2.0 rewrite every copy +# +# The canonical value is `[workspace.package] version` in the root Cargo.toml, +# because that is the one the *running binary* reports: rfm-core builds its +# User-Agent from CARGO_PKG_VERSION, so the API's per-version rollups are keyed +# off it whatever the other files say. +# +# It has to be repeated in four places — Cargo's workspace manifest, package.json, +# tauri.conf.json and the PKGBUILD — because no two of those tools can read each +# other's format. tauri.conf.json could inherit from Cargo.toml by omitting the +# field, but the src-tauri crate declares `version.workspace = true` and Tauri +# parses that manifest itself rather than resolving workspace inheritance, so the +# value has to be written out. `check` is what makes the repetition safe. +# +# This matters more than tidiness: an updater whose manifest version disagrees +# with the installed version either loops forever or never fires +# (docs/distribution.md section 6). +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO" + +canonical() { + # The first `version = "x.y.z"` under [workspace.package]. + awk '/^\[workspace\.package\]/{f=1;next} /^\[/{f=0} f && /^version *=/{gsub(/[^0-9a-zA-Z.\-+]/,"",$3);print $3;exit}' Cargo.toml +} + +read_package_json() { python3 -c 'import json;print(json.load(open("package.json"))["version"])'; } +read_tauri_conf() { python3 -c 'import json;print(json.load(open("src-tauri/tauri.conf.json"))["version"])'; } +read_pkgbuild() { sed -n 's/^pkgver=\(.*\)$/\1/p' packaging/arch/PKGBUILD; } + +SEMVER='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + +cmd_show() { canonical; } + +cmd_check() { + local want tag="${1:-}" status=0 + want="$(canonical)" + if [[ ! "$want" =~ $SEMVER ]]; then + echo "Cargo.toml [workspace.package] version is not semver: '$want'" >&2 + return 1 + fi + local name value + for name in package.json src-tauri/tauri.conf.json packaging/arch/PKGBUILD; do + case "$name" in + package.json) value="$(read_package_json)" ;; + src-tauri/tauri.conf.json) value="$(read_tauri_conf)" ;; + packaging/arch/PKGBUILD) value="$(read_pkgbuild)" ;; + esac + if [[ "$value" != "$want" ]]; then + echo "$name: $value, want $want (run: scripts/version.sh set $want)" >&2 + status=1 + fi + done + # A tag is checked only when one is given, so the same command serves CI on a + # tag push and a developer on a branch. Both `1.2.3` and `v1.2.3` are accepted. + if [[ -n "$tag" && "${tag#v}" != "$want" ]]; then + echo "tag $tag does not match version $want" >&2 + status=1 + fi + if [[ $status -eq 0 ]]; then + echo "version $want is consistent${tag:+ with tag $tag}" + fi + return $status +} + +cmd_set() { + local want="${1:?usage: scripts/version.sh set }" + want="${want#v}" + if [[ ! "$want" =~ $SEMVER ]]; then + echo "not a semver version: '$want'" >&2 + return 1 + fi + python3 - "$want" <<'PY' +import io, json, re, sys +want = sys.argv[1] + +# Cargo.toml: only the [workspace.package] version, never a dependency's. +path = "Cargo.toml" +text = io.open(path, encoding="utf-8").read() +def bump_workspace_version(match): + return match.group(1) + '"' + want + '"' +new, count = re.subn(r'(\[workspace\.package\]\n(?:[^\[]*?\n)?version *= *)"[^"]*"', bump_workspace_version, text) +assert count == 1, f"{path}: matched {count} workspace versions, expected 1" +io.open(path, "w", encoding="utf-8").write(new) + +# package.json / tauri.conf.json: rewrite the single field, preserving formatting +# (a json.dump round trip would reformat files a human maintains). +for path, key in (("package.json", "version"), ("src-tauri/tauri.conf.json", "version")): + text = io.open(path, encoding="utf-8").read() + new, count = re.subn(r'("%s"\s*:\s*)"[^"]*"' % key, lambda m: m.group(1) + '"' + want + '"', text, count=1) + assert count == 1, f"{path}: no {key} field" + json.loads(new) + io.open(path, "w", encoding="utf-8").write(new) + +path = "packaging/arch/PKGBUILD" +text = io.open(path, encoding="utf-8").read() +new, count = re.subn(r'(?m)^pkgver=.*$', "pkgver=" + want, text, count=1) +assert count == 1, f"{path}: no pkgver" +io.open(path, "w", encoding="utf-8").write(new) +print("set version", want) +PY + cmd_check +} + +case "${1:-show}" in + show) cmd_show ;; + check) shift; cmd_check "$@" ;; + set) shift; cmd_set "$@" ;; + *) echo "usage: scripts/version.sh [show|check [tag]|set ]" >&2; exit 2 ;; +esac diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..98fae84 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "reforgermods-launcher" +description = "reforgermods.net launcher: a fast desktop server browser and direct launcher for Arma Reforger." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +# Tauri builds the app as a library so the mobile targets can link it; the +# desktop binary in main.rs is a thin wrapper around run(). +name = "reforgermods_launcher_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[features] +# Tauri decides dev-vs-production from this feature, NOT from the cargo profile. +# `tauri`'s build script computes `dev = !custom_protocol`, and in dev mode +# `generate_context!` makes the app load its UI from `build.devUrl` +# (http://localhost:1420) instead of the embedded frontend. Without this a +# release build still ships as a dev build and shows ERR_CONNECTION_REFUSED on +# any machine that is not running `npm run dev`. +# +# `cargo tauri build` passes this automatically; a direct `cargo build` must be +# given `--features custom-protocol`. See scripts/build-windows.sh. +custom-protocol = ["tauri/custom-protocol"] + +[build-dependencies] +tauri-build = { version = "2.6.3", features = [] } + +[dependencies] +rfm-core = { path = "../crates/rfm-core" } +tauri = { version = "2.11.6", features = [] } +tauri-plugin-opener = "2.5.5" +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +discord-rich-presence = "1.1.0" + +# Windows only, on purpose. The updater replaces the installed application in +# place, which is correct for an NSIS per-user install and wrong for the Arch +# package: a distro package is updated by the distro's tooling, not by the app +# rewriting its own files under /usr/bin (docs/distribution.md section 7). +# Scoping it here rather than with cfg! keeps the dependency out of the Linux +# build entirely. +[target.'cfg(target_os = "windows")'.dependencies] +# default-features = false is not tidiness: the plugin's defaults include +# `rustls-tls`, and because Cargo unions features across a shared dependency that +# switches the *whole binary's* reqwest onto rustls. That pulls in ring, undoing +# the deliberate schannel choice in rfm-core/Cargo.toml — no bundled C crypto to +# build, and the machine's own trust store, which is what works behind corporate +# TLS inspection. It also does not cross-compile: ring's build script fails under +# cargo-xwin, so the mistake shows up as a broken Windows build rather than a +# silently different TLS stack. +# +# `zip` is kept although the current NSIS bundle is signed in place and ships as +# a plain -setup.exe: Tauri emits a zipped artifact for some bundle types, and a +# plugin that cannot unpack one fails on the user's machine at install time, +# which is the worst place to discover a missing feature flag. +tauri-plugin-updater = { version = "2.12.0", default-features = false, features = [ + "native-tls", + "system-proxy", + "zip", +] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json new file mode 100644 index 0000000..bbb0601 --- /dev/null +++ b/src-tauri/capabilities/main.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main", + "description": "Minimum permissions for the launcher window. The window is frameless, so the UI may drag it and operate its own caption buttons — and nothing else. The UI may only invoke this app's own commands; it is granted no filesystem, shell, HTTP or opener access. URIs are opened from Rust, after the server id has been validated.", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-start-dragging", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close" + ] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..6476457 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..fb2051d Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..993ebf6 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..759a3b8 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..5e24eb5 Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..b0d50e9 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,518 @@ +//! The Tauri command surface. +//! +//! This is the only boundary the UI can call across, so it is kept deliberately +//! small. Commands orchestrate; they contain no HTTP, no URI building and no +//! persistence rules of their own — those live in `rfm-core` where they are +//! unit-tested. +//! +//! Untrusted API text never reaches an OS call here. The two commands that hand +//! a URL to the operating system ([`join_server`] and [`open_server_page`]) both +//! build it from a validated [`rfm_core::ServerId`], never from an API-supplied +//! URL field. + +use serde::Serialize; +use tauri::State; +use tauri_plugin_opener::OpenerExt; + +use rfm_core::api::models::{ + DatasetMeta, FleetNow, LauncherStatus, PingSite, ServerDetail, ServerHistory, ServerSummary, +}; +use rfm_core::api::{ApiError, HistoryRange, ServerQuery}; +use rfm_core::launch::{steam_join_uri, steam_launch_uri, LaunchError}; +use rfm_core::modscan::FilesystemInventory; +use rfm_core::ping::{self, PingResult}; +use rfm_core::presence::Activity; +use rfm_core::readiness::{self, ReadinessReport, Unavailable}; +use rfm_core::store::{now_unix, LaunchRecord, LocalState, RecentServer, Settings, StoreError}; +use rfm_core::ServerId; +use std::collections::HashMap; + +use crate::state::AppState; + +/// An error the UI can branch on. +/// +/// `kind` is a stable slug; `message` is display text. Nothing here leaks a file +/// path or an internal type name. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandError { + pub kind: String, + pub message: String, + /// Set for rate limiting, so the UI can say how long to wait. + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_secs: Option, +} + +impl From for CommandError { + fn from(err: ApiError) -> Self { + let retry_after_secs = match &err { + ApiError::RateLimited { retry_after } => *retry_after, + _ => None, + }; + Self { + kind: err.kind().to_string(), + message: err.to_string(), + retry_after_secs, + } + } +} + +impl From for CommandError { + fn from(err: LaunchError) -> Self { + Self { + kind: "invalid_server_id".into(), + message: err.to_string(), + retry_after_secs: None, + } + } +} + +impl From for CommandError { + fn from(err: StoreError) -> Self { + Self { + kind: "storage".into(), + // Deliberately not including the path: it names the user's profile. + message: "Could not save launcher settings.".into(), + retry_after_secs: None, + } + .tap_log(&err) + } +} + +impl CommandError { + /// Logs the underlying cause to stderr while returning the sanitised error. + fn tap_log(self, cause: &dyn std::error::Error) -> Self { + eprintln!("[reforgermods-launcher] {}: {cause}", self.kind); + self + } + + fn other(kind: &str, message: impl Into) -> Self { + Self { + kind: kind.into(), + message: message.into(), + retry_after_secs: None, + } + } +} + +type CommandResult = Result; + +/// One page of the server browser. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerListResult { + pub servers: Vec, + /// Total servers the API matched, before client-side filtering. + pub total_servers: u32, + pub total_pages: u32, + pub current_page: u32, + /// How many of the fetched servers the client-side filters removed, so the + /// UI can say so instead of silently showing fewer rows. + pub filtered_out: u32, + pub dataset: DatasetMeta, +} + +/// Diagnostic view of the install identity, for debug mode. +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallIdentity { + pub id: String, + /// True when the identifier survives a reinstall. + pub machine_derived: bool, +} + +/// Everything the UI needs from local storage in one call at startup. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalStateDto { + pub settings: Settings, + pub favorites: Vec, + pub recents: Vec, + pub history: Vec, +} + +impl From<&LocalState> for LocalStateDto { + fn from(s: &LocalState) -> Self { + Self { + settings: s.settings.clone(), + favorites: s.favorites.clone(), + recents: s.recents.clone(), + history: s.history.clone(), + } + } +} + +/// `GET /v2/servers` with the browser's current filters. +#[tauri::command] +pub async fn list_servers( + state: State<'_, AppState>, + query: ServerQuery, +) -> CommandResult { + let page = state.client().servers(&query).await?; + state.remember_dataset(page.dataset.clone()); + state.publish_presence(Activity::Browsing { + online_servers: page.dataset.online_server_count, + }); + + let fetched = page.data.len() as u32; + let servers: Vec = page + .data + .into_iter() + .filter(|s| query.retains(s.players)) + .collect(); + + Ok(ServerListResult { + filtered_out: fetched - servers.len() as u32, + servers, + total_servers: page.meta.total_servers, + total_pages: page.meta.total_pages, + current_page: page.meta.current_page, + dataset: page.dataset, + }) +} + +/// `GET /v2/servers/{id}`, and record the server in recents. +#[tauri::command] +pub async fn get_server(state: State<'_, AppState>, id: String) -> CommandResult { + let id = ServerId::parse(&id)?; + let response = state.client().server(&id).await?; + state.remember_dataset(response.dataset.clone()); + + let name = response.server.summary.name.clone(); + state.publish_presence(Activity::Viewing { + server_name: &name, + players: response.server.summary.players, + max_players: response.server.summary.max_players, + }); + // A failure to persist a recents entry must not fail the lookup the user asked for. + let _ = state + .store + .write(|s| s.touch_recent(id.as_str(), &name, now_unix())); + + Ok(response.server) +} + +/// `GET /v2/servers/{id}/history` for one range. +#[tauri::command] +pub async fn get_server_history( + state: State<'_, AppState>, + id: String, + range: HistoryRange, +) -> CommandResult { + let id = ServerId::parse(&id)?; + Ok(state.client().server_history(&id, range).await?) +} + +/// Compares a server's required mods against the local Arma Reforger addon store. +/// +/// Reporting only. There is no verified way to install Reforger Workshop content +/// from outside the game, so this command has no counterpart that acts on the +/// result — see `docs/mod-readiness.md`. +/// +/// The server detail comes from the client's cache in the common case, because +/// the inspector has just fetched it, so this costs a directory scan and no HTTP. +#[tauri::command] +pub async fn get_mod_readiness( + state: State<'_, AppState>, + id: String, +) -> CommandResult { + let id = ServerId::parse(&id)?; + let detail = state.client().server(&id).await?; + let mods = detail.server.mods; + + // Reading a few hundred small files is blocking IO; keep it off the async + // runtime's worker threads. + tokio::task::spawn_blocking(move || match FilesystemInventory::discover() { + Ok(inventory) => readiness::report(&mods, &inventory), + Err(_) => ReadinessReport::unavailable(Unavailable::NoAddonsDirectory), + }) + .await + .map_err(|err| { + CommandError::other("scan_failed", "Could not check locally installed mods.").tap_log(&err) + }) +} + +/// Measures round-trip latency to a server by ICMP echo. +/// +/// The address comes from the API and is validated by +/// [`rfm_core::ping::parse_target`] before a packet is sent: literal public IPs +/// only, so remote data cannot aim the launcher at a hostname of its choosing +/// or at the user's own network. +/// +/// Never errors on an unreachable host — it returns a result that states why +/// there is no figure, because "no reply" is information, not a failure. +#[tauri::command] +pub async fn ping_server(state: State<'_, AppState>, id: String) -> CommandResult { + let id = ServerId::parse(&id)?; + // Served from cache in the common case: the inspector just fetched it. + let detail = state.client().server(&id).await?; + Ok(ping::measure( + detail.server.host.as_deref(), + ping::DEFAULT_ATTEMPTS, + ping::DEFAULT_TIMEOUT, + ) + .await) +} + +/// Measures latency to every published ping site, keyed by site id. +/// +/// This is what makes a ping column in the browser possible. `/v2/servers` does +/// not publish a per-server address — only `/v2/servers/{id}` does — so pinging +/// a 100-row page would cost 100 detail requests against a 60/minute limit. +/// Every row does carry `pingSiteId`, and there are only eight sites, so eight +/// probes cover the whole list. +/// +/// The figure is therefore latency to the server's *region*, not to the server. +/// Measured against real servers the two agree within roughly 20 ms, which is +/// enough to sort by and is what the game's own browser shows. The inspector +/// still measures the selected server exactly. +/// +/// Sites are probed concurrently; one unreachable site cannot hold up the rest. +#[tauri::command] +pub async fn ping_regions( + state: State<'_, AppState>, +) -> CommandResult> { + let sites = state.client().ping_sites().await?.sites; + + let probes: Vec<_> = sites + .into_iter() + .filter_map(|site| { + let address = site.address?; + Some(tokio::spawn(async move { + let result = + ping::measure_address(&address, ping::DEFAULT_ATTEMPTS, ping::DEFAULT_TIMEOUT) + .await; + (site.id, result) + })) + }) + .collect(); + + let mut out = HashMap::with_capacity(probes.len()); + for probe in probes { + if let Ok((id, result)) = probe.await { + out.insert(id, result); + } + } + Ok(out) +} + +/// Most servers resolvable in one call. Each costs a request, and the public +/// limit is 60/minute; favorites and recents are both bounded below this. +const MAX_RESOLVE: usize = 40; +/// Requests in flight while resolving. Enough to feel instant, low enough not +/// to burst the rate limiter. +const RESOLVE_CONCURRENCY: usize = 4; + +/// Resolves a list of server ids to current summaries. +/// +/// Backs the favorites and recents views, which hold ids rather than servers and +/// so cannot render a live table from local state alone. Results keep the input +/// order; ids that no longer resolve are dropped rather than failing the call, +/// because a favorite disappearing from the index is normal and the rest of the +/// list is still useful. +/// +/// `/v2/servers` cannot filter by id, so this is one detail request per server. +/// That is affordable only because both lists are small — hence [`MAX_RESOLVE`]. +#[tauri::command] +pub async fn get_servers( + state: State<'_, AppState>, + ids: Vec, +) -> CommandResult> { + let wanted: Vec = ids + .iter() + .filter_map(|id| ServerId::parse(id).ok()) + .take(MAX_RESOLVE) + .collect(); + + let limit = std::sync::Arc::new(tokio::sync::Semaphore::new(RESOLVE_CONCURRENCY)); + let mut tasks = Vec::with_capacity(wanted.len()); + for id in wanted { + let client = state.client(); + let limit = limit.clone(); + tasks.push(tokio::spawn(async move { + let _permit = limit.acquire().await.ok()?; + client.server(&id).await.ok().map(|r| r.server.summary) + })); + } + + let mut out = Vec::with_capacity(tasks.len()); + for task in tasks { + if let Ok(Some(summary)) = task.await { + out.push(summary); + } + } + Ok(out) +} + +/// Players online across the whole fleet, for the header. +#[tauri::command] +pub async fn get_fleet_now(state: State<'_, AppState>) -> CommandResult { + Ok(state.client().fleet_now().await?.now) +} + +/// Ping sites, used to label regions in the filter bar. +#[tauri::command] +pub async fn list_ping_sites(state: State<'_, AppState>) -> CommandResult> { + Ok(state.client().ping_sites().await?.sites) +} + +/// The compact API status for the header. Never fails; an unreachable API +/// produces an offline reading. +#[tauri::command] +pub async fn get_status(state: State<'_, AppState>) -> CommandResult { + let hint = state.last_dataset(); + // `launcher_status` absorbs every failure into an offline/unknown reading, + // so this is infallible; the Result is only Tauri's async command contract. + Ok(state.client().launcher_status(hint).await) +} + +/// Joins a server directly through Steam. +/// +/// The id is validated first, so the URI handed to the OS contains nothing but +/// hex digits and dashes. `name` is only ever written to the local history file. +#[tauri::command] +pub async fn join_server( + app: tauri::AppHandle, + state: State<'_, AppState>, + id: String, + name: String, +) -> CommandResult { + let id = ServerId::parse(&id)?; + + // steam://run//// — the empty segment marks the payload as a + // launch argument. See `rfm_core::launch` for why the single-slash form + // starts the game without joining. + let uri = steam_join_uri(&id); + state.publish_presence(Activity::Joining { server_name: &name }); + let result = app.opener().open_url(&uri, None::<&str>); + + let succeeded = result.is_ok(); + let _ = state + .store + .write(|s| s.record_launch(id.as_str(), &name, succeeded, now_unix())); + + match result { + Ok(()) => Ok(uri), + Err(err) => Err(CommandError::other( + "launch_failed", + "Could not hand the join request to Steam. Is Steam installed?", + ) + .tap_log(&err)), + } +} + +/// Starts Arma Reforger without joining a server. +#[tauri::command] +pub async fn launch_game(app: tauri::AppHandle) -> CommandResult<()> { + app.opener() + .open_url(steam_launch_uri(), None::<&str>) + .map_err(|err| { + CommandError::other( + "launch_failed", + "Could not start Arma Reforger through Steam.", + ) + .tap_log(&err) + }) +} + +/// Opens a server's page on reforgermods.net in the default browser. +/// +/// The URL is built from the validated id rather than from the API's `modURL` +/// field, so a hostile API response cannot choose the destination. +#[tauri::command] +pub async fn open_server_page(app: tauri::AppHandle, id: String) -> CommandResult<()> { + let id = ServerId::parse(&id)?; + let url = format!("https://reforgermods.net/servers/{id}"); + app.opener().open_url(&url, None::<&str>).map_err(|err| { + CommandError::other("open_failed", "Could not open the reforgermods.net page.") + .tap_log(&err) + }) +} + +/// Opens a Discord invite in the default browser. +/// +/// Takes the invite **code**, never a URL: the code is re-validated here and the +/// URL is rebuilt, so text in a server name cannot choose the destination even +/// if the UI's own extraction were wrong. +#[tauri::command] +pub async fn open_discord_invite(app: tauri::AppHandle, code: String) -> CommandResult<()> { + let valid = (2..=64).contains(&code.len()) + && code + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); + if !valid { + return Err(CommandError::other( + "invalid_invite", + "Not a valid Discord invite.", + )); + } + let url = format!("https://discord.gg/{code}"); + app.opener() + .open_url(&url, None::<&str>) + .map_err(|err| CommandError::other("open_failed", "Could not open Discord.").tap_log(&err)) +} + +/// Settings, favorites, recents and history, read once at startup. +#[tauri::command] +pub fn get_local_state(state: State<'_, AppState>) -> LocalStateDto { + state.store.read(|state| LocalStateDto::from(state)) +} + +/// Adds or removes a favorite. Returns the new state. +#[tauri::command] +pub fn toggle_favorite(state: State<'_, AppState>, id: String) -> CommandResult { + state + .store + .write(|s| s.toggle_favorite(&id))? + .ok_or_else(|| CommandError::other("invalid_server_id", "Not a valid server id.")) +} + +/// Replaces application settings, re-pointing the API client when the base URL +/// changed. Returns the settings as stored, after clamping. +#[tauri::command] +pub fn update_settings(state: State<'_, AppState>, settings: Settings) -> CommandResult { + let stored = state.store.write(|s| { + s.settings = settings; + s.settings.sanitise(); + s.settings.clone() + })?; + // Applies a presence opt-out immediately rather than at the next refresh. + if !stored.discord_presence { + state.publish_presence(Activity::Browsing { online_servers: 0 }); + } + Ok(stored) +} + +/// Diagnostic view of the installation identity. Debug mode only. +/// +/// Returns an empty id outside debug mode so the value is not exposed to the +/// UI — and therefore not to anything reading the window — in normal use. +#[tauri::command] +pub fn get_install_identity(state: State<'_, AppState>) -> InstallIdentity { + if !crate::debug_mode() { + return InstallIdentity::default(); + } + InstallIdentity { + id: state.store.install_id(), + machine_derived: rfm_core::identity::is_machine_derived(), + } +} + +/// Whether this session is running in debug mode. +/// +/// Gates developer-facing detail in the UI, such as the install identifier. +/// Enabled with `RFM_DEBUG=1`. +#[tauri::command] +pub fn is_debug_mode() -> bool { + crate::debug_mode() +} + +/// Clears recents and launch history. Favorites are left alone. +#[tauri::command] +pub fn clear_history(state: State<'_, AppState>) -> CommandResult<()> { + state.store.write(|s| { + s.recents.clear(); + s.history.clear(); + })?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..600ccb0 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,108 @@ +//! reforgermods.net launcher: the Tauri application shell. +//! +//! This crate is intentionally thin. It owns the window, the command surface and +//! the wiring between them; all logic lives in `rfm-core`, which has no Tauri +//! dependency and is tested directly. + +mod commands; +mod presence; +mod state; +#[cfg(target_os = "windows")] +mod update; + +use rfm_core::store::Store; +use state::AppState; +use tauri::Manager as _; + +/// True when `RFM_DEBUG` is set to something truthy. +/// +/// Gates developer-facing detail; nothing about product behaviour depends on it. +pub fn debug_mode() -> bool { + std::env::var("RFM_DEBUG") + .map(|v| matches!(v.trim(), "1" | "true" | "yes")) + .unwrap_or(false) +} + +/// File the launcher persists settings, favorites, recents and history to. +const STATE_FILE: &str = "launcher-state.json"; + +/// Builds and runs the application. +pub fn run() { + #[allow(unused_mut)] + let mut builder = tauri::Builder::default() + // Used from Rust only, to hand steam:// and https:// URIs to the OS. + // The UI is not granted permission to call it. + .plugin(tauri_plugin_opener::init()); + + // Windows only; on Linux the distro package owns updates. See update.rs. + #[cfg(target_os = "windows")] + { + builder = builder + .plugin(tauri_plugin_updater::Builder::new().build()) + .manage(update::PendingUpdate::default()); + } + + let app = builder + .setup(|app| { + let store = Store::load(state_file_path(app.handle())); + let state = AppState::new(store).map_err(|e| { + // A client that cannot be built even against the production + // default means no TLS backend; there is nothing to recover to. + format!("could not initialise the API client: {e}") + })?; + app.manage(state); + + // Background: never blocks the window appearing, and the download is + // applied when the session ends rather than during it. + #[cfg(target_os = "windows")] + update::spawn_check(app.handle().clone()); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::list_servers, + commands::get_server, + commands::get_servers, + commands::get_server_history, + commands::get_mod_readiness, + commands::ping_server, + commands::ping_regions, + commands::get_fleet_now, + commands::list_ping_sites, + commands::get_status, + commands::join_server, + commands::launch_game, + commands::open_server_page, + commands::open_discord_invite, + commands::get_local_state, + commands::toggle_favorite, + commands::update_settings, + commands::get_install_identity, + commands::is_debug_mode, + commands::clear_history, + ]) + .build(tauri::generate_context!()) + .expect("error while building the reforgermods.net launcher"); + + // `run` with a callback rather than `Builder::run`, so the exit event can + // hand off to a staged installer. The callback runs for every event; only + // Exit is of interest, and it is the last one. + app.run(|_handle, event| { + if let tauri::RunEvent::Exit = event { + #[cfg(target_os = "windows")] + update::install_staged(_handle); + } + }); +} + +/// Where the state file lives. +/// +/// Uses Tauri's resolved per-app config directory when available, falling back to +/// the working directory so a sandboxed environment without a config dir still +/// starts rather than panicking. +fn state_file_path(app: &tauri::AppHandle) -> std::path::PathBuf { + match app.path().app_config_dir() { + Ok(dir) => dir.join(STATE_FILE), + Err(_) => std::path::PathBuf::from(STATE_FILE), + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..285f56c --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,108 @@ +// Release builds must not open a console window on Windows. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +/// Works around a WebKitGTK startup crash on Nvidia under Wayland. +/// +/// WebKitGTK's DMABUF renderer and the proprietary Nvidia driver disagree about +/// buffer import, and GTK aborts before the window is ever mapped: +/// +/// ```text +/// Gdk-Message: Error 71 (Protocol error) dispatching to Wayland display. +/// ``` +/// +/// Setting `WEBKIT_DISABLE_DMABUF_RENDERER=1` falls back to a software +/// composite path, which costs some GPU acceleration and produces a window that +/// actually appears. Reported against Arch with an Nvidia card; it is a WebKit +/// and driver interaction rather than anything this app controls. +/// +/// Deliberately narrow, because the fallback is not free for anyone who does not +/// need it: +/// +/// - an existing value is never overridden, so a user debugging this keeps control; +/// - only when the Nvidia driver is actually loaded; +/// - only in a Wayland session, since the X11 path does not use this renderer; +/// - not when GDK is already pinned to X11, where it is equally unused. +#[cfg(target_os = "linux")] +fn disable_dmabuf_renderer_on_nvidia_wayland() { + let decision = should_disable_dmabuf( + std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_some(), + std::env::var_os("WAYLAND_DISPLAY").is_some(), + std::env::var_os("GDK_BACKEND") + .as_deref() + .and_then(|b| b.to_str()), + std::path::Path::new("/proc/driver/nvidia/version").exists() + || std::path::Path::new("/sys/module/nvidia").exists(), + ); + if !decision { + return; + } + // SAFETY: this runs at the top of main, before Tauri, GTK or any thread + // exists, so there is no concurrent reader of the environment. It must also + // happen here rather than later: WebKit reads this once, during init. + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); +} + +/// The decision behind [`disable_dmabuf_renderer_on_nvidia_wayland`], separated +/// from the environment so each condition can be tested. +#[cfg(target_os = "linux")] +fn should_disable_dmabuf( + already_set: bool, + wayland_session: bool, + gdk_backend: Option<&str>, + nvidia_loaded: bool, +) -> bool { + if already_set || !wayland_session || !nvidia_loaded { + return false; + } + // A GDK_BACKEND naming x11 alone means the Wayland renderer is not in play. + // A list such as "wayland,x11" still prefers Wayland, so only the exact pin + // counts as opting out. + gdk_backend != Some("x11") +} + +fn main() { + #[cfg(target_os = "linux")] + disable_dmabuf_renderer_on_nvidia_wayland(); + + reforgermods_launcher_lib::run() +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::should_disable_dmabuf; + + #[test] + fn nvidia_under_wayland_is_the_case_that_needs_the_workaround() { + assert!(should_disable_dmabuf(false, true, None, true)); + assert!(should_disable_dmabuf(false, true, Some("wayland"), true)); + // Still Wayland-first, so the renderer is still the one that breaks. + assert!(should_disable_dmabuf( + false, + true, + Some("wayland,x11"), + true + )); + } + + #[test] + fn an_explicit_setting_is_never_overridden() { + // Someone debugging this has set it to 0 on purpose; honour that. + assert!(!should_disable_dmabuf(true, true, None, true)); + } + + #[test] + fn other_sessions_and_drivers_keep_accelerated_rendering() { + assert!( + !should_disable_dmabuf(false, false, None, true), + "X11 session" + ); + assert!( + !should_disable_dmabuf(false, true, None, false), + "not Nvidia" + ); + assert!( + !should_disable_dmabuf(false, true, Some("x11"), true), + "pinned to XWayland" + ); + } +} diff --git a/src-tauri/src/presence.rs b/src-tauri/src/presence.rs new file mode 100644 index 0000000..6d16b3c --- /dev/null +++ b/src-tauri/src/presence.rs @@ -0,0 +1,163 @@ +//! Discord Rich Presence transport. +//! +//! Discord's IPC is a blocking local socket, and Discord may not be running at +//! all, so this owns one background thread and talks to it over a channel. +//! Nothing on the UI path ever blocks on Discord, and a missing or restarted +//! Discord is a no-op rather than an error the user sees. +//! +//! Payload text is built in `rfm_core::presence`, where the length and +//! sanitisation rules are tested. + +use std::sync::mpsc::{self, Receiver, Sender}; +use std::time::{Duration, Instant}; + +use discord_rich_presence::error::Error as DiscordError; +use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient}; +use rfm_core::presence::Presence; + +/// Discord application id. Determines the name shown as the game being played. +/// +/// Empty by default: without a real application this build must not attempt to +/// connect. Set `RFM_DISCORD_APP_ID` at runtime, or replace this constant once +/// the application exists. See `docs/discord-presence.md`. +const DEFAULT_APP_ID: &str = "1552757014474784898"; + +/// Environment override for the application id. +const APP_ID_ENV: &str = "RFM_DISCORD_APP_ID"; + +/// Artwork key uploaded to the Discord application's Rich Presence assets. +const LARGE_IMAGE_KEY: &str = "logo"; + +/// How long to wait before retrying a failed connection. Discord being closed +/// is the normal case, so this stays slow and quiet. +const RECONNECT_DELAY: Duration = Duration::from_secs(30); + +/// Messages the worker accepts. +enum Message { + /// Publish this presence. + Update(Presence), + /// Stop publishing and disconnect. + Clear, +} + +/// Handle to the presence worker. +pub struct PresenceHandle { + tx: Option>, +} + +impl PresenceHandle { + /// Starts the worker, or returns an inert handle when presence is disabled. + /// + /// Disabled means: no application id configured. The launcher still runs + /// normally; nothing is published. + pub fn start() -> Self { + let app_id = std::env::var(APP_ID_ENV) + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_APP_ID.to_string()); + + if app_id.trim().is_empty() { + return Self { tx: None }; + } + + let (tx, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("discord-presence".into()) + .spawn(move || worker(app_id, rx)) + .ok(); + Self { tx: Some(tx) } + } + + /// True when presence is configured and a worker is running. + pub fn is_enabled(&self) -> bool { + self.tx.is_some() + } + + /// Publishes `presence`. Never blocks and never fails the caller. + pub fn update(&self, presence: Presence) { + self.send(Message::Update(presence)); + } + + /// Clears the presence, e.g. when the user turns it off. + pub fn clear(&self) { + self.send(Message::Clear); + } + + fn send(&self, message: Message) { + if let Some(tx) = &self.tx { + // A dead worker means Discord support is gone for this session; + // that is not worth surfacing. + let _ = tx.send(message); + } + } +} + +/// Owns the IPC client for the life of the process. +fn worker(app_id: String, rx: Receiver) { + let mut client = DiscordIpcClient::new(&app_id); + let mut connected = false; + let mut last_attempt: Option = None; + // Re-sent after a reconnect, so a Discord restart does not blank the status. + let mut current: Option = None; + + while let Ok(message) = rx.recv() { + match message { + Message::Update(presence) => current = Some(presence), + Message::Clear => { + // `take` both clears and tells us whether anything was live, so + // a clear with nothing published costs no IPC round trip. + if current.take().is_some() && connected { + let _ = client.clear_activity(); + } + } + } + + // Nothing to publish means no reason to hold or open a connection. + let Some(presence) = current.clone() else { + continue; + }; + + if !connected { + // Back off: Discord not running is the normal case, not an error. + let due = last_attempt.is_none_or(|at| at.elapsed() >= RECONNECT_DELAY); + if !due { + continue; + } + last_attempt = Some(Instant::now()); + connected = client.connect().is_ok(); + if !connected { + continue; + } + } + + if publish(&mut client, &presence).is_err() { + // Discord closed or restarted; drop the connection and let the next + // update reconnect after the backoff. + connected = false; + let _ = client.close(); + } + } + + if connected { + let _ = client.clear_activity(); + let _ = client.close(); + } +} + +/// Sends one activity. +fn publish(client: &mut DiscordIpcClient, presence: &Presence) -> Result<(), DiscordError> { + client.set_activity( + activity::Activity::new() + .details(presence.details.as_str()) + .state(presence.state.as_str()) + .assets( + activity::Assets::new() + .large_image(LARGE_IMAGE_KEY) + .large_text(presence.large_text.as_str()), + ) + .buttons(vec![activity::Button::new( + "Get the launcher", + "https://reforgermods.net", + )]), + ) +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs new file mode 100644 index 0000000..fa5eaf8 --- /dev/null +++ b/src-tauri/src/state.rs @@ -0,0 +1,77 @@ +//! Shared application state. + +use std::sync::{Mutex, RwLock}; + +use rfm_core::api::models::DatasetMeta; +use rfm_core::presence::Activity; +use rfm_core::store::Store; + +use crate::presence::PresenceHandle; +use rfm_core::{ApiClient, ApiError}; + +/// State held for the lifetime of the application. +pub struct AppState { + /// Behind an `RwLock` because changing the API base URL in settings swaps the + /// client, while every request only needs read access. + client: RwLock, + /// Persisted settings, favorites, recents and launch history. + pub store: Store, + /// Discord Rich Presence publisher. Inert when no application id is set. + presence: PresenceHandle, + /// Snapshot freshness from the most recent server response. + /// + /// Every `/v2/servers` response carries this, so the header can show + /// "server data updated Ns ago" without its own polling loop. + last_dataset: Mutex>, +} + +impl AppState { + /// Builds state with a client for the configured API origin, identifying + /// with the store's anonymous install id. + pub fn new(store: Store) -> Result { + let client = ApiClient::new(&store.install_id())?; + Ok(Self { + client: RwLock::new(client), + store, + presence: PresenceHandle::start(), + last_dataset: Mutex::new(None), + }) + } + + /// A clone of the current client, so requests do not hold the lock while + /// awaiting the network. + pub fn client(&self) -> ApiClient { + self.client + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + + /// Publishes `activity` to Discord, if the user has it enabled. + /// + /// Silently does nothing when presence is off or unconfigured; this is a + /// decorative feature and must never interfere with a user action. + pub fn publish_presence(&self, activity: Activity<'_>) { + if !self.presence.is_enabled() { + return; + } + if self.store.read(|s| s.settings.discord_presence) { + self.presence.update(activity.to_presence()); + } else { + self.presence.clear(); + } + } + + /// Records snapshot freshness seen on a server response. + pub fn remember_dataset(&self, dataset: DatasetMeta) { + *self.last_dataset.lock().unwrap_or_else(|e| e.into_inner()) = Some(dataset); + } + + /// The most recently seen snapshot freshness, if any. + pub fn last_dataset(&self) -> Option { + self.last_dataset + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } +} diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs new file mode 100644 index 0000000..e2cc5d9 --- /dev/null +++ b/src-tauri/src/update.rs @@ -0,0 +1,106 @@ +//! Background updates, Windows only. +//! +//! The launcher is an API client, which is what makes this worth shipping in the +//! first release rather than later: when `/v2/servers` changes shape, anyone on +//! an old build gets a broken server browser and blames the site. An updater +//! turns that from a support problem into a non-event. +//! +//! The shape is deliberate, and it is the reason this is not simply +//! `download_and_install`: +//! +//! 1. check and download in the background, so nothing blocks startup; +//! 2. hold the downloaded package until the session ends; +//! 3. run the installer as the app exits, without relaunching it. +//! +//! Nobody is interrupted mid-session, and nobody is asked to make a decision +//! about a patch release. The update is simply there the next time they open the +//! launcher. `Update::install` calls `std::process::exit(0)` on Windows after +//! launching the installer, which is harmless at exit and would be a hard kill at +//! any other moment — another reason the install is pinned to exactly this point. +//! +//! There is no updater on Linux. The Arch package is owned by pacman, and an app +//! that rewrites its own files under `/usr/bin` fights the package manager (see +//! `docs/distribution.md` section 7). The dependency is scoped to Windows in +//! `Cargo.toml`, so this module does not exist in a Linux build. + +use std::sync::Mutex; + +use tauri::Manager as _; +use tauri_plugin_updater::{Update, UpdaterExt as _}; + +/// An update that has been downloaded and is waiting for the session to end. +/// +/// `Update` carries the verified release metadata and `bytes` is the installer +/// payload, already signature-checked by the plugin against the public key in +/// `tauri.conf.json`. Holding both means the install at exit is a local +/// operation: no network, nothing that can hang a closing app. +#[derive(Default)] +pub struct PendingUpdate(Mutex>); + +struct Staged { + update: Update, + bytes: Vec, +} + +/// Reports a failure only to a developer. An updater that cannot reach the +/// network is not the user's problem and must not produce a dialog; `RFM_DEBUG=1` +/// is how it becomes visible, matching how the rest of the app gates detail. +fn note(context: &str, error: &dyn std::fmt::Display) { + if crate::debug_mode() { + eprintln!("updater: {context}: {error}"); + } +} + +/// Checks for an update and downloads it, in the background. +/// +/// Silent on every failure by design: no endpoint, no network, a malformed +/// manifest or a bad signature all leave the app exactly as it was. +pub fn spawn_check(app: tauri::AppHandle) { + // A dev build's version always matches the manifest, so the check can only + // cost a request. Skipped rather than relied upon to no-op. + if cfg!(debug_assertions) { + return; + } + tauri::async_runtime::spawn(async move { + let updater = match app.updater() { + Ok(updater) => updater, + Err(error) => return note("could not build the updater", &error), + }; + let update = match updater.check().await { + Ok(Some(update)) => update, + // Already current: the overwhelmingly common case. + Ok(None) => return, + Err(error) => return note("check failed", &error), + }; + let bytes = match update.download(|_, _| {}, || {}).await { + Ok(bytes) => bytes, + Err(error) => return note("download failed", &error), + }; + if let Some(state) = app.try_state::() { + *state.0.lock().unwrap() = Some(Staged { update, bytes }); + } + }); +} + +/// Runs a staged installer, if there is one. Called from the exit event. +/// +/// `restart_after_install(false)` is the whole point: the default relaunches the +/// app, and relaunching something the user has just closed is worse than the +/// stale build it fixes. +pub fn install_staged(app: &tauri::AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + // Taken, not borrowed: if the installer somehow returns, a second attempt + // during the same shutdown is not wanted. + let Some(staged) = state.0.lock().unwrap().take() else { + return; + }; + if let Err(error) = staged + .update + .restart_after_install(false) + .install(staged.bytes) + { + note("install failed", &error); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..fbdf3ce --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "reforgermods.net launcher", + "version": "0.1.0", + "identifier": "net.reforgermods.launcher", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "reforgermods.net launcher", + "width": 1400, + "height": 880, + "minWidth": 1000, + "minHeight": 620, + "resizable": true, + "center": true, + "decorations": false, + "theme": "Dark", + "dragDropEnabled": false, + "backgroundColor": "#050d10" + } + ], + "security": { + "csp": { + "default-src": "'self'", + "script-src": "'self'", + "style-src": "'self' 'unsafe-inline'", + "font-src": "'self'", + "connect-src": "'self' ipc: http://ipc.localhost", + "img-src": "'self' data: https://ar-gcp-cdn.bistudio.com", + "object-src": "'none'", + "base-uri": "'self'", + "frame-src": "'none'" + }, + "capabilities": ["main"] + } + }, + "bundle": { + "active": true, + "targets": ["nsis"], + "createUpdaterArtifacts": true, + "publisher": "reforgermods.net", + "shortDescription": "Server browser and direct launcher for Arma Reforger.", + "longDescription": "A fast, lightweight desktop companion for Arma Reforger built on the reforgermods.net API: live server browser, filtering, favorites and direct joining through Steam.", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.ico" + ], + "windows": { + "nsis": { + "installMode": "currentUser" + }, + "webviewInstallMode": { + "type": "downloadBootstrapper" + } + } + }, + "plugins": { + "updater": { + "endpoints": ["https://dl.reforgermods.net/updates/latest.json"], + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEIxMDJFNDhENEZDNTE4M0IKUldRN0dNVlBqZVFDc1dBTHlSY2tsakRyNjYvZHdpdWNHQzRud3dzeGhlTTFidmwrekNaUlJYdEYK", + "windows": { + "installMode": "passive" + } + } + } +} diff --git a/src-tauri/tauri.signing.conf.json b/src-tauri/tauri.signing.conf.json new file mode 100644 index 0000000..2cab3e0 --- /dev/null +++ b/src-tauri/tauri.signing.conf.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "windows": { + "signCommand": "cmd /C ..\\packaging\\windows\\sign.cmd \"%1\"" + } + } +} diff --git a/src/app.test.tsx b/src/app.test.tsx new file mode 100644 index 0000000..e8ae012 --- /dev/null +++ b/src/app.test.tsx @@ -0,0 +1,469 @@ +/** + * @vitest-environment jsdom + * + * Smoke test for the shell: with the Tauri IPC layer stubbed, the application + * must mount, render its chrome, and populate the table from a server page. + * This is the check that a component throws at render time — something neither + * `tsc` nor the Rust tests can catch. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "preact"; +import type { ServerSummary } from "./lib/types"; + +const server: ServerSummary = { + id: "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f", + name: "[OGR2] Old Guard Revival 2 | Ruha Finland", + scenarioId: "{FAC303E9F1B7CAB2}Missions/OGR_RuhaN-S.conf", + scenarioName: "[OGR] Ruha Conflict North South", + scenarioThumbnailUrl: + "https://ar-gcp-cdn.bistudio.com/image/b64d/02af30860106e7097a8238852468fb85d249062ae56b43b1d6c34e70c20f/45316.jpg", + gameVersion: "1.8.0.13", + official: false, + passwordProtected: false, + battlEye: true, + crossPlay: true, + players: 12, + maxPlayers: 128, + queue: 0, + queueMax: 25, + pingSiteId: "new_york", + region: "new_york", + platform: "LINUX", + platforms: ["pc", "xbox", "psn"], + modCount: 122, + online: true, + firstSeen: null, + lastSeen: null, + analytics: null, + joinUrl: null, +}; + +/** Stub the Tauri bridge; nothing in a test environment can reach a backend. */ +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(async (command: string) => { + switch (command) { + case "list_servers": + return { + servers: [server], + totalServers: 4999, + totalPages: 50, + currentPage: 1, + filteredOut: 0, + dataset: { + warming: false, + stale: false, + snapshotAgeSeconds: 18, + lastCollectionAt: "2026-09-24T06:54:58Z", + serverCount: 94223, + onlineServerCount: 4999, + offlineServerCount: 89224, + }, + }; + case "get_local_state": + return { + settings: { + perPage: 100, + autoRefresh: true, + refreshIntervalSecs: 60, + discordPresence: true, + }, + favorites: [server.id], + recents: [{ id: server.id, name: server.name, lastSeenAt: 1790149200 }], + history: [], + }; + case "get_status": + return { + apiReachable: true, + dataset: null, + reportedStatus: null, + note: "This API deployment does not serve /v2/status.", + }; + case "get_server": + return { + ...server, + present: true, + hostType: "CommunityDs", + joinable: true, + host: "208.92.232.141", + port: 2001, + fps: 60, + activity: { + peak24h: 95, + avg24h: 33, + median24h: 22, + low24h: 0, + peak7d: 128, + samples24h: 1286, + trackingSince: null, + }, + modSummary: { + count: 2, + knownSize: 8_772_055_531, + knownSizeText: "8.17 GiB", + unresolvedCount: 1, + }, + mods: [ + { + id: "64610AFB74AA9842", + name: "WCS_Core", + version: "8.2.0", + size: 8_772_055_531, + sizeText: null, + sizeKnown: true, + modUrl: null, + }, + { + id: "1337C0DE5DABBEEF", + name: "RHS - Content Pack 01", + version: "0.16.5208", + size: 0, + sizeText: null, + sizeKnown: false, + modUrl: null, + }, + ], + }; + case "get_servers": + return [server]; + case "is_debug_mode": + return false; + case "get_install_identity": + return { id: "", machineDerived: false }; + case "get_fleet_now": + return { onlineServers: 5089, players: 16352, capacity: 224986, queue: 694 }; + case "ping_regions": + return { new_york: { summary: { bestMs: 70, medianMs: 72, received: 3, sent: 3 } } }; + case "get_install_id": + return "8f14e45f-ceea-467a-9c2b-1f2a3b4c5d6e"; + case "ping_server": + return { summary: { bestMs: 70, medianMs: 72, received: 3, sent: 3 } }; + case "get_server_history": + return { + serverId: server.id, + range: "24h", + bucket: "5m", + points: [ + { t: 1790149200, avg: 6, min: 5, max: 7, cap: 128 }, + { t: 1790149500, avg: 40, min: 20, max: 95, cap: 128 }, + { t: 1790149800, avg: 12, min: 10, max: 14, cap: 128 }, + ], + }; + case "get_mod_readiness": + return { + available: true, + required: 136, + upToDate: 39, + outdated: 0, + missing: 96, + missingBytes: 8_772_055_531, + unresolvedMods: 1, + installedTotal: 235, + }; + case "list_ping_sites": + return [ + { id: "new_york", label: "New York", regionGroup: "North America", address: null }, + ]; + default: + return null; + } + }), +})); + +async function mount() { + const { App } = await import("./app"); + const { start } = await import("./lib/state"); + const root = document.createElement("div"); + document.body.appendChild(root); + render(, root); + await start(); + // Let the resulting signal updates flush. + await new Promise((resolve) => setTimeout(resolve, 0)); + return root; +} + +/** + * The column headings, in order. + * + * Sortable headings are buttons wrapping a label and a direction arrow, so this + * reads the label rather than the cell's whole text content — which would + * otherwise pick up the arrow on whichever column is currently sorted. + */ +function headerLabels(root: HTMLElement): (string | null | undefined)[] { + return Array.from(root.querySelectorAll(".thead > *")).map( + (cell) => (cell.querySelector(".th-sort__label") ?? cell).textContent, + ); +} + +describe("application shell", () => { + beforeEach(() => { + document.body.innerHTML = ""; + vi.resetModules(); + }); + + it("renders the brand, navigation and status", async () => { + const root = await mount(); + const text = root.textContent ?? ""; + expect(text).toContain("reforger"); + expect(text).toContain("launcher"); + expect(text).toContain("Server browser"); + expect(text).toContain("Favorites"); + expect(text).toContain("Settings"); + // Status resolved from the stubbed probe. + expect(text).toContain("API operational"); + }); + + it("renders a fetched server into the table", async () => { + const root = await mount(); + const text = root.textContent ?? ""; + expect(text).toContain("Old Guard Revival 2"); + expect(text).toContain("12 / 128"); + expect(text).toContain("New York"); + // 4,999 matched servers reported in the footer. + expect(text).toMatch(/4[.,  ]?999/); + }); + + it("exposes the search field with its shortcut hint", async () => { + const root = await mount(); + const search = root.querySelector('input[aria-label="Search servers"]'); + expect(search).not.toBeNull(); + expect(root.textContent).toContain("Ctrl F"); + }); + + it("renders the filter chips the API supports", async () => { + const root = await mount(); + const chips = Array.from(root.querySelectorAll(".chip")).map((c) => c.textContent); + expect(chips).toEqual(["Official", "Modded", "BattlEye", "Password", "Offline"]); + // All start unset, so no filter is silently applied at launch. + for (const chip of root.querySelectorAll(".chip")) { + expect(chip.getAttribute("data-state")).toBe("unset"); + } + }); + + it("shows the empty inspector until a server is selected", async () => { + const root = await mount(); + expect(root.textContent).toContain("No server selected"); + }); + + it("shows region latency in the table and offers sorting by it", async () => { + const root = await mount(); + expect(headerLabels(root)).toEqual([ + "Server", "", "Players", "Queue", "Ping", "Region", "Mods", "Platform", + ]); + + // The stub measures new_york, which is this server's region. + const cell = root.querySelector(".cell.ping"); + expect(cell?.textContent).toBe("72"); + + const sortOptions = Array.from( + root.querySelectorAll('select[aria-label="Sort order"]')[0]?.options ?? [], + ).map((o) => o.value); + expect(sortOptions).toContain("ping"); + }); + + it("sorts from the column headings, marking what only reaches this page", async () => { + const root = await mount(); + const { clientSort, query } = await import("./lib/state"); + + const heading = (label: string) => + Array.from(root.querySelectorAll(".thead .th-sort")).find( + (b) => b.querySelector(".th-sort__label")?.textContent === label, + ); + + // Queue is not an ordering the API offers, so it reorders the loaded page + // and the footer has to say so. + heading("Queue")?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(clientSort.value).toEqual({ column: "queue", direction: "desc" }); + expect(root.textContent).toContain("sorted by queue within this page"); + + // Clicking it again reverses it, still page-local. + heading("Queue")?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(clientSort.value).toEqual({ column: "queue", direction: "asc" }); + + // Players is a sort the API serves across every matching server, so the + // local reordering is dropped and the caveat goes with it. + heading("Players")?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(clientSort.value).toBeNull(); + expect(query.value.sort).toBe("players"); + expect(root.textContent).not.toContain("within this page"); + + // Reversing it is something the API has no parameter for, so that one does + // fall back to the page. + heading("Players")?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(clientSort.value).toEqual({ column: "players", direction: "asc" }); + expect(root.textContent).toContain("sorted by players within this page"); + }); + + it("renders favorites as a server table, not a plain list", async () => { + const root = await mount(); + const { setView } = await import("./lib/state"); + setView("favorites"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Same table chrome as the browser, with live columns. + expect(headerLabels(root)).toEqual([ + "Server", "", "Players", "Queue", "Ping", "Region", "Mods", "Platform", + ]); + expect(root.textContent).toContain("Old Guard Revival 2"); + expect(root.textContent).toContain("12 / 128"); + // The inspector travels with the table. + expect(root.textContent).toContain("No server selected"); + }); + + it("keeps the install identifier out of the UI outside debug mode", async () => { + const root = await mount(); + const { setView } = await import("./lib/state"); + setView("settings"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const text = root.textContent ?? ""; + expect(text).not.toContain("Install identity"); + expect(text).not.toContain("Reset identifier"); + // The Discord toggle is user-facing and should still be there. + expect(text).toContain("Discord status"); + }); + + it("shows a platform mark per supported platform", async () => { + const root = await mount(); + const badges = Array.from( + root.querySelectorAll(".row .platforms__badge svg"), + ).map((b) => b.getAttribute("aria-label")); + expect(badges).toEqual(["PC", "Xbox", "PlayStation"]); + expect(root.textContent).not.toContain("XPLAY"); + }); + + it("aligns the header with the rows by sharing one scroll container", async () => { + // With the header outside the scroller, a visible scrollbar narrowed the + // rows and every column drifted left of its heading. + const root = await mount(); + const table = root.querySelector(".table"); + expect(table?.querySelector(".thead")).not.toBeNull(); + expect(table?.querySelector(".rows")).not.toBeNull(); + }); + + it("shows status as aligned marks, not pills trailing the name", async () => { + const root = await mount(); + // The pills used to start wherever the name ended, forming a ragged column. + expect(root.querySelector(".row .name .tag")).toBeNull(); + const marks = Array.from( + root.querySelectorAll(".row .marks .marks__i"), + ).map((m) => m.getAttribute("title")); + // This server is community, open and BattlEye-enabled. + expect(marks).toEqual(["BattlEye enabled"]); + }); + + it("uses a dedicated favourite button per row", async () => { + const root = await mount(); + const star = root.querySelector(".row .star"); + expect(star).not.toBeNull(); + // The stubbed local state has this server favourited, so the star is on. + expect(star?.getAttribute("aria-pressed")).toBe("true"); + expect(star?.getAttribute("data-on")).toBe("true"); + expect(star?.getAttribute("title")).toBe("Remove from favorites"); + // Drawn as a glyph, not a text character. + expect(star?.querySelector("svg")).not.toBeNull(); + }); + + it("drops the population bar beside the player count", async () => { + const root = await mount(); + expect(root.querySelector(".pop__bar")).toBeNull(); + expect(root.querySelector(".row .cell.num")?.textContent).toBe("12 / 128"); + }); + + it("shows the fleet player total beside the server count", async () => { + const root = await mount(); + const context = root.querySelector(".titlebar__context")?.textContent ?? ""; + expect(context).toMatch(/4[.,\s]?999\s*servers/); + expect(context).toContain("players online"); + expect(context).toMatch(/16[.,\s]?352/); + }); + + it("colours the wordmark like the site", async () => { + const root = await mount(); + expect(root.querySelector(".wordmark__reforger")?.textContent).toBe("reforger"); + expect(root.querySelector(".wordmark__mods")?.textContent).toBe("mods"); + expect(root.querySelector(".wordmark__net")?.textContent).toBe(".net"); + }); + + it("no longer offers a scenario filter", async () => { + const root = await mount(); + expect( + root.querySelector('select[aria-label="Scenario (filters the loaded page)"]'), + ).toBeNull(); + }); + + it("renders readiness and the history chart once a server is selected", async () => { + const root = await mount(); + const { selectServer } = await import("./lib/state"); + await selectServer(server.id); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const text = root.textContent ?? ""; + // The honest copy, end to end through the IPC boundary. + expect(text).toContain( + "96 of 136 mods missing, at least 8.17 GiB to download. " + + "Arma Reforger downloads these when you join.", + ); + // Counts read as labelled stats rather than a sentence fragment. + const counts = Array.from(root.querySelectorAll(".readiness__counts div")).map( + (d) => [d.querySelector("dt")?.textContent, d.querySelector("dd")?.textContent], + ); + expect(counts).toEqual([ + ["Installed", "39"], + ["Missing", "96"], + ["On disk", "235"], + ]); + // Readiness needs action, and the state is carried by a dot, not a side edge. + expect(root.querySelector(".readiness")?.getAttribute("data-tone")).toBe("action"); + expect(root.querySelector(".readiness__dot")).not.toBeNull(); + + // The chart drew from the stubbed series. + const line = root.querySelector(".chart__line"); + expect(line).not.toBeNull(); + expect(line?.getAttribute("d")).toMatch(/^M0,/); + expect(text).toContain("peak"); + + // And no action is offered that the launcher cannot perform. + expect(text).not.toContain("Prepare mods"); + + // Mod rows split name, version and size into aligned columns. + const first = root.querySelector(".modlist__row"); + expect(first?.querySelector(".modlist__name")?.textContent).toBe("WCS_Core"); + expect(first?.querySelector(".modlist__version")?.textContent).toBe("8.2.0"); + expect(first?.querySelector(".modlist__size")?.textContent).toBe("8.2 GiB"); + }); + + it("renders the scenario image and the measured ping", async () => { + const root = await mount(); + const { selectServer } = await import("./lib/state"); + await selectServer(server.id); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const img = root.querySelector(".scenario__img"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("src")).toContain("ar-gcp-cdn.bistudio.com"); + + // Measured latency, not a region estimate. + expect(root.textContent).toContain("72 ms"); + // The published address, for people who want to see it. + expect(root.textContent).toContain("208.92.232.141:2001"); + }); + + it("offers the history range switcher", async () => { + const root = await mount(); + const { selectServer } = await import("./lib/state"); + await selectServer(server.id); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const ranges = Array.from(root.querySelectorAll(".rangeswitch button")).map( + (b) => b.textContent, + ); + expect(ranges).toEqual(["6h", "24h", "7d", "30d"]); + const active = root.querySelector('.rangeswitch button[data-on="true"]'); + expect(active?.textContent).toBe("24h"); + }); +}); diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 0000000..93de1f5 --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,32 @@ +/** Application shell: brand, header, navigation and the active view. */ + +import { Inspector } from "./components/Inspector"; +import { TitleBar } from "./components/TitleBar"; +import { Sidebar } from "./components/Sidebar"; +import { BrowseView } from "./views/BrowseView"; +import { FavoritesView, RecentView } from "./views/ListView"; +import { SettingsView } from "./views/SettingsView"; +import { view } from "./lib/state"; + +export function App() { + const current = view.value; + // The saved lists render the same table as the browser, so they get the same + // inspector; only settings is full width. + const showInspector = current !== "settings"; + + return ( +
+ + + + +
+ {current === "browse" && } + {current === "favorites" && } + {current === "recent" && } + {current === "settings" && } + {showInspector && } +
+
+ ); +} diff --git a/src/assets/brand-mark.png b/src/assets/brand-mark.png new file mode 100644 index 0000000..e8ff44b Binary files /dev/null and b/src/assets/brand-mark.png differ diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx new file mode 100644 index 0000000..d1059a6 --- /dev/null +++ b/src/components/FilterBar.tsx @@ -0,0 +1,227 @@ +/** + * Compact filter row. + * + * Only filters `/v2/servers` actually supports appear here, plus the one + * client-side control (minimum players), which is labelled as page-local so it + * does not read as a server-side filter. + */ + +import { useEffect, useRef } from "preact/hooks"; +import { + clearFilters, + clientSort, + pingSites, + query, + searchInput, + setQuery, + setSearch, +} from "../lib/state"; +import { humaniseRegion } from "../lib/format"; +import type { ClientPlatform, ServerSort } from "../lib/types"; + +/** Cycles a tri-state filter: unset -> true -> false -> unset. */ +function nextTriState(value: boolean | null): boolean | null { + if (value === null) return true; + if (value) return false; + return null; +} + +function triLabel(value: boolean | null): "on" | "off" | "unset" { + return value === null ? "unset" : value ? "on" : "off"; +} + +interface ChipProps { + label: string; + value: boolean | null; + onChange: (next: boolean | null) => void; + titles: [unset: string, on: string, off: string]; +} + +function Chip({ label, value, onChange, titles }: ChipProps) { + const state = triLabel(value); + const title = state === "unset" ? titles[0] : state === "on" ? titles[1] : titles[2]; + return ( + + ); +} + +export function FilterBar() { + const q = query.value; + const searchRef = useRef(null); + + // Ctrl+K focuses search, Escape clears it. Registered once for the window so + // the shortcut works regardless of what currently has focus. + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f") { + event.preventDefault(); + searchRef.current?.focus(); + searchRef.current?.select(); + } else if (event.key === "Escape" && document.activeElement === searchRef.current) { + searchRef.current?.blur(); + if (searchInput.value) setSearch(""); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + const sites = pingSites.value; + + return ( +
+ + + + + + + + + + setQuery({ official })} + titles={["Any", "Official only", "Community only"]} + /> + setQuery({ hasMods })} + titles={["Any", "Modded only", "Vanilla only"]} + /> + setQuery({ battleye })} + titles={["Any", "BattlEye on", "BattlEye off"]} + /> + setQuery({ locked })} + titles={["Any", "Locked only", "Open only"]} + /> + + + + Min players + { + const raw = Number((e.target as HTMLInputElement).value); + setQuery({ minPlayers: Number.isFinite(raw) ? Math.max(0, Math.trunc(raw)) : 0 }); + }} + /> + +
+ {hasActiveFilters(q) && ( + + )} +
+ ); +} + +function hasActiveFilters(q: typeof query.value): boolean { + return ( + q.search.trim() !== "" || + q.region !== null || + q.platform !== null || + q.official !== null || + q.hasMods !== null || + q.battleye !== null || + q.locked !== null || + q.includeOffline || + q.minPlayers > 0 + ); +} diff --git a/src/components/HistoryChart.tsx b/src/components/HistoryChart.tsx new file mode 100644 index 0000000..d9f7eb7 --- /dev/null +++ b/src/components/HistoryChart.tsx @@ -0,0 +1,74 @@ +/** + * Population history for the selected server. + * + * One inline SVG, no charting library: a min–max band, an average line, and a + * range switcher. The paths come from `lib/chart.ts`, which is pure and tested. + */ + +import { historyLoading, historyRange, historySeries, setHistoryRange } from "../lib/state"; +import { buildChart } from "../lib/chart"; +import { HISTORY_RANGES } from "../lib/types"; + +/** Drawing box. `preserveAspectRatio="none"` stretches it to the panel width. */ +const WIDTH = 300; +const HEIGHT = 56; + +export function HistoryChart() { + const series = historySeries.value; + const loading = historyLoading.value; + const range = historyRange.value; + const geometry = series ? buildChart(series.points, WIDTH, HEIGHT) : null; + + return ( +
+
+ Population +
+ {HISTORY_RANGES.map((option) => ( + + ))} +
+
+ + {geometry ? ( + <> + + {/* Band first so the average line sits on top of it. */} + + {/* non-scaling-stroke keeps the line 1px despite the stretched viewBox. */} + + +
+ + peak {geometry.peak} + + + avg {geometry.mean} + + + low {geometry.low} + + {geometry.cap > 0 && of {geometry.cap}} + {series?.bucket} buckets +
+ + ) : ( +

+ {loading ? "Loading history…" : "No recorded history for this range."} +

+ )} +
+ ); +} diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx new file mode 100644 index 0000000..fa8bc91 --- /dev/null +++ b/src/components/Inspector.tsx @@ -0,0 +1,279 @@ +/** + * Selected-server detail pane. + * + * Shows only values the API actually returns, or that were measured locally. + * There is deliberately no uptime figure, and mod readiness is reported without + * any action attached: the launcher cannot install Workshop content, so it says + * what the game will do instead. See `docs/mod-readiness.md`. + */ + +import { + detailError, + detailLoading, + favoriteSet, + join, + joinError, + joining, + ping, + pingLoading, + selectServer, + selectedId, + selectedServer, + toggleFavorite, +} from "../lib/state"; +import { HistoryChart } from "./HistoryChart"; +import { PlatformBadges } from "./PlatformBadges"; +import { StatusMarks } from "./StatusMarks"; +import { ReadinessNote } from "./ReadinessNote"; +import { openDiscordInvite, openServerPage } from "../lib/ipc"; +import { + discordInviteFrom, + formatBytes, + formatPlayers, + humaniseRegion, + safeImageUrl, + tidyName, +} from "../lib/format"; +import type { ServerDetail } from "../lib/types"; + +export function Inspector() { + const id = selectedId.value; + const server = selectedServer.value; + const error = detailError.value; + + if (!id) { + return ( + + ); + } + + if (error) { + return ( + + ); + } + + if (!server) { + return ( + + ); + } + + return ; +} + +function Detail({ server, isFavorite }: { server: ServerDetail; isFavorite: boolean }) { + const refreshing = detailLoading.value; + const busy = joining.value; + const failure = joinError.value; + + // About half of servers publish no imagery, and any URL the API sends is + // untrusted, so this is null more often than not. + const image = safeImageUrl(server.scenarioThumbnailUrl); + // Server operators advertise their Discord in the server name; the same + // detection the website uses surfaces it as a button. + const discord = discordInviteFrom(server.name); + + return ( + + ); +} + +/** + * Measured round-trip latency to the server, by ICMP. + * + * Shows the median of three probes. When the host does not answer — about a + * third of servers filter ICMP — it says so rather than substituting a + * region-level estimate, which would look like a per-server measurement. + */ +function PingFact() { + const result = ping.value; + const loading = pingLoading.value; + + if (loading && !result) return ; + const summary = result?.summary; + if (!summary) { + return ( +
+
Ping
+
—
+
+ ); + } + const loss = summary.sent > 0 ? Math.round(((summary.sent - summary.received) / summary.sent) * 100) : 0; + return ( +
+
Ping{loss > 0 ? ` (${loss}% loss)` : ""}
+
{summary.medianMs} ms
+
+ ); +} + +function Fact({ k, v }: { k: string; v: string }) { + return ( +
+
{k}
+
+ {v} +
+
+ ); +} diff --git a/src/components/PlatformBadges.tsx b/src/components/PlatformBadges.tsx new file mode 100644 index 0000000..ccc0f88 --- /dev/null +++ b/src/components/PlatformBadges.tsx @@ -0,0 +1,82 @@ +/** + * Platform badges. + * + * The same three marks reforgermods.net uses, so a server reads the same in + * both places. Glyphs are Bootstrap Icons (MIT) — `pc-display`, `xbox` and + * `playstation` — inlined as paths rather than pulled in as a webfont, since + * three icons do not justify a font file and the CSP forbids remote fonts. + */ + +import type { JSX } from "preact"; + +/** Server-reported platform tokens. */ +export type PlatformToken = "pc" | "xbox" | "psn"; + +interface Glyph { + label: string; + /** Brand colour, used sparingly: the badge tint only. */ + tint: string; + path: string; +} + +const GLYPHS: Record = { + pc: { + label: "PC", + tint: "#9aa7b7", + path: "M8 1a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1zm1 13.5a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0m2 0a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0M9.5 1a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM9 3.5a.5.5 0 0 0 .5.5h5a.5.5 0 0 0 0-1h-5a.5.5 0 0 0-.5.5M1.5 2A1.5 1.5 0 0 0 0 3.5v7A1.5 1.5 0 0 0 1.5 12H6v2h-.5a.5.5 0 0 0 0 1H7v-4H1.5a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5H7V2z", + }, + xbox: { + label: "Xbox", + tint: "#6bad4a", + path: "M7.202 15.967a8 8 0 0 1-3.552-1.26c-.898-.585-1.101-.826-1.101-1.306 0-.965 1.062-2.656 2.879-4.583C6.459 7.723 7.897 6.44 8.052 6.475c.302.068 2.718 2.423 3.622 3.531 1.43 1.753 2.088 3.189 1.754 3.829-.254.486-1.83 1.437-2.987 1.802-.954.301-2.207.429-3.239.33m-5.866-3.57C.589 11.253.212 10.127.03 8.497c-.06-.539-.038-.846.137-1.95.218-1.377 1.002-2.97 1.945-3.95.401-.417.437-.427.926-.263.595.2 1.23.638 2.213 1.528l.574.519-.313.385C4.056 6.553 2.52 9.086 1.94 10.653c-.315.852-.442 1.707-.306 2.063.091.24.007.15-.3-.319Zm13.101.195c.074-.36-.019-1.02-.238-1.687-.473-1.443-2.055-4.128-3.508-5.953l-.457-.575.494-.454c.646-.593 1.095-.948 1.58-1.25.381-.237.927-.448 1.161-.448.145 0 .654.528 1.065 1.104a8.4 8.4 0 0 1 1.343 3.102c.153.728.166 2.286.024 3.012a9.5 9.5 0 0 1-.6 1.893c-.179.393-.624 1.156-.82 1.404-.1.128-.1.127-.043-.148ZM7.335 1.952c-.67-.34-1.704-.705-2.276-.803a4 4 0 0 0-.759-.043c-.471.024-.45 0 .306-.358A7.8 7.8 0 0 1 6.47.128c.8-.169 2.306-.17 3.094-.005.85.18 1.853.552 2.418.9l.168.103-.385-.02c-.766-.038-1.88.27-3.078.853-.361.176-.676.316-.699.312a12 12 0 0 1-.654-.319Z", + }, + psn: { + label: "PlayStation", + tint: "#6d8fd6", + path: "M15.858 11.451c-.313.395-1.079.676-1.079.676l-5.696 2.046v-1.509l4.192-1.493c.476-.17.549-.412.162-.538-.386-.127-1.085-.09-1.56.08l-2.794.984v-1.566l.161-.054s.807-.286 1.942-.412c1.135-.125 2.525.017 3.616.43 1.23.39 1.368.962 1.056 1.356M9.625 8.883v-3.86c0-.453-.083-.87-.508-.988-.326-.105-.528.198-.528.65v9.664l-2.606-.827V2c1.108.206 2.722.692 3.59.985 2.207.757 2.955 1.7 2.955 3.825 0 2.071-1.278 2.856-2.903 2.072Zm-8.424 3.625C-.061 12.15-.271 11.41.304 10.984c.532-.394 1.436-.69 1.436-.69l3.737-1.33v1.515l-2.69.963c-.474.17-.547.411-.161.538.386.126 1.085.09 1.56-.08l1.29-.469v1.356l-.257.043a8.45 8.45 0 0 1-4.018-.323Z", + }, +}; + +/** True for a token the launcher knows how to draw. */ +export function isPlatformToken(value: string): value is PlatformToken { + return value === "pc" || value === "xbox" || value === "psn"; +} + +/** + * Renders the platforms a server accepts. + * + * Unknown tokens are skipped rather than drawn as a placeholder: a future + * platform should be invisible here until it has a real mark. + */ +export function PlatformBadges({ + platforms, + size = 14, +}: { + platforms: string[]; + size?: number; +}): JSX.Element | null { + const known = platforms.filter(isPlatformToken); + if (known.length === 0) return null; + + return ( + + {known.map((token) => { + const glyph = GLYPHS[token]; + return ( + + + + + + ); + })} + + ); +} diff --git a/src/components/ReadinessNote.tsx b/src/components/ReadinessNote.tsx new file mode 100644 index 0000000..8a30a82 --- /dev/null +++ b/src/components/ReadinessNote.tsx @@ -0,0 +1,55 @@ +/** + * Mod readiness, as information only. + * + * Deliberately a single line of text and a count strip — not a checklist with a + * "prepare" button. There is no verified way to install Reforger Workshop + * content from outside the game, so the launcher states what it knows and what + * the game will do, and offers no action it cannot perform. See + * `docs/mod-readiness.md`. + */ + +import { readiness } from "../lib/state"; +import { readinessSummary, readinessTone } from "../lib/format"; + +export function ReadinessNote() { + const report = readiness.value; + // Null means the check has not answered yet. Rendering nothing is correct: + // an unknown state must never look like a reassuring one. + if (!report) return null; + + const tone = readinessTone(report); + + return ( +
+

+ {/* A dot carries the state without a coloured edge down the side. */} + + {readinessSummary(report)} +

+ {report.available && report.required > 0 && ( +
+
+
Installed
+
{report.upToDate}
+
+ {report.outdated > 0 && ( +
+
Outdated
+
{report.outdated}
+
+ )} + {report.missing > 0 && ( +
+
Missing
+
{report.missing}
+
+ )} +
+
On disk
+
{report.installedTotal}
+
+
+ )} +
+ ); +} diff --git a/src/components/ServerTable.tsx b/src/components/ServerTable.tsx new file mode 100644 index 0000000..88e7643 --- /dev/null +++ b/src/components/ServerTable.tsx @@ -0,0 +1,296 @@ +/** + * The server table. + * + * Windowed: only the rows intersecting the viewport are in the DOM, so a + * 500-row page costs the same as a 20-row one. Row height is fixed in CSS + * (`--row-h`) and mirrored here, which is what makes the arithmetic cheap + * enough to run on every scroll frame. + */ + +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { + activeSort, + favoriteSet, + pingFor, + selectServer, + selectedId, + sortByColumn, + sortIsPageLocal, + toggleFavorite, +} from "../lib/state"; +import type { SortColumn } from "../lib/sort"; +import { formatPlayers, humaniseRegion, tidyName } from "../lib/format"; +import { PlatformBadges } from "./PlatformBadges"; +import { FavouriteStar, StatusMarks } from "./StatusMarks"; +import type { ServerSummary } from "../lib/types"; + +const ROW_HEIGHT = 30; +/** Rows rendered beyond the viewport, so fast scrolling does not show gaps. */ +const OVERSCAN = 8; + +interface Props { + servers: ServerSummary[]; + /** + * True when these rows are one page of a larger, API-ordered result. + * + * It decides both halves of the sorting behaviour: whether a header click can + * ask the API to reorder the whole dataset, and whether an ordering it cannot + * serve has to be marked as reaching only the loaded page. The favorites and + * recents tables pass false — those lists are resolved in full, so sorting + * them is always complete and never needs the caveat. + */ + paginated?: boolean; +} + +export function ServerTable({ servers, paginated = true }: Props) { + const scrollerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(600); + + useEffect(() => { + const el = scrollerRef.current; + if (!el) return; + const measure = () => setViewportHeight(el.clientHeight); + measure(); + + // ResizeObserver catches layout changes the window never sees, such as the + // inspector collapsing at narrow widths. Fall back to window resize where + // it is unavailable, so the list still sizes correctly. + if (typeof ResizeObserver === "function") { + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + } + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, []); + + const onScroll = useCallback((event: Event) => { + setScrollTop((event.currentTarget as HTMLDivElement).scrollTop); + }, []); + + const first = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const visibleCount = Math.ceil(viewportHeight / ROW_HEIGHT) + OVERSCAN * 2; + const last = Math.min(servers.length, first + visibleCount); + const visibleRows = servers.slice(first, last); + + const selected = selectedId.value; + const favorites = favoriteSet.value; + + /** Moves the selection by `delta` rows and keeps it in view. */ + const move = useCallback( + (delta: number) => { + if (servers.length === 0) return; + const current = servers.findIndex((s) => s.id === selectedId.value); + const nextIndex = Math.min( + servers.length - 1, + Math.max(0, current === -1 ? 0 : current + delta), + ); + const next = servers[nextIndex]; + if (!next) return; + void selectServer(next.id); + const el = scrollerRef.current; + if (!el) return; + const top = nextIndex * ROW_HEIGHT; + if (top < el.scrollTop) el.scrollTop = top; + else if (top + ROW_HEIGHT > el.scrollTop + el.clientHeight) { + el.scrollTop = top + ROW_HEIGHT - el.clientHeight; + } + }, + [servers], + ); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + move(1); + break; + case "ArrowUp": + event.preventDefault(); + move(-1); + break; + case "PageDown": + event.preventDefault(); + move(Math.floor(viewportHeight / ROW_HEIGHT)); + break; + case "PageUp": + event.preventDefault(); + move(-Math.floor(viewportHeight / ROW_HEIGHT)); + break; + case "Home": + event.preventDefault(); + move(-servers.length); + break; + case "End": + event.preventDefault(); + move(servers.length); + break; + } + }, + [move, viewportHeight, servers.length], + ); + + return ( +
+ {/* Inside the scroller, so the header and the rows share one width. With + the header outside, a visible scrollbar narrowed the rows and every + column drifted left of its heading. */} + + ); +} + +interface SortHeaderProps { + column: SortColumn; + label: string; + /** Matches the column's cell alignment, so heading and values line up. */ + align?: "num" | "mid"; + paginated: boolean; +} + +/** + * A clickable column heading. + * + * The arrow is filled when the ordering covers every matching server and hollow + * when it only reorders the rows already loaded — a distinction the API forces, + * since it sorts by `players` and `name` alone and takes no direction. Shape + * rather than colour alone carries it, and the tooltip says it in words. + */ +function SortHeader({ column, label, align, paginated }: SortHeaderProps) { + const active = activeSort.value; + const isActive = active?.column === column; + const pageLocal = isActive && paginated && sortIsPageLocal.value; + const ascending = active?.direction === "asc"; + + let title: string; + if (!isActive) { + title = `Sort by ${label.toLowerCase()}`; + } else if (pageLocal) { + title = `Sorted by ${label.toLowerCase()} — reorders this page only`; + } else if (paginated) { + title = `Sorted by ${label.toLowerCase()} across all matching servers`; + } else { + title = `Sorted by ${label.toLowerCase()}`; + } + + return ( + + ); +} + +interface RowProps { + server: ServerSummary; + top: number; + isSelected: boolean; + isFavorite: boolean; +} + +/** + * Latency to the server's region. + * + * Region, not per-server: the list endpoint publishes no address, so eight + * probes stand in for a whole page. The title says so, and the inspector shows + * the exact per-server figure. + */ +function PingCell({ pingSiteId }: { pingSiteId: string | null }) { + const ms = pingFor(pingSiteId); + if (ms === null) return
—
; + const band = ms < 60 ? "good" : ms < 140 ? "ok" : "far"; + return ( +
+ {ms} +
+ ); +} + +function Row({ server, top, isSelected, isFavorite }: RowProps) { + return ( +
void selectServer(server.id)} + > +
+ void toggleFavorite(server.id)} + /> + {/* Server names are untrusted text and are rendered as text content only. */} + + {tidyName(server.name)} + +
+ + {/* Own column, so the marks line up instead of trailing each name. */} + + +
= server.maxPlayers}> + {formatPlayers(server.players, server.maxPlayers)} +
+ +
+ {server.queue > 0 ? `+${server.queue}` : "—"} +
+ + + +
+ {humaniseRegion(server.pingSiteId ?? server.region)} +
+ +
{server.modCount > 0 ? server.modCount : "—"}
+ + +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..3ab772e --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,51 @@ +/** Persistent left navigation. */ + +import { favorites, history, recents, setView, view, type View } from "../lib/state"; + +interface Item { + id: View; + label: string; + count?: number; +} + +export function Sidebar() { + const items: Item[] = [ + { id: "browse", label: "Server browser" }, + { id: "favorites", label: "Favorites", count: favorites.value.length }, + { id: "recent", label: "Recent", count: recents.value.length }, + ]; + + return ( + + ); +} diff --git a/src/components/StatusMarks.tsx b/src/components/StatusMarks.tsx new file mode 100644 index 0000000..b84b2b1 --- /dev/null +++ b/src/components/StatusMarks.tsx @@ -0,0 +1,90 @@ +/** + * Row status marks and the favourite star. + * + * These replace the text pills that used to trail the server name. The pills + * started wherever the name happened to end, so they formed a ragged column + * that read as clutter on every row; as icons in a fixed slot they line up and + * become scannable. Glyphs are Bootstrap Icons (MIT), inlined as paths. + */ + +import type { JSX } from "preact"; + +const LOCK = "M8 0a4 4 0 0 1 4 4v2.05a2.5 2.5 0 0 1 2 2.45v5a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 2 13.5v-5a2.5 2.5 0 0 1 2-2.45V4a4 4 0 0 1 4-4m0 1a3 3 0 0 0-3 3v2h6V4a3 3 0 0 0-3-3"; +const OFFICIAL = "M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01zm.287 5.984-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708.708"; +const BATTLEYE = "M5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.8 11.8 0 0 1-2.517 2.453 7 7 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7 7 0 0 1-1.048-.625 11.8 11.8 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 63 63 0 0 1 5.072.56"; +const OFFLINE = "M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1z"; +const STAR_FILLED = "M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"; +const STAR_OUTLINE = "M2.866 14.85c-.078.444.36.791.746.593l4.39-2.256 4.389 2.256c.386.198.824-.149.746-.592l-.83-4.73 3.522-3.356c.33-.314.16-.888-.282-.95l-4.898-.696L8.465.792a.513.513 0 0 0-.927 0L5.354 5.12l-4.898.696c-.441.062-.612.636-.283.95l3.523 3.356-.83 4.73zm4.905-2.767-3.686 1.894.694-3.957a.56.56 0 0 0-.163-.505L1.71 6.745l4.052-.576a.53.53 0 0 0 .393-.288L8 2.223l1.847 3.658a.53.53 0 0 0 .393.288l4.052.575-2.906 2.77a.56.56 0 0 0-.163.506l.694 3.957-3.686-1.894a.5.5 0 0 0-.461 0z"; + +function Glyph({ path, size }: { path: string; size: number }) { + return ( + + ); +} + +/** + * The status marks for one server, in a fixed order. + * + * Order is constant so a given mark always occupies the same position in the + * slot, which is what lets the eye skip the column rather than read it. + */ +export function StatusMarks({ server }: { + server: { + official: boolean; + passwordProtected: boolean; + battlEye: boolean; + online: boolean; + }; +}): JSX.Element { + return ( + + {server.official && ( + + + + )} + {server.passwordProtected && ( + + + + )} + {server.battlEye && ( + + + + )} + {!server.online && ( + + + + )} + + ); +} + +/** Favourite toggle. */ +export function FavouriteStar({ + isFavorite, + onToggle, +}: { + isFavorite: boolean; + onToggle: () => void; +}): JSX.Element { + return ( + + ); +} diff --git a/src/components/StatusPill.tsx b/src/components/StatusPill.tsx new file mode 100644 index 0000000..2bafc69 --- /dev/null +++ b/src/components/StatusPill.tsx @@ -0,0 +1,84 @@ +/** + * Compact API status for the header. + * + * Reads `nowMs`, which ticks once a second, so the "updated Ns ago" figure stays + * live. Because signals subscribe per component, that tick re-renders this pill + * and nothing else — the server table is untouched. + */ + +import { nowMs, status } from "../lib/state"; +import { formatAge } from "../lib/format"; + +export function StatusPill() { + const s = status.value; + // Subscribe to the clock so the age figure advances. + const now = nowMs.value; + + if (!s) { + return ( +
+ + Checking API… +
+ ); + } + + if (!s.apiReachable) { + return ( +
+ + API unreachable +
+ ); + } + + const dataset = s.dataset; + // `snapshotAgeSeconds` was measured when the response was produced; advance it + // by the time since, so the figure keeps counting between requests. + const measuredAtMs = datasetMeasuredAt(dataset?.snapshotAgeSeconds); + const age = + dataset && measuredAtMs !== null + ? dataset.snapshotAgeSeconds + (now - measuredAtMs) / 1000 + : null; + + const stale = dataset?.stale === true; + const warming = dataset?.warming === true; + const dotClass = warming || stale ? "status__dot--warn" : "status__dot--ok"; + + return ( +
+ + {s.reportedStatus ? `API ${s.reportedStatus}` : "API operational"} + {dataset && ( + <> + + + {warming + ? "Data warming up" + : age === null + ? "Data age unknown" + : `Updated ${formatAge(age)}`} + + + )} +
+ ); +} + +/** + * Wall-clock time the current `snapshotAgeSeconds` reading was taken. + * + * Tracked here rather than in state because it exists only to make the label + * tick; it is reset whenever the reading changes. + */ +let lastAgeSeconds: number | null = null; +let lastAgeAtMs: number | null = null; + +function datasetMeasuredAt(ageSeconds: number | undefined): number | null { + if (ageSeconds === undefined) return null; + if (ageSeconds !== lastAgeSeconds) { + lastAgeSeconds = ageSeconds; + lastAgeAtMs = Date.now(); + } + return lastAgeAtMs; +} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx new file mode 100644 index 0000000..f05a164 --- /dev/null +++ b/src/components/TitleBar.tsx @@ -0,0 +1,98 @@ +/** + * Custom window title bar. + * + * The native Windows caption plus our own brand strip meant two stacked bars + * doing one job. This is a single row: brand, context, status, window controls. + * The window is frameless (`decorations: false`), so dragging and the caption + * buttons are wired here. + */ + +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { StatusPill } from "./StatusPill"; +import { fleet, totalServers, view } from "../lib/state"; +import brandMark from "../assets/brand-mark.png"; + +/** + * Resolved lazily: `getCurrentWindow()` reads Tauri internals that only exist + * inside the app, so calling it at module scope would break any environment + * that merely imports this file. + */ +function windowAction(action: "minimize" | "toggleMaximize" | "close"): void { + try { + void getCurrentWindow()[action](); + } catch { + // No Tauri window (tests, or a browser preview): nothing to do. + } +} + +export function TitleBar() { + return ( +
+ {/* The drag region must be the element itself, not a child, or the + draggable area ends up shaped like the text inside it. */} + + {/* Wordmark split and coloured as on reforgermods.net. */} + + reforger + mods + .net + launcher + + + + {view.value === "browse" && totalServers.value > 0 + ? `${totalServers.value.toLocaleString()} servers` + : ""} + {fleet.value && fleet.value.players > 0 && ( + <> + {view.value === "browse" && totalServers.value > 0 && ( + + )} + {`${fleet.value.players.toLocaleString()} players online`} + + )} + + +
+ + +
+ + + +
+
+ ); +} diff --git a/src/lib/chart.test.ts b/src/lib/chart.test.ts new file mode 100644 index 0000000..6746e17 --- /dev/null +++ b/src/lib/chart.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { buildChart } from "./chart"; +import type { ServerHistoryPoint } from "./types"; + +function point(t: number, min: number, avg: number, max: number, cap = 128): ServerHistoryPoint { + return { t, min, avg, max, cap }; +} + +describe("buildChart", () => { + it("refuses to draw a series too short to be a line", () => { + expect(buildChart([], 300, 56)).toBeNull(); + expect(buildChart([point(0, 1, 1, 1)], 300, 56)).toBeNull(); + }); + + it("refuses a zero-sized box", () => { + const points = [point(0, 0, 0, 0), point(300, 1, 1, 1)]; + expect(buildChart(points, 0, 56)).toBeNull(); + expect(buildChart(points, 300, 0)).toBeNull(); + }); + + it("spans the full width and inverts the Y axis", () => { + const points = [point(0, 0, 0, 0), point(300, 10, 10, 10)]; + const chart = buildChart(points, 300, 56); + expect(chart).not.toBeNull(); + // Average line: starts bottom-left at the zero bucket, ends top-right at the peak. + expect(chart?.line).toBe("M0,56L300,0"); + }); + + it("closes the min-max band", () => { + const points = [point(0, 1, 2, 3), point(300, 1, 2, 3)]; + const chart = buildChart(points, 300, 60); + // Upper edge left-to-right, lower edge back, closed. + expect(chart?.band).toBe("M0,0L300,0L300,40L0,40Z"); + }); + + it("scales to the observed peak, not to capacity", () => { + // A server peaking at 8 of 128 must not draw as a flat line on the axis. + const points = [point(0, 0, 4, 8), point(300, 2, 3, 4)]; + const chart = buildChart(points, 300, 100); + expect(chart?.peak).toBe(8); + expect(chart?.cap).toBe(128); + // The 8-player peak reaches the top of the box. + expect(chart?.line.startsWith("M0,50")).toBe(true); + expect(chart?.band.startsWith("M0,0")).toBe(true); + }); + + it("survives an all-zero series without dividing by zero", () => { + const points = [point(0, 0, 0, 0), point(300, 0, 0, 0), point(600, 0, 0, 0)]; + const chart = buildChart(points, 300, 56); + expect(chart?.peak).toBe(0); + expect(chart?.line).not.toContain("NaN"); + expect(chart?.band).not.toContain("NaN"); + }); + + it("reports the summary figures the legend renders", () => { + const points = [point(100, 0, 10, 20), point(200, 5, 15, 25), point(300, 1, 2, 3)]; + const chart = buildChart(points, 300, 56); + expect(chart?.peak).toBe(25); + expect(chart?.low).toBe(0); + expect(chart?.mean).toBe(9); // (10 + 15 + 2) / 3 + expect(chart?.from).toBe(100); + expect(chart?.to).toBe(300); + }); + + it("falls back to zero capacity when no bucket recorded one", () => { + const points = [point(0, 1, 1, 1, 0), point(300, 1, 1, 1, 0)]; + expect(buildChart(points, 300, 56)?.cap).toBe(0); + }); + + it("emits compact path data", () => { + const points = Array.from({ length: 288 }, (_, i) => point(i * 300, i % 7, i % 11, i % 13)); + const chart = buildChart(points, 300, 56); + // Two decimals max, so 288 buckets stay a reasonable DOM string. + expect(chart?.line).not.toMatch(/\.\d{3}/); + expect((chart?.line.length ?? 0)).toBeLessThan(4000); + }); +}); diff --git a/src/lib/chart.ts b/src/lib/chart.ts new file mode 100644 index 0000000..b38f05b --- /dev/null +++ b/src/lib/chart.ts @@ -0,0 +1,82 @@ +/** + * Geometry for the population history chart. + * + * Pure: it turns history buckets into SVG path strings and nothing else, so the + * scaling rules are unit-testable without rendering anything. + * + * The Y axis scales to the observed peak rather than to slot capacity. A server + * that peaks at 8 of 128 would otherwise draw as a flat line on the axis; the + * capacity is stated in the label instead, where it reads better anyway. + */ + +import type { ServerHistoryPoint } from "./types"; + +export interface ChartGeometry { + /** Filled area between each bucket's min and max. */ + band: string; + /** Line through each bucket's average. */ + line: string; + /** Highest `max` in the series, which is the top of the Y scale. */ + peak: number; + /** Lowest `min` in the series. */ + low: number; + /** Average of the bucket averages, rounded. */ + mean: number; + /** Slot capacity, from the last bucket that recorded one. 0 when unknown. */ + cap: number; + /** First and last bucket timestamps, unix seconds. */ + from: number; + to: number; +} + +/** + * Builds the chart paths for `points` inside a `width` x `height` box. + * + * Returns `null` for a series too short to draw, which the caller renders as an + * empty state rather than an axis with nothing on it. + */ +export function buildChart( + points: ServerHistoryPoint[], + width: number, + height: number, +): ChartGeometry | null { + if (points.length < 2 || width <= 0 || height <= 0) return null; + + const peak = points.reduce((acc, p) => Math.max(acc, p.max), 0); + const low = points.reduce((acc, p) => Math.min(acc, p.min), Number.POSITIVE_INFINITY); + const meanRaw = points.reduce((acc, p) => acc + p.avg, 0) / points.length; + const cap = points.reduce((acc, p) => (p.cap > 0 ? p.cap : acc), 0); + + // An all-zero series still needs a finite scale, or every point divides by zero. + const scaleMax = peak > 0 ? peak : 1; + const lastIndex = points.length - 1; + + const x = (i: number) => round((i / lastIndex) * width); + const y = (value: number) => round(height - (value / scaleMax) * height); + + // Upper edge left-to-right, lower edge right-to-left, closed. + const upper = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(i)},${y(p.max)}`); + const lower = points + .slice() + .reverse() + .map((p, i) => `L${x(lastIndex - i)},${y(p.min)}`); + const band = `${upper.join("")}${lower.join("")}Z`; + + const line = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(i)},${y(p.avg)}`).join(""); + + return { + band, + line, + peak, + low: Number.isFinite(low) ? low : 0, + mean: Math.round(meanRaw), + cap, + from: points[0]?.t ?? 0, + to: points[lastIndex]?.t ?? 0, + }; +} + +/** Two decimals is plenty for path data and keeps the DOM string small. */ +function round(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts new file mode 100644 index 0000000..5944e53 --- /dev/null +++ b/src/lib/format.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it } from "vitest"; +import { + discordInviteFrom, + formatAge, + formatBytes, + formatPlayers, + formatTimestamp, + humaniseRegion, + readinessSummary, + safeImageUrl, + readinessTone, + tidyName, +} from "./format"; + +describe("formatBytes", () => { + it("uses binary units", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(1024)).toBe("1.0 KiB"); + expect(formatBytes(1024 * 1024)).toBe("1.0 MiB"); + // The real figure from a 122-mod server. + expect(formatBytes(18870301863)).toBe("17.6 GiB"); + }); + + it("drops the decimal at three digits so columns stay narrow", () => { + expect(formatBytes(1024 * 200)).toBe("200 KiB"); + expect(formatBytes(1024 * 99.5)).toBe("99.5 KiB"); + }); + + it("handles nonsense input without throwing", () => { + expect(formatBytes(-1)).toBe("0 B"); + expect(formatBytes(Number.NaN)).toBe("0 B"); + expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0 B"); + }); +}); + +describe("formatAge", () => { + it("renders the header's freshness label", () => { + expect(formatAge(18)).toBe("18s ago"); + expect(formatAge(0.4)).toBe("just now"); + expect(formatAge(59.9)).toBe("59s ago"); + expect(formatAge(60)).toBe("1m ago"); + expect(formatAge(3600)).toBe("1h ago"); + expect(formatAge(86_400)).toBe("1d ago"); + }); + + it("reports unknown rather than a negative age", () => { + expect(formatAge(-5)).toBe("unknown"); + expect(formatAge(Number.NaN)).toBe("unknown"); + }); +}); + +describe("formatTimestamp", () => { + it("is relative to the supplied clock", () => { + const now = 1_700_000_000_000; + expect(formatTimestamp(now / 1000 - 120, now)).toBe("2m ago"); + }); +}); + +describe("humaniseRegion", () => { + it("turns ping-site ids into labels", () => { + expect(humaniseRegion("new_york")).toBe("New York"); + expect(humaniseRegion("frankfurt")).toBe("Frankfurt"); + expect(humaniseRegion("los_angeles")).toBe("Los Angeles"); + }); + + it("falls back for an absent region", () => { + expect(humaniseRegion(null)).toBe("—"); + expect(humaniseRegion("")).toBe("—"); + expect(humaniseRegion(undefined)).toBe("—"); + }); +}); + + +describe("formatPlayers", () => { + it("renders the population cell", () => { + expect(formatPlayers(12, 128)).toBe("12 / 128"); + }); +}); + + +describe("tidyName", () => { + it("collapses whitespace in untrusted server names", () => { + expect(tidyName(" [EU1] Some Server \n\t ")).toBe("[EU1] Some Server"); + }); + + it("leaves ordinary names alone", () => { + expect(tidyName("[OGR2] Old Guard Revival 2")).toBe("[OGR2] Old Guard Revival 2"); + }); + + it("does not attempt to sanitise markup, which is rendered as text", () => { + // The value is set as text content, so angle brackets are inert; the + // function's job is layout, not escaping. + expect(tidyName("x")).toBe("x"); + }); +}); + +describe("readinessSummary", () => { + it("reads as sentences, not dot-separated fragments", () => { + // The joined-with-middots form read as machine output. + expect(readinessSummary(base)).not.toContain("·"); + }); + + const base = { + available: true, + required: 136, + upToDate: 39, + outdated: 0, + missing: 96, + missingBytes: 8_772_055_531, + unresolvedMods: 1, + installedTotal: 235, + }; + + it("produces the intended sentence for a partly-installed server", () => { + // "at least" because one mod's size is unresolved; the closing clause + // states what the game does, because the launcher cannot do it. + expect(readinessSummary(base)).toBe( + "96 of 136 mods missing, at least 8.17 GiB to download. " + + "Arma Reforger downloads these when you join.", + ); + }); + + it("drops 'at least' once every size is resolved", () => { + const summary = readinessSummary({ ...base, unresolvedMods: 0 }); + expect(summary).toContain("8.17 GiB to download"); + expect(summary).not.toContain("at least"); + }); + + it("never presents an unavailable scan as a verdict", () => { + const summary = readinessSummary({ + available: false, + unavailable: "no_addons_directory", + message: "No local Arma Reforger addon folder found, so installed mods could not be checked.", + required: 0, + upToDate: 0, + outdated: 0, + missing: 0, + missingBytes: 0, + unresolvedMods: 0, + installedTotal: 0, + }); + expect(summary).toContain("could not be checked"); + expect(summary).not.toContain("up to date"); + expect(summary).not.toContain("missing"); + }); + + it("falls back to generic copy if the backend sent no message", () => { + const summary = readinessSummary({ ...base, available: false, message: undefined }); + expect(summary).toBe("Installed mods could not be checked."); + }); + + it("reports a fully-installed server plainly", () => { + expect( + readinessSummary({ ...base, upToDate: 136, outdated: 0, missing: 0, missingBytes: 0, unresolvedMods: 0 }), + ).toBe("All 136 mods installed and up to date."); + }); + + it("says update, not download, when only versions differ", () => { + const summary = readinessSummary({ + ...base, + upToDate: 135, + outdated: 1, + missing: 0, + missingBytes: 0, + unresolvedMods: 0, + }); + expect(summary).toBe("1 of 136 mods out of date. Arma Reforger updates these when you join."); + }); + + it("combines missing and outdated counts", () => { + const summary = readinessSummary({ ...base, outdated: 1, unresolvedMods: 0 }); + expect(summary).toContain("96 missing and 1 out of date of 136 mods"); + }); + + it("says the size is unknown rather than showing 0 B", () => { + const summary = readinessSummary({ ...base, missingBytes: 0, unresolvedMods: 96 }); + expect(summary).toContain("download size unknown"); + expect(summary).not.toContain("0 B"); + }); + + it("handles a vanilla server", () => { + expect(readinessSummary({ ...base, required: 0, missing: 0, missingBytes: 0, unresolvedMods: 0 })).toBe( + "No mods required.", + ); + }); +}); + +describe("readinessTone", () => { + const base = { + available: true, + required: 10, + upToDate: 10, + outdated: 0, + missing: 0, + missingBytes: 0, + unresolvedMods: 0, + installedTotal: 20, + }; + + it("never reads as ready when the scan did not run", () => { + expect(readinessTone({ ...base, available: false })).toBe("unknown"); + }); + + it("distinguishes ready from needing action", () => { + expect(readinessTone(base)).toBe("ready"); + expect(readinessTone({ ...base, missing: 1 })).toBe("action"); + expect(readinessTone({ ...base, outdated: 1 })).toBe("action"); + }); +}); + +describe("formatBytes precision override", () => { + it("keeps the compact default for table cells", () => { + expect(formatBytes(18870301863)).toBe("17.6 GiB"); + }); + + it("honours an explicit precision for download figures", () => { + expect(formatBytes(8_772_055_531, 2)).toBe("8.17 GiB"); + expect(formatBytes(8_772_055_531, 0)).toBe("8 GiB"); + }); + + it("never puts decimals on whole bytes", () => { + expect(formatBytes(500, 2)).toBe("500 B"); + }); +}); + +describe("safeImageUrl", () => { + const real = + "https://ar-gcp-cdn.bistudio.com/image/b64d/02af30860106e7097a8238852468fb85d249062ae56b43b1d6c34e70c20f/45316.jpg"; + + it("accepts the CDN the API actually uses", () => { + // Verified across 300 servers: every image URL was https on this host. + expect(safeImageUrl(real)).toBe(real); + }); + + it("treats an absent image as absent, not an error", () => { + // About half of servers publish no imagery at all. + expect(safeImageUrl(null)).toBeNull(); + expect(safeImageUrl(undefined)).toBeNull(); + expect(safeImageUrl("")).toBeNull(); + }); + + it("refuses non-https schemes", () => { + expect(safeImageUrl("http://ar-gcp-cdn.bistudio.com/a.jpg")).toBeNull(); + expect(safeImageUrl("data:image/png;base64,iVBORw0KGgo=")).toBeNull(); + expect(safeImageUrl("javascript:alert(1)")).toBeNull(); + expect(safeImageUrl("file:///etc/passwd")).toBeNull(); + }); + + it("refuses lookalike and subdomain-suffix hosts", () => { + for (const url of [ + "https://evil.test/a.jpg", + "https://ar-gcp-cdn.bistudio.com.attacker.test/a.jpg", + "https://notar-gcp-cdn.bistudio.com/a.jpg", + "https://sub.ar-gcp-cdn.bistudio.com/a.jpg", + "https://ar-gcp-cdn.bistudio.com@evil.test/a.jpg", + ]) { + expect(safeImageUrl(url), url).toBeNull(); + } + }); + + it("refuses unparseable input", () => { + expect(safeImageUrl("not a url")).toBeNull(); + expect(safeImageUrl("//ar-gcp-cdn.bistudio.com/a.jpg")).toBeNull(); + }); +}); + + +describe("discordInviteFrom", () => { + it("finds invites in real server names", () => { + for (const [name, code] of [ + ["[OGR2] Old Guard Revival | discord.gg/oldguardarma", "oldguardarma"], + ["[AU] FTA #2 | From The Ashes | discord.gg/from-the-ashes", "from-the-ashes"], + ["EXD.gg 1PP | https://discord.gg/AbC_123", "AbC_123"], + ["Server | https://www.discord.com/invite/xyz789", "xyz789"], + ["Server | discordapp.com/invite/legacy1", "legacy1"], + ] as const) { + expect(discordInviteFrom(name)?.code, name).toBe(code); + } + }); + + it("rebuilds the URL from the code rather than trusting the text", () => { + const found = discordInviteFrom("Join us at discord.gg/realcode today"); + expect(found?.code).toBe("realcode"); + expect(found?.url).toBe("https://discord.gg/realcode"); + }); + + it("refuses a code followed by characters that could redirect it", () => { + // `discord.gg/code@evil.test` is not an invite; the trailing-boundary rule + // rejects it outright rather than extracting a code from it. + expect(discordInviteFrom("discord.gg/realcode@evil.test/path")).toBeNull(); + expect(discordInviteFrom("discord.gg/realcode%2Fevil")).toBeNull(); + }); + + it("returns nothing when there is no invite", () => { + for (const name of [ + "", + "[EU1] Plain Server Name", + "discord.gg/", + "discord.gg/x", + "https://example.com/invite/abcdef", + ]) { + expect(discordInviteFrom(name), name).toBeNull(); + } + expect(discordInviteFrom(null)).toBeNull(); + expect(discordInviteFrom(undefined)).toBeNull(); + }); + + it("matches the site's regex, including its lack of a left boundary", () => { + // Ported verbatim from reforgermods.net so the button appears on exactly + // the same servers. A lookalike host is a known false positive upstream; + // it is harmless here because the URL is rebuilt from the code alone. + expect(discordInviteFrom("not-discord.gg/abcdef")?.url).toBe( + "https://discord.gg/abcdef", + ); + }); + + it("stops the code at a trailing separator", () => { + expect(discordInviteFrom("come to discord.gg/abc123, we play daily")?.code).toBe("abc123"); + expect(discordInviteFrom("discord.gg/abc123.")?.code).toBe("abc123"); + expect(discordInviteFrom("(discord.gg/abc123)")?.code).toBe("abc123"); + }); +}); diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..51ec275 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,199 @@ +/** + * Pure display helpers. + * + * Kept free of DOM and Tauri imports so the formatting rules the UI depends on + * can be unit-tested directly. + */ + +import type { ReadinessReport } from "./types"; + +/** + * Formats a byte count for a compact desktop layout. + * + * `digits` overrides the default precision. Table cells want one decimal so + * columns stay narrow; a download figure the user is about to commit to wants + * two, because 0.01 GiB is still 10 MB. + */ +export function formatBytes(bytes: number, digits?: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + // Whole bytes read oddly with a decimal, whatever the caller asked for. + const decimals = unit === 0 ? 0 : (digits ?? (value >= 100 ? 0 : 1)); + return `${value.toFixed(decimals)} ${units[unit]}`; +} + +/** + * Formats an age in seconds as a short relative label, e.g. `18s ago`. + * + * Returns `just now` under a second so the header never flashes `0s ago`. + */ +export function formatAge(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "unknown"; + const s = Math.floor(seconds); + if (s < 1) return "just now"; + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +/** Formats a unix-seconds timestamp as an age relative to `now`. */ +export function formatTimestamp(unixSeconds: number, now = Date.now()): string { + return formatAge(now / 1000 - unixSeconds); +} + +/** + * Turns a ping-site id into a readable label when the API has not supplied one, + * e.g. `new_york` -> `New York`. + */ +export function humaniseRegion(id: string | null | undefined): string { + if (!id) return "—"; + return id + .split(/[_-]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +/** `12 / 128`, with the queue appended when one exists. */ +export function formatPlayers(players: number, max: number): string { + return `${players} / ${max}`; +} + + + +/** + * Collapses whitespace in untrusted server names so a name padded with newlines + * cannot break the table layout. This is a layout measure, not a security one: + * the value is still rendered as text content, never as markup. + */ +export function tidyName(name: string): string { + return name.replace(/\s+/g, " ").trim(); +} + +/** + * The one-line mod readiness summary. + * + * Deliberately pure and directly tested, because this is the copy that has to + * stay honest: + * + * - "at least" appears whenever any required mod's size is unresolved, so a + * floor is never presented as a total. + * - An unavailable scan reports that it is unavailable. It never falls through + * to a reassuring message. + * - The closing clause states what actually happens — the game downloads the + * missing content during the join — because the launcher cannot do it. There + * is no "prepare mods" action to imply. + */ +export function readinessSummary(report: ReadinessReport): string { + if (!report.available) { + return report.message ?? "Installed mods could not be checked."; + } + if (report.required === 0) { + return "No mods required."; + } + if (report.missing === 0 && report.outdated === 0) { + return `All ${report.required} mods installed and up to date.`; + } + + let counts: string; + if (report.missing > 0 && report.outdated > 0) { + counts = `${report.missing} missing and ${report.outdated} out of date of ${report.required} mods`; + } else if (report.missing > 0) { + counts = `${report.missing} of ${report.required} mods missing`; + } else { + counts = `${report.outdated} of ${report.required} mods out of date`; + } + + if (report.missing > 0) { + if (report.missingBytes > 0) { + const size = formatBytes(report.missingBytes, 2); + // "at least" whenever a required mod's size is unresolved, so a floor is + // never presented as a total. + counts += + report.unresolvedMods > 0 + ? `, at least ${size} to download` + : `, ${size} to download`; + } else if (report.unresolvedMods > 0) { + counts += ", download size unknown"; + } + } + + const actor = + report.missing > 0 + ? "Arma Reforger downloads these when you join." + : "Arma Reforger updates these when you join."; + return `${counts}. ${actor}`; +} + +/** Severity for the readiness line, driving its colour only. */ +export function readinessTone(report: ReadinessReport): "unknown" | "ready" | "action" { + if (!report.available) return "unknown"; + return report.missing === 0 && report.outdated === 0 ? "ready" : "action"; +} + +/** + * The only host the API serves scenario imagery from. + * + * Verified across 300 servers: every image URL was `https` on this exact host, + * and about half of servers publish no image at all. + */ +const IMAGE_HOST = "ar-gcp-cdn.bistudio.com"; + +/** + * Returns `url` only if it is safe to put in an `img` tag. + * + * Image URLs arrive from the API and are therefore untrusted. This is the first + * of two layers: the page's CSP `img-src` is the enforcing one and would block + * anything else regardless, but refusing here means the launcher never even + * attempts a request to a host it did not expect. + * + * Anything that is not `https` on the known CDN host — including `data:`, + * `javascript:`, a redirect through another origin, or a lookalike domain — + * returns `null`, which renders as no image. + */ +export function safeImageUrl(url: string | null | undefined): string | null { + if (!url) return null; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== "https:") return null; + // Exact host match: `evil-ar-gcp-cdn.bistudio.com.attacker.test` must not pass. + if (parsed.hostname !== IMAGE_HOST) return null; + return parsed.toString(); +} + + +/** + * Finds a Discord invite in untrusted server text. + * + * The pattern is ported verbatim from reforgermods.net's own + * `discordInviteFromText`, so a server showing a Join Discord button on the site + * shows one here too. + * + * Only the invite **code** is taken from the text; the URL is rebuilt from it. + * Nothing a server operator writes in their name can steer where the button + * goes — a name containing `discord.gg/x@evil.test` yields a link to `x`. + */ +export function discordInviteFrom(text: string | null | undefined): { + code: string; + url: string; +} | null { + const match = String(text ?? "").match( + /(?:https?:\/\/)?(?:www\.)?(?:discord\.gg|discord(?:app)?\.com\/invite)\/([A-Za-z0-9_-]{2,64})(?=$|[\s)\]}<>"'|,;:?!./])/i, + ); + const code = match?.[1]; + if (!code) return null; + return { code, url: `https://discord.gg/${encodeURIComponent(code)}` }; +} diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts new file mode 100644 index 0000000..d459f9f --- /dev/null +++ b/src/lib/ipc.ts @@ -0,0 +1,129 @@ +/** + * Typed wrappers around the Tauri command surface. + * + * Components call these, never `invoke` directly, so every command name and + * argument shape is declared once. + */ + +import { invoke } from "@tauri-apps/api/core"; +import type { + FleetNow, + HistoryRange, + InstallIdentity, + LauncherStatus, + LocalStateDto, + PingResult, + PingSite, + ReadinessReport, + ServerDetail, + ServerHistory, + ServerSummary, + ServerListResult, + ServerQuery, + Settings, +} from "./types"; + +export function listServers(query: ServerQuery): Promise { + return invoke("list_servers", { query }); +} + +export function getServer(id: string): Promise { + return invoke("get_server", { id }); +} + +/** Resolves server ids to current summaries, dropping any that no longer exist. */ +export function getServers(ids: string[]): Promise { + return invoke("get_servers", { ids }); +} + +export function getServerHistory(id: string, range: HistoryRange): Promise { + return invoke("get_server_history", { id, range }); +} + +/** + * Compares the server's required mods against the local addon store. + * + * Reporting only — there is no command that acts on the result. + */ +export function getModReadiness(id: string): Promise { + return invoke("get_mod_readiness", { id }); +} + +/** + * Measures latency to a server by ICMP echo. + * + * Resolves with a result that states why there is no figure when the host does + * not answer; it does not reject for an unreachable server. + */ +export function pingServer(id: string): Promise { + return invoke("ping_server", { id }); +} + +/** + * Latency to every ping site, keyed by site id. + * + * Eight probes cover an entire page, because `/v2/servers` publishes + * `pingSiteId` per row but no per-server address. + */ +export function pingRegions(): Promise> { + return invoke("ping_regions"); +} + +/** Players online across the whole fleet. */ +export function getFleetNow(): Promise { + return invoke("get_fleet_now"); +} + +export function listPingSites(): Promise { + return invoke("list_ping_sites"); +} + +export function getStatus(): Promise { + return invoke("get_status"); +} + +/** Hands a validated join URI to Steam. Resolves with the URI that was opened. */ +export function joinServer(id: string, name: string): Promise { + return invoke("join_server", { id, name }); +} + +export function launchGame(): Promise { + return invoke("launch_game"); +} + +export function openServerPage(id: string): Promise { + return invoke("open_server_page", { id }); +} + +/** Opens a Discord invite by code. The URL is rebuilt in Rust. */ +export function openDiscordInvite(code: string): Promise { + return invoke("open_discord_invite", { code }); +} + +export function getLocalState(): Promise { + return invoke("get_local_state"); +} + +/** Returns the new favorite state for `id`. */ +export function toggleFavorite(id: string): Promise { + return invoke("toggle_favorite", { id }); +} + +/** Returns the settings as stored, after clamping. */ +export function updateSettings(settings: Settings): Promise { + return invoke("update_settings", { settings }); +} + +export function clearHistory(): Promise { + return invoke("clear_history"); +} + +/** True when the launcher was started with RFM_DEBUG set. */ +export function isDebugMode(): Promise { + return invoke("is_debug_mode"); +} + +/** Install identity, for debug mode. Empty outside it. */ +export function getInstallIdentity(): Promise { + return invoke("get_install_identity"); +} diff --git a/src/lib/sort.test.ts b/src/lib/sort.test.ts new file mode 100644 index 0000000..307fc1b --- /dev/null +++ b/src/lib/sort.test.ts @@ -0,0 +1,218 @@ +/** + * Table ordering. + * + * The cases that matter here are the ones where a plausible implementation is + * quietly wrong: unmeasured latency presented as fast, a direction inherited + * from the previous column, or equal values reshuffling on every refresh. + */ + +import { describe, expect, it } from "vitest"; +import { + API_SORTABLE, + isDatasetWide, + nextSort, + sortRows, + type PingLookup, + type TableSort, +} from "./sort"; +import type { ServerSummary } from "./types"; + +function server(overrides: Partial & { id: string }): ServerSummary { + return { + name: `server ${overrides.id}`, + scenarioId: "{ABC}Missions/Conflict.conf", + scenarioName: "Conflict", + scenarioThumbnailUrl: null, + gameVersion: "1.8.0.13", + official: false, + passwordProtected: false, + battlEye: true, + crossPlay: true, + players: 0, + maxPlayers: 128, + queue: 0, + queueMax: 25, + pingSiteId: null, + region: "new_york", + platform: "LINUX", + platforms: ["pc"], + modCount: 0, + online: true, + firstSeen: null, + lastSeen: null, + analytics: null, + joinUrl: null, + ...overrides, + }; +} + +/** No region was ever measured. */ +const noPings: PingLookup = () => null; + +const pings: PingLookup = (id) => + ({ frankfurt: 22, new_york: 95, sydney: 280 })[id ?? ""] ?? null; + +const ids = (rows: ServerSummary[]) => rows.map((r) => r.id); + +describe("nextSort", () => { + it("starts a column at the direction that answers the obvious question", () => { + // Busiest, most modded and lowest ping first; names from A. + expect(nextSort(null, "players")).toEqual({ column: "players", direction: "desc" }); + expect(nextSort(null, "mods")).toEqual({ column: "mods", direction: "desc" }); + expect(nextSort(null, "queue")).toEqual({ column: "queue", direction: "desc" }); + expect(nextSort(null, "ping")).toEqual({ column: "ping", direction: "asc" }); + expect(nextSort(null, "name")).toEqual({ column: "name", direction: "asc" }); + expect(nextSort(null, "region")).toEqual({ column: "region", direction: "asc" }); + }); + + it("reverses the column already sorted", () => { + const current: TableSort = { column: "players", direction: "desc" }; + expect(nextSort(current, "players")).toEqual({ column: "players", direction: "asc" }); + }); + + it("does not carry a direction across to a different column", () => { + // Otherwise clicking players (high–low) then name would sort names Z–A. + const current: TableSort = { column: "players", direction: "desc" }; + expect(nextSort(current, "name")).toEqual({ column: "name", direction: "asc" }); + }); +}); + +describe("isDatasetWide", () => { + it("is true only for the orderings the API itself serves", () => { + expect(isDatasetWide({ column: "players", direction: "desc" })).toBe(true); + expect(isDatasetWide({ column: "name", direction: "asc" })).toBe(true); + }); + + it("is false in the reverse direction, which the API has no parameter for", () => { + expect(isDatasetWide({ column: "players", direction: "asc" })).toBe(false); + expect(isDatasetWide({ column: "name", direction: "desc" })).toBe(false); + }); + + it("is false for every column the API cannot sort by", () => { + for (const column of ["queue", "ping", "mods", "region"] as const) { + expect(isDatasetWide({ column, direction: "desc" })).toBe(false); + expect(isDatasetWide({ column, direction: "asc" })).toBe(false); + expect(API_SORTABLE[column]).toBeUndefined(); + } + }); +}); + +describe("sortRows", () => { + it("returns the rows untouched when nothing is sorted", () => { + const rows = [server({ id: "a" }), server({ id: "b" })]; + // Identity, not just equality: the browse view re-renders on a new array. + expect(sortRows(rows, null, noPings)).toBe(rows); + }); + + it("does not mutate the array it was given", () => { + const rows = [server({ id: "a", players: 1 }), server({ id: "b", players: 9 })]; + sortRows(rows, { column: "players", direction: "desc" }, noPings); + expect(ids(rows)).toEqual(["a", "b"]); + }); + + it("orders by players in both directions", () => { + const rows = [ + server({ id: "mid", players: 40 }), + server({ id: "high", players: 120 }), + server({ id: "low", players: 3 }), + ]; + expect(ids(sortRows(rows, { column: "players", direction: "desc" }, noPings))).toEqual([ + "high", + "mid", + "low", + ]); + expect(ids(sortRows(rows, { column: "players", direction: "asc" }, noPings))).toEqual([ + "low", + "mid", + "high", + ]); + }); + + it("orders by name on the tidied text, as shown", () => { + const rows = [ + server({ id: "b", name: " [EU] Bravo " }), + server({ id: "a", name: "[EU] Alpha" }), + ]; + expect(ids(sortRows(rows, { column: "name", direction: "asc" }, noPings))).toEqual([ + "a", + "b", + ]); + }); + + it("orders by queue and mod count", () => { + const rows = [ + server({ id: "few", queue: 2, modCount: 5 }), + server({ id: "many", queue: 40, modCount: 190 }), + ]; + expect(ids(sortRows(rows, { column: "queue", direction: "desc" }, noPings))).toEqual([ + "many", + "few", + ]); + expect(ids(sortRows(rows, { column: "mods", direction: "desc" }, noPings))).toEqual([ + "many", + "few", + ]); + }); + + it("orders regions by the label on screen, not the raw id", () => { + // "new_york" renders as "New York", which belongs under N — before Sydney. + const rows = [ + server({ id: "s", pingSiteId: "sydney" }), + server({ id: "n", pingSiteId: "new_york" }), + server({ id: "f", pingSiteId: "frankfurt" }), + ]; + expect(ids(sortRows(rows, { column: "region", direction: "asc" }, pings))).toEqual([ + "f", + "n", + "s", + ]); + }); + + it("orders by measured latency", () => { + const rows = [ + server({ id: "far", pingSiteId: "sydney" }), + server({ id: "near", pingSiteId: "frankfurt" }), + server({ id: "mid", pingSiteId: "new_york" }), + ]; + expect(ids(sortRows(rows, { column: "ping", direction: "asc" }, pings))).toEqual([ + "near", + "mid", + "far", + ]); + }); + + it("keeps unmeasured regions last in both directions", () => { + // The case that must not regress: an unknown latency shown as the fastest + // thing on the page would be actively misleading. + const rows = [ + server({ id: "unknown", pingSiteId: null }), + server({ id: "far", pingSiteId: "sydney" }), + server({ id: "near", pingSiteId: "frankfurt" }), + ]; + expect(ids(sortRows(rows, { column: "ping", direction: "asc" }, pings))).toEqual([ + "near", + "far", + "unknown", + ]); + expect(ids(sortRows(rows, { column: "ping", direction: "desc" }, pings))).toEqual([ + "far", + "near", + "unknown", + ]); + }); + + it("settles ties the same way every time", () => { + // Every vanilla server has zero mods; without a tie-break they would + // reshuffle on each refresh, because the source array is rebuilt per request. + const rows = [ + server({ id: "quiet", name: "Zulu", players: 2 }), + server({ id: "busy", name: "Alpha", players: 60 }), + server({ id: "alsoBusy", name: "Bravo", players: 60 }), + ]; + const sort: TableSort = { column: "mods", direction: "desc" }; + const once = ids(sortRows(rows, sort, noPings)); + const twice = ids(sortRows([...rows].reverse(), sort, noPings)); + expect(once).toEqual(["busy", "alsoBusy", "quiet"]); + expect(twice).toEqual(once); + }); +}); diff --git a/src/lib/sort.ts b/src/lib/sort.ts new file mode 100644 index 0000000..49014b9 --- /dev/null +++ b/src/lib/sort.ts @@ -0,0 +1,161 @@ +/** + * Table ordering. + * + * Two kinds of sort exist here, and the difference is visible to the user: + * + * - **Dataset-wide.** `/v2/servers` orders every matching server before + * paginating, so the first page really is the top of the list. It accepts + * `players`, `name`, `newest` and `lastSeen`, and no direction parameter. + * - **Page-local.** Everything else — queue, ping, mod count, region, and the + * reverse of the two directions the API does offer — can only reorder the + * hundred rows already loaded. Sorting by queue does *not* find the most + * queued servers in the fleet; it reorders the page the API chose. + * + * The distinction is kept in the types rather than in a comment in the UI, + * because presenting the second as though it were the first is the kind of + * quiet lie this codebase avoids elsewhere (see the "Filters the loaded page" + * label on minimum players). + * + * These functions are pure and take latency as a lookup, so they can be tested + * without the signal graph or a Tauri host. + */ + +import { humaniseRegion, tidyName } from "./format"; +import type { ServerSort, ServerSummary } from "./types"; + +/** A column the table can be ordered by. */ +export type SortColumn = "name" | "players" | "queue" | "ping" | "mods" | "region"; + +export type SortDirection = "asc" | "desc"; + +export interface TableSort { + column: SortColumn; + direction: SortDirection; +} + +/** Resolves a region's median latency, or null when it was never measured. */ +export type PingLookup = (pingSiteId: string | null) => number | null; + +/** + * The direction a column takes on its first click. + * + * Chosen so one click gives the answer people actually want: the busiest + * servers, the lowest ping, the most mods — but names from A. + */ +export const NATURAL_DIRECTION: Record = { + name: "asc", + players: "desc", + queue: "desc", + ping: "asc", + mods: "desc", + region: "asc", +}; + +/** + * Columns the API can order the whole dataset by, with the direction it returns. + * + * There is no direction parameter, so only these exact pairings are + * dataset-wide; anything else falls back to reordering the loaded page. + */ +export const API_SORTABLE: Partial< + Record +> = { + name: { sort: "name", direction: "asc" }, + players: { sort: "players", direction: "desc" }, +}; + +/** Column headings, also used in the note naming the active page-local sort. */ +export const COLUMN_LABEL: Record = { + name: "server", + players: "players", + queue: "queue", + ping: "ping", + mods: "mods", + region: "region", +}; + +/** + * The sort a click on `column` produces. + * + * Clicking the active column reverses it; clicking any other starts at that + * column's natural direction rather than inheriting the previous one, which + * would otherwise sort names Z–A just because players had been sorted high–low. + */ +export function nextSort(current: TableSort | null, column: SortColumn): TableSort { + if (current?.column === column) { + return { column, direction: current.direction === "asc" ? "desc" : "asc" }; + } + return { column, direction: NATURAL_DIRECTION[column] }; +} + +/** True when the API can serve this ordering across the whole dataset. */ +export function isDatasetWide(sort: TableSort): boolean { + const api = API_SORTABLE[sort.column]; + return api !== undefined && api.direction === sort.direction; +} + +/** + * Orders `rows` by `sort`. + * + * Returns the input untouched when there is nothing to do, so the browse view + * keeps the API's ordering — and its array identity — rather than paying for a + * copy and a re-render on every refresh. + */ +export function sortRows( + rows: ServerSummary[], + sort: TableSort | null, + pingOf: PingLookup, +): ServerSummary[] { + if (!sort) return rows; + const direction = sort.direction === "asc" ? 1 : -1; + + return [...rows].sort((a, b) => { + if (sort.column === "ping") { + const pa = pingOf(a.pingSiteId); + const pb = pingOf(b.pingSiteId); + // Unmeasured regions sort last in *both* directions. Flipping them with + // the rest would present "never measured" as the fastest thing on the + // page, which is the one reading that is actively misleading. + if (pa === null && pb === null) return tieBreak(a, b); + if (pa === null) return 1; + if (pb === null) return -1; + return (pa - pb) * direction || tieBreak(a, b); + } + return compareAscending(a, b, sort.column) * direction || tieBreak(a, b); + }); +} + +/** Compares two rows on `column`, always ascending. */ +function compareAscending( + a: ServerSummary, + b: ServerSummary, + column: Exclude, +): number { + switch (column) { + case "name": + return tidyName(a.name).localeCompare(tidyName(b.name)); + case "players": + return a.players - b.players; + case "queue": + return a.queue - b.queue; + case "mods": + return a.modCount - b.modCount; + case "region": + // Sorted by the label shown, not the raw id, so the order matches what is + // on screen — "New York" sits under N, not under "new_york". + return humaniseRegion(a.pingSiteId ?? a.region).localeCompare( + humaniseRegion(b.pingSiteId ?? b.region), + ); + } +} + +/** + * Settles ties deterministically. + * + * Without this, equal values (every vanilla server has zero mods) would reorder + * themselves on each refresh, because `Array.prototype.sort` is only stable + * with respect to the array it was given and that array is rebuilt per request. + */ +function tieBreak(a: ServerSummary, b: ServerSummary): number { + return b.players - a.players || tidyName(a.name).localeCompare(tidyName(b.name)); +} diff --git a/src/lib/state.ts b/src/lib/state.ts new file mode 100644 index 0000000..3f023a4 --- /dev/null +++ b/src/lib/state.ts @@ -0,0 +1,586 @@ +/** + * Application state. + * + * Signals rather than a store framework: each component subscribes only to the + * signals it reads, so the one-second clock behind "updated 18s ago" re-renders + * the status pill and nothing else. The server table re-renders only when the + * server array itself changes. + */ + +import { computed, signal } from "@preact/signals"; +import * as ipc from "./ipc"; +import { + API_SORTABLE, + isDatasetWide, + nextSort, + sortRows, + type SortColumn, + type TableSort, +} from "./sort"; +import { + DEFAULT_QUERY, + isCommandError, + type CommandError, + type FleetNow, + type HistoryRange, + type InstallIdentity, + type PingResult, + type ReadinessReport, + type ServerHistory, + type LaunchRecord, + type LauncherStatus, + type PingSite, + type RecentServer, + type ServerDetail, + type ServerQuery, + type ServerSummary, + type Settings, +} from "./types"; + +export type View = "browse" | "favorites" | "recent" | "settings"; + +/** Debounce applied to the search field before a request is issued. */ +export const SEARCH_DEBOUNCE_MS = 250; +/** How often the header re-reads API status. */ +const STATUS_POLL_MS = 60_000; + +// ---------------------------------------------------------------- navigation + +export const view = signal("browse"); + +// -------------------------------------------------------------- server list + +export const query = signal({ ...DEFAULT_QUERY }); +/** The raw search field, before debouncing into `query`. */ +export const searchInput = signal(""); + +export const servers = signal([]); +export const listLoading = signal(false); +/** True only for the very first load, so refreshes do not blank the table. */ +export const listInitialising = signal(true); +export const listError = signal(null); +export const totalServers = signal(0); +export const totalPages = signal(0); +export const filteredOut = signal(0); +/** + * Latency per ping-site id, measured once and reused for every row. + * + * Region latency, not per-server: see `ping_regions` in the Rust command layer + * for why a per-row measurement is not affordable. + */ +export const regionPings = signal>({}); + +/** + * Client-side ordering applied on top of the API's sort. + * + * Null means the rows are shown in the order the API returned them. Anything + * else reorders only the rows already loaded — see `./sort` for why that + * distinction is kept rather than smoothed over. + */ +export const clientSort = signal(null); + +// ------------------------------------------------- favorites & recents lists + +/** Live rows for the favorites view, resolved from stored ids. */ +export const favoriteServers = signal([]); +/** Live rows for the recents view, resolved from stored ids. */ +export const recentServers = signal([]); +export const savedListLoading = signal(false); + +// ------------------------------------------------------------------ detail + +export const selectedId = signal(null); +export const selectedServer = signal(null); +export const detailLoading = signal(false); +export const detailError = signal(null); +/** Set while a join is in flight, so the button can show progress. */ +export const joining = signal(false); +export const joinError = signal(null); + +// ------------------------------------------------------- history & readiness + +export const historyRange = signal("oneDay"); +export const historySeries = signal(null); +export const historyLoading = signal(false); +/** Null while unknown; the report itself says whether a scan was possible. */ +export const readiness = signal(null); +/** Null while unmeasured; the result itself says whether there is a figure. */ +export const ping = signal(null); +export const pingLoading = signal(false); + +// ------------------------------------------------------------ local storage + +export const settings = signal(null); +/** True when started with RFM_DEBUG. Gates developer-facing detail. */ +export const debugMode = signal(false); +/** Install identity. Populated only in debug mode. */ +export const installIdentity = signal(null); +export const favorites = signal([]); +export const recents = signal([]); +export const history = signal([]); + +export const favoriteSet = computed(() => new Set(favorites.value)); + +/** Median latency for a server's region, or null when unmeasured. */ +export function pingFor(pingSiteId: string | null): number | null { + if (!pingSiteId) return null; + return regionPings.value[pingSiteId]?.summary?.medianMs ?? null; +} + +/** The browse rows, after any client-side ordering. */ +export const visibleServers = computed(() => + sortRows(servers.value, clientSort.value, pingFor), +); + +/** + * Favorites and recents, under the same ordering. + * + * These lists are resolved in full rather than paginated, so a sort here covers + * everything the view can show and carries no page-local caveat. + */ +export const visibleFavorites = computed(() => + sortRows(favoriteServers.value, clientSort.value, pingFor), +); +export const visibleRecents = computed(() => + sortRows(recentServers.value, clientSort.value, pingFor), +); + +/** + * The ordering currently in effect, for the header indicators. + * + * With no client sort, this reports the API's own ordering so the column the + * server sorted by is still marked — `newest` and `lastSeen` have no column and + * mark nothing. + */ +export const activeSort = computed(() => { + if (clientSort.value) return clientSort.value; + switch (query.value.sort) { + case "players": + return { column: "players", direction: "desc" }; + case "name": + return { column: "name", direction: "asc" }; + default: + return null; + } +}); + +/** + * True when the active ordering reaches only the loaded page. + * + * A client sort that happens to match what the API already did is not + * page-local: the rows are the dataset's top rows either way. + */ +export const sortIsPageLocal = computed(() => { + const sort = clientSort.value; + if (!sort) return false; + const api = API_SORTABLE[sort.column]; + return !(api && api.direction === sort.direction && query.value.sort === api.sort); +}); + +/** + * Applies a header click. + * + * `datasetWide` is false for the favorites and recents tables, where there is + * no API query behind the rows and every sort is therefore local to the list + * being shown — and complete, because the list is not paginated. + */ +export function sortByColumn(column: SortColumn, datasetWide = true): void { + const next = nextSort(activeSort.value, column); + if (datasetWide && isDatasetWide(next)) { + // The API can order the whole dataset this way, so ask it to and drop the + // local reordering entirely. + clientSort.value = null; + setQuery({ sort: API_SORTABLE[next.column]!.sort }); + return; + } + clientSort.value = next; +} + +// ------------------------------------------------------------------ status + +export const status = signal(null); +export const pingSites = signal([]); +/** Fleet-wide population, shown in the title bar. */ +export const fleet = signal(null); +/** Ticks once a second purely so relative timestamps stay live. */ +export const nowMs = signal(Date.now()); + +// ---------------------------------------------------------------- internals + +/** Guards against a slow response overwriting a newer one. */ +let listRequestSeq = 0; +let detailRequestSeq = 0; +let historyRequestSeq = 0; +let readinessRequestSeq = 0; +let pingRequestSeq = 0; +let savedListSeq = 0; +let searchTimer: ReturnType | undefined; + +function toCommandError(err: unknown): CommandError { + if (isCommandError(err)) return err; + return { kind: "unknown", message: String(err) }; +} + +// ----------------------------------------------------------------- actions + +/** Replaces the query, resetting to page 1 unless the page itself changed. */ +export function setQuery(patch: Partial): void { + const next = { ...query.value, ...patch }; + if (patch.page === undefined) next.page = 1; + query.value = next; + void loadServers(); +} + +/** Updates the search box and schedules a debounced reload. */ +export function setSearch(value: string): void { + searchInput.value = value; + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + if (searchInput.value !== query.value.search) { + setQuery({ search: searchInput.value }); + } + }, SEARCH_DEBOUNCE_MS); +} + +/** Clears every filter, keeping sort and page size. */ +export function clearFilters(): void { + searchInput.value = ""; + clearTimeout(searchTimer); + setQuery({ + search: "", + region: null, + platform: null, + official: null, + hasMods: null, + battleye: null, + locked: null, + includeOffline: false, + minPlayers: 0, + }); +} + +export async function loadServers(): Promise { + const seq = ++listRequestSeq; + listLoading.value = true; + try { + const result = await ipc.listServers(query.value); + if (seq !== listRequestSeq) return; // Superseded by a newer request. + servers.value = result.servers; + totalServers.value = result.totalServers; + totalPages.value = result.totalPages; + filteredOut.value = result.filteredOut; + listError.value = null; + if (status.value) { + // Every server response carries snapshot freshness, so the header stays + // current without a status request of its own. + status.value = { ...status.value, dataset: result.dataset }; + } + } catch (err) { + if (seq !== listRequestSeq) return; + listError.value = toCommandError(err); + } finally { + if (seq === listRequestSeq) { + listLoading.value = false; + listInitialising.value = false; + } + } +} + +export async function selectServer(id: string | null): Promise { + selectedId.value = id; + joinError.value = null; + if (!id) { + selectedServer.value = null; + historySeries.value = null; + readiness.value = null; + ping.value = null; + return; + } + const seq = ++detailRequestSeq; + detailLoading.value = true; + detailError.value = null; + // Show what the list already knows while the detail request is in flight. + const known = servers.value.find((s) => s.id === id); + if (known && selectedServer.value?.id !== id) { + selectedServer.value = null; + } + try { + const detail = await ipc.getServer(id); + if (seq !== detailRequestSeq) return; + selectedServer.value = detail; + // Both are secondary to the panel and must not delay it, so they are not + // awaited. Readiness reuses the detail response the client just cached, so + // it costs a directory scan rather than a request. + void loadHistory(id); + void loadReadiness(id); + void measurePing(id); + // The backend recorded the visit; mirror it locally without a round trip. + recents.value = [ + { id: detail.id, name: detail.name, lastSeenAt: Math.floor(Date.now() / 1000) }, + ...recents.value.filter((r) => r.id !== detail.id), + ].slice(0, 30); + } catch (err) { + if (seq !== detailRequestSeq) return; + detailError.value = toCommandError(err); + } finally { + if (seq === detailRequestSeq) detailLoading.value = false; + } +} + +/** Loads population history for `id` at the current range. */ +export async function loadHistory(id: string): Promise { + const seq = ++historyRequestSeq; + historyLoading.value = true; + try { + const series = await ipc.getServerHistory(id, historyRange.value); + if (seq !== historyRequestSeq || selectedId.value !== id) return; + historySeries.value = series; + } catch { + if (seq !== historyRequestSeq) return; + // The chart is supplementary; a failure renders as "no history", not an error. + historySeries.value = null; + } finally { + if (seq === historyRequestSeq) historyLoading.value = false; + } +} + +/** Switches the history range and reloads for the selected server. */ +export function setHistoryRange(range: HistoryRange): void { + if (historyRange.value === range) return; + historyRange.value = range; + historySeries.value = null; + const id = selectedId.value; + if (id) void loadHistory(id); +} + +/** + * Loads the mod readiness report for `id`. + * + * Reporting only: nothing in the launcher acts on the result. + */ +export async function loadReadiness(id: string): Promise { + const seq = ++readinessRequestSeq; + try { + const report = await ipc.getModReadiness(id); + if (seq !== readinessRequestSeq || selectedId.value !== id) return; + readiness.value = report; + } catch { + if (seq !== readinessRequestSeq) return; + // Leaving this null renders nothing, which is correct: an unknown state + // must never be shown as a reassuring one. + readiness.value = null; + } +} + +/** + * Measures latency to the selected server. + * + * Only ever for the selected server: measuring a whole page would emit a + * hundred ICMP probes on every refresh, which is both rude and indistinguishable + * from a scan. + */ +export async function measurePing(id: string): Promise { + const seq = ++pingRequestSeq; + pingLoading.value = true; + ping.value = null; + try { + const result = await ipc.pingServer(id); + if (seq !== pingRequestSeq || selectedId.value !== id) return; + ping.value = result; + } catch { + if (seq !== pingRequestSeq) return; + ping.value = null; + } finally { + if (seq === pingRequestSeq) pingLoading.value = false; + } +} + +/** + * Resolves the favorites or recents list into live server rows. + * + * Those views hold ids, so a table needs one detail request per row. Both lists + * are small and the client caches responses, so revisiting a tab is usually + * free; ids that no longer resolve are dropped by the backend. + */ +export async function loadSavedList(which: "favorites" | "recent"): Promise { + const ids = which === "favorites" ? favorites.value : recents.value.map((r) => r.id); + const target = which === "favorites" ? favoriteServers : recentServers; + if (ids.length === 0) { + target.value = []; + return; + } + const seq = ++savedListSeq; + savedListLoading.value = true; + try { + const resolved = await ipc.getServers(ids); + if (seq !== savedListSeq) return; + target.value = resolved; + } catch { + if (seq !== savedListSeq) return; + // Leave whatever was shown; the stored list is still intact. + } finally { + if (seq === savedListSeq) savedListLoading.value = false; + } +} + +/** Switches view, resolving the saved lists on entry. */ +export function setView(next: View): void { + view.value = next; + if (next === "favorites" || next === "recent") { + void loadSavedList(next); + } +} + +export async function toggleFavorite(id: string): Promise { + try { + const isFavorite = await ipc.toggleFavorite(id); + favorites.value = isFavorite + ? [...favorites.value, id] + : favorites.value.filter((f) => f !== id); + if (!isFavorite && view.value === "favorites") { + favoriteServers.value = favoriteServers.value.filter((s) => s.id !== id); + } + } catch { + // A failed toggle leaves the previous state visible, which is correct: + // nothing was persisted. + } +} + +export async function join(server: { id: string; name: string }): Promise { + joining.value = true; + joinError.value = null; + try { + await ipc.joinServer(server.id, server.name); + history.value = [ + { + serverId: server.id, + serverName: server.name, + launchedAt: Math.floor(Date.now() / 1000), + succeeded: true, + }, + ...history.value, + ].slice(0, 200); + } catch (err) { + joinError.value = toCommandError(err); + } finally { + joining.value = false; + } +} + +export async function saveSettings(next: Settings): Promise { + const stored = await ipc.updateSettings(next); + settings.value = stored; + if (stored.perPage !== query.value.perPage) { + setQuery({ perPage: stored.perPage }); + } +} + + +/** Refreshes the fleet-wide population shown in the title bar. */ +export async function refreshFleet(): Promise { + try { + fleet.value = await ipc.getFleetNow(); + } catch { + // Decorative; the count simply stays as it was. + } +} + +export async function refreshStatus(): Promise { + try { + const next = await ipc.getStatus(); + // Prefer freshness already seen on a server response over a status probe + // that may not carry one. + status.value = { ...next, dataset: next.dataset ?? status.value?.dataset ?? null }; + } catch { + status.value = { + apiReachable: false, + dataset: status.value?.dataset ?? null, + reportedStatus: null, + note: null, + }; + } +} + +export async function clearLocalHistory(): Promise { + await ipc.clearHistory(); + recents.value = []; + history.value = []; +} + +let started = false; + +/** Loads persisted state, then the first page of servers. */ +export async function start(): Promise { + if (started) return; + started = true; + + try { + const local = await ipc.getLocalState(); + settings.value = local.settings; + favorites.value = local.favorites; + recents.value = local.recents; + history.value = local.history; + query.value = { ...query.value, perPage: local.settings.perPage }; + debugMode.value = await ipc.isDebugMode(); + if (debugMode.value) { + installIdentity.value = await ipc.getInstallIdentity(); + } + } catch { + // Defaults are already in place; a storage failure must not block startup. + } + + // The first server page is what the user is waiting for, so it goes first and + // the decorative requests follow. + await loadServers(); + + void refreshStatus(); + void refreshFleet(); + // Eight ICMP probes, reused for every row in every page from here on. + void ipc + .pingRegions() + .then((results) => { + regionPings.value = results; + }) + .catch(() => { + // The ping column simply stays empty; it is supplementary. + }); + void ipc + .listPingSites() + .then((sites) => { + pingSites.value = sites; + }) + .catch(() => { + // Region labels fall back to humanised ids. + }); + + setInterval(() => { + nowMs.value = Date.now(); + }, 1000); + setInterval(() => { + void refreshStatus(); + void refreshFleet(); + }, STATUS_POLL_MS); + + startAutoRefresh(); +} + +/** + * Optional periodic reload of the server list. + * + * Off by default and floored at 15s by the settings store, and it skips while + * the window is hidden so a backgrounded launcher makes no requests. + */ +function startAutoRefresh(): void { + let lastRefreshMs = Date.now(); + // A single slow timer that checks the configured interval, rather than one + // timer per settings change. + setInterval(() => { + const s = settings.value; + if (!s?.autoRefresh) return; + if (document.hidden) return; // A backgrounded window makes no requests. + if (view.value !== "browse") return; + if (Date.now() - lastRefreshMs < s.refreshIntervalSecs * 1000) return; + lastRefreshMs = Date.now(); + void loadServers(); + }, 5_000); +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..c9a032a --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,300 @@ +/** + * Mirrors of the Rust DTOs crossing the Tauri boundary. + * + * These are hand-written rather than generated: the surface is small, and + * keeping it hand-written means the field names the UI reads are visible in + * one place. `rfm-core` serialises with `rename_all = "camelCase"`, with one + * deliberate exception — `battlEye`, which matches the API's own spelling. + * + * Every string here is untrusted remote text. Render it as text content only; + * never build a URL or markup from it. + */ + +export interface DatasetMeta { + warming: boolean; + stale: boolean; + snapshotAgeSeconds: number; + lastCollectionAt: string | null; + serverCount: number; + onlineServerCount: number; + offlineServerCount: number; +} + +export interface ServerAnalytics { + rank: number | null; + overallScore: number | null; + activityScore: number | null; + reliabilityScore: number | null; + confidence: number | null; +} + +export interface ServerSummary { + id: string; + name: string; + scenarioId: string; + scenarioName: string; + scenarioThumbnailUrl: string | null; + gameVersion: string; + official: boolean; + passwordProtected: boolean; + /** Spelled as the API spells it. */ + battlEye: boolean; + crossPlay: boolean; + players: number; + maxPlayers: number; + queue: number; + queueMax: number; + pingSiteId: string | null; + region: string | null; + /** Host OS (`LINUX`/`WINDOWS`), not a client platform. */ + platform: string | null; + /** Client platforms that may join: `pc`, `xbox`, `psn`. */ + platforms: string[]; + modCount: number; + online: boolean; + firstSeen: string | null; + lastSeen: string | null; + analytics: ServerAnalytics | null; + /** The API's own join URI. Cross-checked in tests; never handed to the OS. */ + joinUrl: string | null; +} + +export interface ServerActivity { + peak24h: number; + avg24h: number; + median24h: number; + low24h: number; + peak7d: number; + samples24h: number; + trackingSince: string | null; +} + +export interface ServerModSummary { + count: number; + knownSize: number; + knownSizeText: string | null; + unresolvedCount: number; +} + +export interface ServerMod { + id: string; + name: string; + version: string; + size: number; + sizeText: string | null; + sizeKnown: boolean; + modUrl: string | null; +} + +/** `ServerDetail` flattens `ServerSummary`, so it carries every summary field. */ +export interface ServerDetail extends ServerSummary { + present: boolean; + hostType: string | null; + joinable: boolean; + /** Published address. Untrusted; only the backend ever acts on it. */ + host: string | null; + port: number; + /** Server-reported simulation FPS; 0 when not reported. */ + fps: number; + activity: ServerActivity | null; + modSummary: ServerModSummary; + mods: ServerMod[]; +} + +export interface PingSite { + id: string; + label: string; + regionGroup: string; + address: string | null; +} + +/** Fleet-wide population right now. */ +export interface FleetNow { + onlineServers: number; + players: number; + capacity: number; + queue: number; +} + +export interface LauncherStatus { + apiReachable: boolean; + dataset: DatasetMeta | null; + reportedStatus: string | null; + note: string | null; +} + +/** Ranges `/v2/servers/{id}/history` accepts. */ +export type HistoryRange = + | "sixHours" + | "oneDay" + | "threeDays" + | "oneWeek" + | "oneMonth" + | "threeMonths" + | "oneYear" + | "all"; + +/** The ranges the inspector offers, with their labels. */ +export const HISTORY_RANGES: ReadonlyArray<{ value: HistoryRange; label: string }> = [ + { value: "sixHours", label: "6h" }, + { value: "oneDay", label: "24h" }, + { value: "oneWeek", label: "7d" }, + { value: "oneMonth", label: "30d" }, +]; + +export interface ServerHistoryPoint { + /** Bucket start, unix seconds. */ + t: number; + avg: number; + min: number; + max: number; + /** Slot capacity during the bucket; 0 when not recorded. */ + cap: number; +} + +export interface ServerHistory { + serverId: string; + /** The range the API served, which may differ from what was asked. */ + range: string; + /** Bucket width the API chose: `5m`, `hour` or `day`. */ + bucket: string; + points: ServerHistoryPoint[]; +} + +/** Diagnostic view of the install identity. Populated only in debug mode. */ +export interface InstallIdentity { + id: string; + /** True when the identifier survives a reinstall. */ + machineDerived: boolean; +} + +/** Why a server could not be measured. */ +export type PingUnavailable = "no_host" | "unroutable_host" | "no_reply" | "not_permitted"; + +export interface PingSummary { + bestMs: number; + /** Preferred for display: one delayed packet should not move the figure. */ + medianMs: number; + received: number; + sent: number; +} + +/** A latency measurement, or a stated reason there is none. */ +export interface PingResult { + summary?: PingSummary; + unavailable?: PingUnavailable; + message?: string; +} + +/** Why a local mod scan could not produce an answer. */ +export type ReadinessUnavailable = "no_addons_directory" | "scan_failed"; + +/** + * Comparison of a server's required mods against the local addon store. + * + * Reporting only. `available: false` means the scan did not run, and every + * count is then zero and must not be rendered. + */ +export interface ReadinessReport { + available: boolean; + unavailable?: ReadinessUnavailable; + message?: string; + required: number; + upToDate: number; + outdated: number; + missing: number; + /** Floor, not a total, while `unresolvedMods` is non-zero. */ + missingBytes: number; + unresolvedMods: number; + installedTotal: number; +} + +export type ServerSort = "players" | "name" | "newest" | "lastSeen"; +export type ClientPlatform = "pc" | "xbox" | "psn"; + +export interface ServerQuery { + search: string; + sort: ServerSort; + region: string | null; + platform: ClientPlatform | null; + official: boolean | null; + hasMods: boolean | null; + battleye: boolean | null; + /** The API's name for password-protected. */ + locked: boolean | null; + includeOffline: boolean; + /** Applied client-side; the API has no equivalent parameter. */ + minPlayers: number; + page: number; + perPage: number; +} + +export interface ServerListResult { + servers: ServerSummary[]; + totalServers: number; + totalPages: number; + currentPage: number; + /** Rows the client-side filters removed from this page. */ + filteredOut: number; + dataset: DatasetMeta; +} + +export interface Settings { + perPage: number; + autoRefresh: boolean; + refreshIntervalSecs: number; + /** Publish activity to Discord Rich Presence. */ + discordPresence: boolean; +} + +export interface RecentServer { + id: string; + name: string; + lastSeenAt: number; +} + +export interface LaunchRecord { + serverId: string; + serverName: string; + launchedAt: number; + succeeded: boolean; +} + +export interface LocalStateDto { + settings: Settings; + favorites: string[]; + recents: RecentServer[]; + history: LaunchRecord[]; +} + +/** The shape every failing command rejects with. */ +export interface CommandError { + kind: string; + message: string; + retryAfterSecs?: number; +} + +/** Type guard for the error shape, since `catch` gives `unknown`. */ +export function isCommandError(value: unknown): value is CommandError { + return ( + typeof value === "object" && + value !== null && + typeof (value as CommandError).kind === "string" && + typeof (value as CommandError).message === "string" + ); +} + +export const DEFAULT_QUERY: ServerQuery = { + search: "", + sort: "players", + region: null, + platform: null, + official: null, + hasMods: null, + battleye: null, + locked: null, + includeOffline: false, + minPlayers: 0, + page: 1, + perPage: 100, +}; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..8db78cb --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import { render } from "preact"; +import { App } from "./app"; +import { start } from "./lib/state"; +import "./styles.css"; + +const root = document.getElementById("app"); +if (!root) throw new Error("missing #app root element"); + +render(, root); + +// Kick off data loading after the first paint, so the chrome is on screen +// immediately rather than waiting on the network. +void start(); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..b405b22 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,1319 @@ +/* + * reforgermods.net launcher chrome. + * + * A dense, dark-teal desktop utility: hairline borders, one restrained accent, + * compact type, no gradients or oversized cards. Sized in px on purpose — + * this is a fixed-scale desktop application, not a responsive web page, and + * high-DPI is handled by the WebView's device pixel ratio. + */ + +/* + * Palette and type are taken from reforgermods.net's own dark theme + * (`[data-bs-theme="dark"]` in the site's global stylesheet), so the launcher + * and the site read as one product: near-black blue-greys, a single olive + * accent, and the site's condensed type stack. + * + * Token names stay launcher-local; the comment on each records the site token + * it mirrors, so a change on the site can be traced here. + */ +:root { + --bg: #050d10; /* --site-bg */ + --bg-panel: #0d171b; /* --site-panel */ + --bg-raised: #152126; /* --site-panel-soft */ + --bg-sunken: #060f12; /* --site-inset */ + --bg-hover: #121c21; /* --site-card */ + --bg-active: #1a272d; /* --site-card-hover */ + --bg-band: #071114; /* --site-band */ + + --border: rgba(170, 185, 196, 0.15); /* --site-border */ + --border-muted: rgba(170, 185, 196, 0.09); /* --site-muted-border */ + --border-strong: rgba(170, 185, 196, 0.31); /* --site-strong-border */ + --border-input: #303f46; /* --site-input-border */ + + --text: #f4f4f1; /* --site-text-strong */ + --text-dim: #aeb8c2; /* --site-text-muted */ + --text-faint: #75828c; /* --site-text-faint */ + + /* The brand accent is olive, not teal. --accent-deep is the seated variant + used behind text, and is dark enough for #0b1116 to sit on it. */ + --accent: #8b9d63; /* --site-accent */ + --accent-strong: #a4b873; /* --site-accent-strong */ + --accent-deep: #6d7d4c; + --accent-bg: rgba(139, 157, 99, 0.13); /* --site-accent-dim */ + --on-accent: #0b1116; + + --good: #a4b873; + --warn: #c99044; /* --site-orange */ + --bad: #c2705a; /* --rm-fall */ + --info: #9aa7b7; /* --site-blue */ + + --row-h: 30px; + --nav-w: 172px; + --inspector-w: 348px; + + /* The site's condensed stack. It is a large part of the brand's look, and it + buys roughly 15% more characters per column in the server table. */ + --sans: "Arial Narrow", "Roboto Condensed", "Agency FB", "Segoe UI", system-ui, sans-serif; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 13px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; + overflow: hidden; + /* A desktop chrome, not a document: no accidental text selection on labels. */ + user-select: none; + cursor: default; +} + +button, +input, +select { + font: inherit; + color: inherit; +} + +/* Focus is always visible, and always the accent. */ +:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 1px; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: var(--bg-sunken); +} +::-webkit-scrollbar-thumb { + background: #1e2d34; + border: 2px solid var(--bg-sunken); + border-radius: 6px; +} +::-webkit-scrollbar-thumb:hover { + background: #2a3d45; +} + +/* ------------------------------------------------------------------ shell */ + +.shell { + display: grid; + grid-template-rows: 32px 1fr; + grid-template-columns: var(--nav-w) 1fr; + grid-template-areas: + "title title" + "nav main"; + height: 100%; +} + +/* One bar doing the work of the native caption and a brand strip. */ +.titlebar { + grid-area: title; + display: flex; + align-items: center; + gap: 10px; + padding-left: 10px; + background: var(--bg-band); + border-bottom: 1px solid var(--border); + /* Nothing in the caption should be selectable while dragging. */ + user-select: none; +} + +.titlebar__mark { + width: 16px; + height: 16px; + flex: none; + display: block; + object-fit: contain; +} + +.titlebar__name { + font-size: 12px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; +} +.titlebar__name em { + font-style: normal; + color: var(--text-dim); + font-weight: 500; +} + +.titlebar__context { + font-size: 11px; + color: var(--text-faint); + white-space: nowrap; + padding-left: 4px; + border-left: 1px solid var(--border); + margin-left: 2px; +} + +.titlebar__spacer { + flex: 1; + align-self: stretch; +} + +/* Caption buttons. Windows metrics: 46x32, close goes red on hover. */ +.wincontrols { + display: flex; + align-self: stretch; + margin-left: 8px; +} +.wincontrols__btn { + width: 46px; + border: 0; + background: none; + color: var(--text-dim); + display: grid; + place-items: center; + cursor: default; +} +.wincontrols__btn:hover { + background: var(--bg-hover); + color: var(--text); +} +.wincontrols__btn--close:hover { + background: #c42b1c; + color: #fff; +} + +.nav { + grid-area: nav; + display: flex; + flex-direction: column; + padding: 8px 6px; + gap: 1px; + background: var(--bg-panel); + border-right: 1px solid var(--border); + overflow-y: auto; +} + +.nav__label { + padding: 10px 8px 4px; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-faint); +} + +.nav__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 8px; + border: 0; + border-radius: 3px; + background: transparent; + color: var(--text-dim); + text-align: left; + cursor: pointer; + white-space: nowrap; +} +.nav__item:hover { + background: var(--bg-hover); + color: var(--text); +} +.nav__item[aria-current="true"] { + background: var(--bg-active); + color: var(--text); +} +.nav__count { + font-family: var(--mono); + font-size: 10.5px; + color: var(--text-faint); +} + +.nav__spacer { + flex: 1; +} + +.nav__foot { + padding: 8px; + font-size: 10.5px; + color: var(--text-faint); + border-top: 1px solid var(--border); + margin-top: 8px; +} + +.main { + grid-area: main; + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) var(--inspector-w); +} +.main--full { + grid-template-columns: minmax(0, 1fr); +} + +@media (max-width: 1120px) { + .main { + grid-template-columns: minmax(0, 1fr); + } + .inspector { + position: fixed; + top: 32px; + right: 0; + bottom: 0; + width: var(--inspector-w); + z-index: 20; + box-shadow: -12px 0 24px rgba(0, 0, 0, 0.45); + } +} + +/* ----------------------------------------------------------------- status */ + +.status { + display: flex; + align-items: center; + gap: 8px; + padding-right: 4px; + font-size: 11px; + color: var(--text-dim); + white-space: nowrap; +} + +.status__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-faint); + flex: none; +} +.status__dot--ok { + background: var(--good); +} +.status__dot--warn { + background: var(--warn); +} +.status__dot--bad { + background: var(--bad); +} + +/* A hairline rule rather than a middot: the old form read as machine output. */ +.status__divider { + width: 1px; + height: 11px; + background: var(--border-strong); +} + +/* --------------------------------------------------------------- controls */ + +.toolbar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 7px 10px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); +} + +.search { + position: relative; + flex: 1 1 200px; + min-width: 140px; + max-width: 380px; +} +.search input { + width: 100%; + height: 24px; + padding: 0 46px 0 8px; + background: var(--bg-sunken); + border: 1px solid var(--border-input); + border-radius: 3px; + user-select: text; + cursor: text; +} +.search input::placeholder { + color: var(--text-faint); +} +.search input:focus { + border-color: var(--accent-deep); + outline: none; +} +.search__hint { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + font-family: var(--mono); + font-size: 9.5px; + color: var(--text-faint); + border: 1px solid var(--border-strong); + border-radius: 3px; + padding: 0 4px; + pointer-events: none; +} + +select.control, +input.control { + height: 24px; + padding: 0 6px; + background: var(--bg-sunken); + border: 1px solid var(--border-strong); + border-radius: 3px; + cursor: pointer; +} +input.control { + width: 62px; + font-family: var(--mono); + user-select: text; + cursor: text; +} + +.chip { + height: 24px; + padding: 0 8px; + background: var(--bg-sunken); + border: 1px solid var(--border-strong); + border-radius: 3px; + color: var(--text-dim); + cursor: pointer; + white-space: nowrap; +} +.chip:hover { + border-color: var(--accent-deep); + color: var(--text); +} +/* Tri-state: unset -> include -> exclude. */ +.chip[data-state="on"] { + background: var(--accent-bg); + border-color: var(--accent-deep); + color: var(--accent); +} +.chip[data-state="off"] { + border-color: rgba(194, 112, 90, 0.45); + color: var(--bad); + text-decoration: line-through; + text-decoration-thickness: 1px; +} + +.control-label { + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); +} + +.toolbar__spacer { + flex: 1; +} + +.linkish { + background: none; + border: 0; + padding: 0 4px; + color: var(--text-dim); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: var(--border-strong); +} +.linkish:hover { + color: var(--accent); +} + +/* ------------------------------------------------------------------ table */ + +.browser { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.table { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + position: relative; + background: var(--bg); + /* Reserve the gutter so rows do not shift when the list becomes scrollable. */ + scrollbar-gutter: stable; +} + +/* One definition for both, so a column can never drift from its heading. */ +.thead, +.row { + display: grid; + /* The narrow numeric columns carry a sort arrow beside their heading when + they are the active ordering, so each has room for the label plus the + arrow. Sized for the longest heading, not the widest value. */ + grid-template-columns: + minmax(170px, 1fr) 58px 76px 62px 54px 88px 56px 72px; + align-items: center; + gap: 12px; + padding: 0 12px; +} + +.thead { + position: sticky; + top: 0; + z-index: 2; + height: 26px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border-strong); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); +} +.thead .num { + text-align: right; +} +.thead .mid { + text-align: center; +} + +/* Sortable column heading. A button for keyboard and screen-reader access, + stripped back to look exactly like the static headings beside it. */ +.th-sort { + display: flex; + align-items: center; + gap: 3px; + min-width: 0; + padding: 0; + border: 0; + background: none; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + color: inherit; + cursor: pointer; +} +.th-sort.num { + justify-content: flex-end; +} +.th-sort.mid { + justify-content: center; +} +.th-sort__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.th-sort:hover, +.th-sort[data-active="true"] { + color: var(--text-dim); +} +.th-sort:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 2px; +} +.th-sort__arrow { + flex: none; + font-size: 8px; + line-height: 1; + color: var(--accent-strong); +} +/* Hollow, and in the dimmer accent: this ordering reorders the loaded page + only. The shape carries it without relying on colour alone, and the footer + states it in words. */ +.th-sort__arrow[data-scope="page"] { + color: var(--accent); +} + +.rows { + position: relative; +} + +.row { + position: absolute; + left: 0; + right: 0; + height: var(--row-h); + border-bottom: 1px solid var(--border-muted); + cursor: pointer; +} +.row:hover { + background: var(--bg-hover); +} +.row[aria-selected="true"] { + background: var(--bg-active); + box-shadow: inset 2px 0 0 var(--accent); +} +.row--offline { + color: var(--text-faint); +} + +.cell { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cell.num { + text-align: right; + font-family: var(--mono); + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} + +/* Ping is a short, roughly fixed-width figure with nothing to its right to + align against, so centring reads better than a ragged right edge. */ +.cell.mid { + text-align: center; + font-family: var(--mono); + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} +.cell.dim { + color: var(--text-dim); +} + +/* Latency bands. Restrained on purpose: a coloured number per row would turn + the table into a traffic light. */ +.cell.ping[data-band="good"] { + color: var(--accent-strong); +} +.cell.ping[data-band="ok"] { + color: var(--text-dim); +} +.cell.ping[data-band="far"] { + color: var(--warn); +} + +.name { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.name__text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.star { + flex: none; + width: 20px; + height: 20px; + display: grid; + place-items: center; + border: 0; + background: none; + color: #4e6260; + cursor: pointer; + border-radius: 3px; + padding: 0; +} +.star:hover { + color: var(--accent-strong); + background: var(--bg-raised); +} +.star[data-on="true"] { + color: #d9b44a; +} + +/* Status marks. Muted by default: on a list where most servers run BattlEye, + a bright mark on every row is noise, and only the exceptions should draw the + eye. */ +.marks { + display: flex; + align-items: center; + gap: 3px; + color: var(--text-faint); +} +.marks__i { + display: grid; + place-items: center; + width: 14px; + height: 14px; +} +.marks__i--official { + color: var(--accent); +} +.marks__i--locked { + color: var(--warn); +} +.marks__i--offline { + color: var(--bad); +} + +/* A full server is worth noticing; everything else reads from the figure. */ +.cell[data-full="true"] { + color: var(--warn); +} + +.tags { + display: flex; + gap: 4px; + overflow: hidden; +} + +/* Platform marks, as on the site: a tinted glyph in a quiet tile. */ +.platforms { + display: flex; + gap: 3px; +} +.platforms__badge { + width: 20px; + height: 18px; + display: grid; + place-items: center; + border-radius: 3px; + background: var(--bg-raised); + border: 1px solid var(--border); +} +.platforms__badge svg { + display: block; +} + +.tag { + flex: none; + height: 15px; + padding: 0 4px; + border-radius: 2px; + border: 1px solid var(--border-strong); + background: var(--bg-raised); + font-family: var(--mono); + font-size: 9.5px; + line-height: 13px; + color: var(--text-dim); + letter-spacing: 0.02em; +} +.tag--accent { + color: var(--accent); + border-color: var(--accent-deep); + background: var(--accent-bg); +} +.tag--warn { + color: var(--warn); + border-color: rgba(201, 144, 68, 0.4); +} + +/* ------------------------------------------------------------- inspector */ + +.inspector { + display: flex; + flex-direction: column; + min-height: 0; + background: var(--bg-panel); + border-left: 1px solid var(--border); + overflow: hidden; +} + +.inspector__head { + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} + +.inspector__title { + font-size: 13px; + font-weight: 600; + line-height: 1.3; + user-select: text; + cursor: text; + overflow-wrap: anywhere; +} + +.inspector__sub { + margin-top: 3px; + font-size: 11px; + color: var(--text-dim); + overflow-wrap: anywhere; +} + +.inspector__body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 10px 12px; +} + +.inspector__foot { + padding: 10px 12px; + border-top: 1px solid var(--border); + background: var(--bg-sunken); +} + +.facts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; +} + +.fact { + background: var(--bg-raised); + padding: 6px 8px; + min-width: 0; +} +.fact__k { + font-size: 9.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); +} +.fact__v { + font-family: var(--mono); + font-size: 12px; + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.section { + margin-top: 14px; +} +.section__h { + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-faint); + margin-bottom: 6px; + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} + +.modlist { + border: 1px solid var(--border); + border-radius: 3px; + max-height: 210px; + overflow-y: auto; + scrollbar-gutter: stable; + background: var(--bg-sunken); +} +.modlist__row { + display: grid; + grid-template-columns: minmax(0, 1fr) 58px 62px; + gap: 8px; + align-items: baseline; + padding: 4px 9px; + font-size: 11px; +} +/* Banding instead of a rule per row: 122 hairlines read as a grid, not a list. */ +.modlist__row:nth-child(odd) { + background: rgba(170, 185, 196, 0.03); +} +.modlist__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: text; +} +.modlist__version, +.modlist__size { + font-family: var(--mono); + font-size: 10px; + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.modlist__size { + text-align: right; +} + +.btn { + height: 28px; + padding: 0 12px; + border-radius: 3px; + border: 1px solid var(--border-strong); + background: var(--bg-raised); + color: var(--text); + cursor: pointer; + white-space: nowrap; +} +.btn:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--accent-deep); +} +.btn:disabled { + opacity: 0.45; + cursor: default; +} + +.btn--primary { + width: 100%; + height: 32px; + font-weight: 600; + background: var(--accent-deep); + border-color: var(--accent); + color: var(--on-accent); +} +.btn--primary:hover:not(:disabled) { + background: var(--accent); +} + +.btn-row { + display: flex; + gap: 6px; + margin-top: 6px; +} +.btn-row .btn { + flex: 1; +} + +/* ------------------------------------------------------------ list states */ + +.state { + display: grid; + place-content: center; + justify-items: center; + gap: 8px; + padding: 48px 24px; + text-align: center; + color: var(--text-dim); +} +.state__title { + color: var(--text); + font-weight: 600; +} +.state__hint { + font-size: 11.5px; + max-width: 44ch; + color: var(--text-faint); +} + +.banner { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + font-size: 11.5px; + border-bottom: 1px solid var(--border); + background: var(--bg-raised); + color: var(--text-dim); +} +.banner--error { + color: var(--bad); + border-bottom-color: rgba(194, 112, 90, 0.35); + background: rgba(194, 112, 90, 0.08); +} +.banner--warn { + color: var(--warn); + background: rgba(201, 144, 68, 0.08); + border-bottom-color: rgba(201, 144, 68, 0.3); +} + +/* Indeterminate progress hairline under the toolbar during a refresh. */ +.loadbar { + height: 1px; + background: var(--border); + overflow: hidden; + position: relative; +} +.loadbar::after { + content: ""; + position: absolute; + inset: 0; + width: 30%; + background: var(--accent); + animation: slide 1.1s ease-in-out infinite; +} +@keyframes slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } +} +@media (prefers-reduced-motion: reduce) { + .loadbar::after { + animation: none; + width: 100%; + opacity: 0.4; + } +} + +.footbar { + display: flex; + align-items: center; + gap: 10px; + height: 24px; + padding: 0 10px; + background: var(--bg-panel); + border-top: 1px solid var(--border); + font-size: 10.5px; + color: var(--text-faint); + font-family: var(--mono); +} +.footbar__spacer { + flex: 1; +} +.pager { + display: flex; + align-items: center; + gap: 4px; +} +.pager button { + height: 17px; + min-width: 20px; + padding: 0 5px; + border: 1px solid var(--border-strong); + background: var(--bg-raised); + color: var(--text-dim); + border-radius: 2px; + cursor: pointer; + font-size: 10px; +} +.pager button:disabled { + opacity: 0.35; + cursor: default; +} + +/* --------------------------------------------------------------- settings */ + +.page { + padding: 16px 20px; + overflow-y: auto; + min-height: 0; +} +.page h2 { + margin: 0 0 4px; + font-size: 14px; +} +.page__lede { + margin: 0 0 18px; + color: var(--text-dim); + font-size: 11.5px; + max-width: 68ch; +} + +.field { + display: grid; + grid-template-columns: 200px minmax(0, 320px); + align-items: center; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid var(--border); + max-width: 560px; +} +.field__k { + font-size: 11.5px; +} +.field__hint { + grid-column: 1 / -1; + font-size: 10.5px; + color: var(--text-faint); + margin-top: -4px; +} +.field input[type="text"], +.field input[type="number"] { + width: 100%; + height: 24px; + padding: 0 8px; + background: var(--bg-sunken); + border: 1px solid var(--border-input); + border-radius: 3px; + user-select: text; + cursor: text; + font-family: var(--mono); + font-size: 11.5px; +} +.field input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--accent-deep); + cursor: pointer; +} + +.simple-list { + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; + max-width: 760px; +} +.simple-list__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + padding: 5px 10px; + border-bottom: 1px solid var(--border-muted); + background: var(--bg-panel); +} +.simple-list__row:last-child { + border-bottom: 0; +} +.simple-list__row:hover { + background: var(--bg-hover); +} +.simple-list__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} +.simple-list__meta { + font-family: var(--mono); + font-size: 10.5px; + color: var(--text-faint); + white-space: nowrap; +} + +/* ------------------------------------------------------------ history chart */ + +.rangeswitch { + display: flex; + gap: 2px; +} +.rangeswitch button { + height: 16px; + padding: 0 5px; + border: 1px solid var(--border-strong); + background: var(--bg-raised); + color: var(--text-faint); + border-radius: 2px; + cursor: pointer; + font-family: var(--mono); + font-size: 9.5px; + line-height: 1; +} +.rangeswitch button:hover { + color: var(--text); +} +.rangeswitch button[data-on="true"] { + color: var(--accent); + border-color: var(--accent-deep); + background: var(--accent-bg); +} + +.chart { + display: block; + width: 100%; + height: 56px; + background: var(--bg-sunken); + border: 1px solid var(--border); + border-radius: 3px; +} +.chart__band { + fill: var(--accent); + fill-opacity: 0.16; + stroke: none; +} +.chart__line { + fill: none; + stroke: var(--accent); + stroke-width: 1; + stroke-linejoin: round; +} + +.chart__legend { + display: flex; + gap: 10px; + margin-top: 4px; + font-family: var(--mono); + font-size: 10px; + color: var(--text-faint); +} +.chart__legend b { + color: var(--text-dim); + font-weight: 600; +} +.chart__bucket { + margin-left: auto; +} + +/* --------------------------------------------------------- mod readiness */ + +/* Information only: no action attached, so it is a note rather than a card. + The state is carried by a dot, not a coloured edge down the side — the same + treatment that made the nav look wrong. */ +.readiness { + margin-bottom: 6px; + padding: 7px 9px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg-sunken); +} + +.readiness__line { + display: flex; + align-items: baseline; + gap: 7px; + margin: 0; + font-size: 11px; + line-height: 1.45; + color: var(--text-dim); +} +.readiness[data-tone="ready"] .readiness__line { + color: var(--text); +} + +.readiness__dot { + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-faint); + transform: translateY(-1px); +} +.readiness[data-tone="ready"] .readiness__dot { + background: var(--good); +} +.readiness[data-tone="action"] .readiness__dot { + background: var(--warn); +} + +/* Label over figure, so the numbers read as a row of small stats rather than + a sentence fragment. */ +.readiness__counts { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin: 7px 0 0; + padding-top: 7px; + border-top: 1px solid var(--border-muted); +} +.readiness__counts div { + display: flex; + flex-direction: column; + gap: 1px; +} +.readiness__counts dt { + font-size: 9.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); +} +.readiness__counts dd { + margin: 0; + font-family: var(--mono); + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--text-dim); +} +.readiness__counts div:last-child { + margin-left: auto; + align-items: flex-end; +} + +select.control--wide { + max-width: 170px; +} + +/* ------------------------------------------------------- scenario imagery */ + +/* 16:9 banner at the top of the inspector. Fixed ratio so the panel does not + jump as images load, and cropped rather than letterboxed. */ +.scenario { + position: relative; + aspect-ratio: 16 / 9; + background: var(--bg-sunken); + border-bottom: 1px solid var(--border); + overflow: hidden; + flex: none; +} +.scenario__img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +/* Fade the lower edge so the title below it stays legible over busy artwork. */ +.scenario::after { + content: ""; + position: absolute; + inset: auto 0 0 0; + height: 46%; + background: linear-gradient(to bottom, transparent, var(--bg-panel)); + pointer-events: none; +} + +.fact__v--muted { + color: var(--text-faint); +} + +.identity { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.identity code { + font-family: var(--mono); + font-size: 11px; + color: var(--text-dim); + background: var(--bg-sunken); + border: 1px solid var(--border-strong); + border-radius: 3px; + padding: 2px 6px; + user-select: text; +} + +/* Discord invite, when a server advertises one in its name. */ +.btn--discord { + width: 100%; + margin-top: 6px; + border-color: #454b7a; + color: #b3baf5; +} +.btn--discord:hover:not(:disabled) { + background: #2b2f4d; + border-color: #5865f2; + color: #fff; +} + +/* Wordmark, coloured as on reforgermods.net. */ +.wordmark { + display: flex; + align-items: baseline; + gap: 1px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: lowercase; + white-space: nowrap; + padding-left: 8px; + border-left: 1px solid var(--border); +} +.wordmark__reforger { + color: #f4f4f1; +} +.wordmark__mods { + color: var(--accent-strong); +} +.wordmark__net { + color: #aab6c3; +} +.wordmark__app { + font-style: normal; + font-weight: 500; + color: var(--text-faint); + margin-left: 6px; +} + +/* Separator between the two header counts. */ +.titlebar__dot { + display: inline-block; + width: 1px; + height: 9px; + margin: 0 7px; + vertical-align: -1px; + background: var(--border-strong); +} + +/* Platform tags are informational, not status: keep them quieter than BE. */ + diff --git a/src/views/BrowseView.tsx b/src/views/BrowseView.tsx new file mode 100644 index 0000000..f6fade9 --- /dev/null +++ b/src/views/BrowseView.tsx @@ -0,0 +1,133 @@ +/** The server browser: filters, table, and the pager/status footer. */ + +import { FilterBar } from "../components/FilterBar"; +import { ServerTable } from "../components/ServerTable"; +import { + clearFilters, + filteredOut, + listError, + listInitialising, + listLoading, + loadServers, + query, + setQuery, + sortIsPageLocal, + activeSort, + totalPages, + totalServers, + visibleServers, +} from "../lib/state"; +import { COLUMN_LABEL } from "../lib/sort"; + +export function BrowseView() { + const rows = visibleServers.value; + const error = listError.value; + const loading = listLoading.value; + const initialising = listInitialising.value; + const q = query.value; + + return ( +
+ + {loading ?
:
} + + {error && ( +