diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index db91a281..066e272c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -5,63 +5,48 @@ checks CI enforces, and what is expected of a pull request. ## Prerequisites -- Node.js 22 (the version CI uses). -- pnpm 10.32.1. It is pinned by `packageManager` in `package.json`, so - `corepack enable` gives you the right version automatically. - A stable Rust toolchain with the `rustfmt` and `clippy` components. -- Tauri 2 system dependencies for your OS. Follow - [tauri.app/start/prerequisites](https://tauri.app/start/prerequisites/). +- Node.js 22 and pnpm 10.32.1, only to build installers (the Tauri bundler) and + to work on the telemetry worker. pnpm is pinned by `packageManager` in + `package.json`, so `corepack enable` gives you the right version. On Ubuntu, the packages CI installs are: ```bash -sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev \ - libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev libssl-dev pkg-config +sudo apt-get install -y libdbus-1-dev libgtk-3-dev libxkbcommon-dev pkg-config ``` ## Getting started ```bash -pnpm install -pnpm tauri dev +cargo run -p accshift-egui ``` -`pnpm tauri build` produces a release bundle for your platform. +`node crates/accshift-egui/bundle/bundle.mjs --build` builds the installers for +your platform after `pnpm install`. ## Checks -CI runs the `validate` job on Windows, Ubuntu and macOS. Every step must pass on +CI runs the `rust` job on Windows, Ubuntu and macOS. Every step must pass on all three. Run the equivalents locally before opening a pull request: ```bash -pnpm exec vp check # Vite+ check -pnpm run check:frontend # svelte-check -pnpm exec vp test # frontend tests -pnpm exec vp build # frontend build -pnpm run fmt:check # cargo fmt --all --check -pnpm run clippy # cargo clippy --workspace -- -D warnings -pnpm run check:rust # cargo check --workspace -pnpm run test:rust # cargo test --workspace +cargo fmt --all --check +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked ``` -`pnpm run check` chains the Vite+ check, `check:frontend` and `check:rust` in one -command. `pnpm gates` runs `check`, the frontend and worker tests, the worker -typecheck, `fmt:check`, `clippy` and `test:rust`, then prints each exit code; -`pnpm gates clippy rust` runs only the named gates. It needs -`server/node_modules` installed. The frontend build is not part of it. - -Two details that are easy to miss locally: - -- The Rust check, test and clippy scripts run `scripts/stage-cli.mjs` first. - It stages the gitignored CLI sidecar and builds it if missing. Direct - `cargo check`, `cargo test` or `cargo clippy` commands do not run this step; - run `node scripts/stage-cli.mjs` first on a fresh checkout. -- The telemetry worker in `server/` is a separate pnpm package and is - typechecked on its own: - ```bash - pnpm --dir server install --frozen-lockfile - pnpm --dir server exec tsc --noEmit - ``` +`node scripts/gates.mjs` runs those three and the worker typecheck and tests, +then prints each exit code; `node scripts/gates.mjs clippy rust` runs only the +named gates. It needs `server/node_modules` installed. + +The telemetry worker in `server/` is a separate pnpm package: + +```bash +pnpm --dir server install --frozen-lockfile +pnpm --dir server exec tsc --noEmit +pnpm --dir server exec vitest run +``` `cargo audit --file Cargo.lock` runs on Ubuntu only. If your change touches dependencies, run it locally too. @@ -70,9 +55,10 @@ dependencies, run it locally too. See the "Project Structure" section of the [README](../README.md#project-structure). -The Cargo workspace has three members: `src-tauri` (the Tauri GUI wrapper), -`crates/accshift-core` (platform logic, config, storage, OS primitives) and -`crates/accshift-cli` (the CLI binary). +The Cargo workspace has five members: `crates/accshift-core` (platform logic, +config, storage, OS primitives), `crates/accshift-cli` (the CLI binary), +`crates/accshift-app` (the UI-free app logic), `crates/accshift-theme` (the +theme engine) and `crates/accshift-egui` (the GUI). The CLI and GUI both sit on top of `crates/accshift-core`, so a platform change usually touches both surfaces. When you add or modify a platform, check that the diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 8d66967a..7885f2f6 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -35,7 +35,7 @@ In scope: - The exclusive file lock shared by the GUI and CLI on mutating operations. - The `accshift://` deep link handler and any input reaching it. - The updater and its signature verification. -- The webview-reachable command surface and input validation on platform ids. +- Input validation on platform ids and deep links (`accshift://`). - The optional CS2 stats bridge and the storage of its Bearer token. Out of scope: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 789a7e68..cb8c0868 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -59,6 +59,15 @@ updates: update-types: - minor - patch + # A new egui minor needs crates/accshift-egui/vendor/epaint and + # egui_software_backend moved to it by hand. + ignore: + - dependency-name: "egui*" + update-types: ["version-update:semver-minor", "version-update:semver-major"] + - dependency-name: "eframe" + update-types: ["version-update:semver-minor", "version-update:semver-major"] + - dependency-name: "epaint" + update-types: ["version-update:semver-minor", "version-update:semver-major"] - package-ecosystem: github-actions directory: / diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2325ae5..d7e9512f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: contents: read jobs: - validate: + rust: strategy: fail-fast: false matrix: @@ -22,70 +22,57 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install Linux Tauri build deps + - name: Install Linux build deps if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ - libgtk-3-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev \ - libsoup-3.0-dev \ - libssl-dev \ + libxkbcommon-dev \ pkg-config - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 - with: - node-version: 22 - cache: true - - name: Setup Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, pinned 2026-06-09 with: components: rustfmt, clippy - - name: Vite+ check - run: vp check - - - name: Svelte check - run: vp run check:frontend - - - name: Frontend tests - run: vp test - - # setup-vp only puts `vp` on PATH; the server worker is a plain pnpm - # package. `corepack enable` writes its pnpm shim next to the real node - # binary, which is not on PATH here, so drive pnpm through corepack - # itself (version pinned by packageManager in the root package.json). - - name: Install server dependencies - run: corepack pnpm --dir server install --frozen-lockfile - - - name: Server typecheck - run: corepack pnpm --dir server exec tsc --noEmit - - - name: Build frontend - run: vp build - - # clippy, check:rust and test:rust each run scripts/stage-cli.mjs first: - # tauri-build resolves `externalBin` at build-script time and the sidecar - # is gitignored, so a fresh checkout has to stage it before any of them. - - name: Cargo fmt - run: vp run fmt:check + run: cargo fmt --all --check - name: Cargo clippy - run: vp run clippy - - - name: Cargo check - run: vp run check:rust + run: cargo clippy --workspace --all-targets --locked -- -D warnings - name: Rust tests - run: vp run test:rust + run: cargo test --workspace --locked - name: Cargo audit if: matrix.os == 'ubuntu-latest' run: | cargo install cargo-audit --quiet cargo audit --file Cargo.lock + + # The telemetry worker (server/) is a plain pnpm package of its own. + worker: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Only used for the Node runtime. `corepack enable` writes its pnpm shim + # next to the real node binary, which is not on PATH here, so drive pnpm + # through corepack itself (version pinned by packageManager in the root + # package.json). + - name: Setup Node + uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 + with: + node-version: 22 + run-install: false + + - name: Install server dependencies + run: corepack pnpm --dir server install --frozen-lockfile + + - name: Server typecheck + run: corepack pnpm --dir server exec tsc --noEmit + + - name: Server tests + run: corepack pnpm --dir server exec vitest run diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index b43b87ec..2927a4ab 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -19,19 +19,18 @@ jobs: - os: windows-latest artifact_name: accshift-windows artifact_paths: | - target/release/bundle/nsis/*.exe - target/release/bundle/msi/*.msi + target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe - os: ubuntu-latest artifact_name: accshift-linux artifact_paths: | - target/release/bundle/deb/*.deb - target/release/bundle/appimage/*.AppImage - target/release/bundle/rpm/*.rpm + target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb + target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage + target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm - os: macos-latest artifact_name: accshift-macos artifact_paths: | - target/release/bundle/dmg/*.dmg - target/release/bundle/macos/*.app.zip + target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + target/aarch64-apple-darwin/release/bundle/macos/*.app.zip runs-on: ${{ matrix.os }} steps: - name: Checkout @@ -39,43 +38,43 @@ jobs: with: ref: ${{ inputs.ref || github.ref }} - - name: Install Linux Tauri build deps + - name: Install Linux build deps if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ libgtk-3-dev \ - libayatana-appindicator3-dev \ + libxkbcommon-dev \ librsvg2-dev \ - libsoup-3.0-dev \ - libssl-dev \ pkg-config - - name: Setup Vite+ + - name: Setup Node uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 with: node-version: 22 - cache: true + run-install: false - name: Setup Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, pinned 2026-06-09 + - name: Install the bundler + run: corepack pnpm install --frozen-lockfile + # No ACCSHIFT_TELEMETRY_URL: a dev build compiles in the inert fallback, so # test installs never reach the analytics dashboard. Only release.yml sets it. - - name: Build Tauri bundles (unsigned, no updater artifacts) - run: vp exec tauri build -c '{"bundle":{"createUpdaterArtifacts":false}}' + - name: Build the installers (unsigned, no updater artifacts) + run: node crates/accshift-egui/bundle/bundle.mjs --build --no-updater - name: Compress macOS .app bundle if: matrix.os == 'macos-latest' run: | - cd target/release/bundle/macos + cd target/aarch64-apple-darwin/release/bundle/macos for app in *.app; do zip -r "${app%.app}.app.zip" "$app" done - name: Upload artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ matrix.artifact_name }} path: ${{ matrix.artifact_paths }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cf639d3..0ff27320 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,8 @@ permissions: jobs: build-windows: runs-on: windows-latest + outputs: + signed: ${{ steps.windows_signed.outputs.signed }} steps: - name: Checkout @@ -33,18 +35,17 @@ jobs: "tag=$tag" >> $env:GITHUB_OUTPUT "version=$version" >> $env:GITHUB_OUTPUT - - name: Setup Vite+ + - name: Setup Node uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 with: node-version: 22 - cache: true run-install: false - name: Setup Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, pinned 2026-06-09 - - name: Install dependencies - run: vp install --frozen-lockfile + - name: Install the bundler + run: corepack pnpm install --frozen-lockfile - name: Validate versions against tag shell: pwsh @@ -52,12 +53,12 @@ jobs: $tag = "${{ steps.release_tag.outputs.version }}" $packageVersion = (Get-Content package.json -Raw | ConvertFrom-Json).version - $tauriVersion = (Get-Content src-tauri/tauri.conf.json -Raw | ConvertFrom-Json).version + $tauriVersion = (Get-Content crates/accshift-egui/tauri.conf.json -Raw | ConvertFrom-Json).version $metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json - $cargoPackage = $metadata.packages | Where-Object { $_.name -eq "accshift-gui" } | Select-Object -First 1 + $cargoPackage = $metadata.packages | Where-Object { $_.name -eq "accshift-egui" } | Select-Object -First 1 if (-not $cargoPackage) { - throw "Could not resolve accshift-gui version from cargo metadata" + throw "Could not resolve accshift-egui version from cargo metadata" } $cargoVersion = $cargoPackage.version @@ -94,12 +95,112 @@ jobs: throw "ACCSHIFT_TELEMETRY_URL must be an HTTPS base URL without credentials, query, fragment, or trailing slash." } - - name: Build Tauri bundles + - name: Build the GUI and the CLI env: ACCSHIFT_TELEMETRY_URL: ${{ vars.ACCSHIFT_TELEMETRY_URL }} + run: cargo build --release --locked -p accshift-egui -p accshift-cli + + # Authenticode through SignPath (see "Code signing policy" in the README) + # once the SIGNPATH_ORGANIZATION_ID variable and the SIGNPATH_API_TOKEN + # secret exist; until then the release stays unsigned, as before. Each + # request waits for a manual approval in SignPath, the binaries first so + # the installer packs signed files, then the installer itself. The + # artifact configurations live in .signpath/artifact-configurations. + - name: Upload the binaries to sign + id: unsigned_binaries + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: unsigned-binaries + if-no-files-found: error + path: | + target/release/accshift-egui.exe + target/release/accshift.exe + + - name: Sign the binaries + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: accshift + signing-policy-slug: release-signing + artifact-configuration-slug: binaries + github-artifact-id: ${{ steps.unsigned_binaries.outputs.artifact-id }} + parameters: | + version: "${{ steps.release_tag.outputs.version }}" + wait-for-completion-timeout-in-seconds: 7200 + output-artifact-directory: signed/binaries + + - name: Use the signed binaries + if: vars.SIGNPATH_ORGANIZATION_ID != '' + shell: pwsh + run: | + foreach ($name in "accshift-egui.exe", "accshift.exe") { + $signed = "signed/binaries/$name" + if ((Get-AuthenticodeSignature $signed).Status -ne "Valid") { + throw "$signed has no valid signature" + } + Copy-Item $signed "target/release/$name" -Force + } + + # The Tauri bundler packs the egui app with crates/accshift-egui/tauri.conf.json, + # the config of the v1.0.x Tauri builds, so an existing install updates + # in place. It signs the installer for the updater. + - name: Build the installer + env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: vp exec tauri build + run: node crates/accshift-egui/bundle/bundle.mjs + + - name: Upload the installer to sign + id: unsigned_installer + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + archive: false + if-no-files-found: error + path: target/x86_64-pc-windows-msvc/release/bundle/nsis/Accshift_${{ steps.release_tag.outputs.version }}_x64-setup.exe + + - name: Sign the installer + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: accshift + signing-policy-slug: release-signing + artifact-configuration-slug: installer + github-artifact-id: ${{ steps.unsigned_installer.outputs.artifact-id }} + parameters: | + version: "${{ steps.release_tag.outputs.version }}" + wait-for-completion-timeout-in-seconds: 7200 + skip-decompress: true + output-artifact-directory: signed/installer + + # Authenticode changes the installer's bytes, so the updater signature + # the bundler wrote no longer matches: sign the signed file again. + - name: Use the signed installer + id: windows_signed + if: vars.SIGNPATH_ORGANIZATION_ID != '' + shell: pwsh + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + $version = "${{ steps.release_tag.outputs.version }}" + $name = "Accshift_${version}_x64-setup.exe" + $signed = "signed/installer/$name" + if ((Get-AuthenticodeSignature $signed).Status -ne "Valid") { + throw "$signed has no valid signature" + } + $installer = "target/x86_64-pc-windows-msvc/release/bundle/nsis/$name" + Copy-Item $signed $installer -Force + Remove-Item "$installer.sig" + node node_modules/@tauri-apps/cli/tauri.js signer sign --app-version $version $installer + if ($LASTEXITCODE -ne 0) { throw "tauri signer sign failed" } + if (-not (Test-Path "$installer.sig")) { throw "no updater signature for $installer" } + "signed=true" >> $env:GITHUB_OUTPUT - name: Stage standalone CLI artifact shell: pwsh @@ -109,6 +210,7 @@ jobs: if (-not (Test-Path $cli)) { throw "CLI binary not found at $cli" } + New-Item -ItemType Directory -Force "target/release/bundle" | Out-Null Copy-Item $cli "target/release/bundle/accshift-cli_${version}_x64.exe" - name: Generate release changelog @@ -253,19 +355,17 @@ jobs: # once. They are attached to the release too, so a download can be # verified against the updater public key by hand. - name: Upload Windows artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: bundle-windows if-no-files-found: error path: | - target/release/bundle/nsis/*.exe - target/release/bundle/nsis/*.exe.sig - target/release/bundle/msi/*.msi - target/release/bundle/msi/*.msi.sig + target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe + target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig target/release/bundle/accshift-cli_*.exe - name: Upload release notes - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-notes if-no-files-found: error @@ -288,40 +388,37 @@ jobs: fi echo "version=${tag#v}" >> "$GITHUB_OUTPUT" - - name: Install Linux Tauri build deps + # librsvg2-dev: the AppImage's GTK plugin bundles its pixbuf loader. + - name: Install Linux build deps run: | sudo apt-get update sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ libgtk-3-dev \ - libayatana-appindicator3-dev \ + libxkbcommon-dev \ librsvg2-dev \ - libsoup-3.0-dev \ - libssl-dev \ pkg-config - - name: Setup Vite+ + - name: Setup Node uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 with: node-version: 22 - cache: true run-install: false - name: Setup Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, pinned 2026-06-09 - - name: Install dependencies - run: vp install --frozen-lockfile + - name: Install the bundler + run: corepack pnpm install --frozen-lockfile # Version/tag consistency and the telemetry URL format are validated once # in build-windows; the release job depends on all build jobs, so a # failure there still blocks the release. - - name: Build Tauri bundles + - name: Build the bundles env: ACCSHIFT_TELEMETRY_URL: ${{ vars.ACCSHIFT_TELEMETRY_URL }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: vp exec tauri build + run: node crates/accshift-egui/bundle/bundle.mjs --build - name: Stage standalone CLI artifact run: | @@ -334,19 +431,19 @@ jobs: cp "$cli" "target/release/bundle/accshift-cli_${version}_linux_x86_64" - name: Upload Linux artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: bundle-linux if-no-files-found: error path: | - target/release/bundle/deb/*.deb - target/release/bundle/deb/*.deb.sig - target/release/bundle/rpm/*.rpm - target/release/bundle/rpm/*.rpm.sig - target/release/bundle/appimage/*.AppImage - target/release/bundle/appimage/*.AppImage.sig - target/release/bundle/appimage/*.AppImage.tar.gz - target/release/bundle/appimage/*.AppImage.tar.gz.sig + target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb + target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig + target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm + target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm.sig + target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage + target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig + target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz + target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig target/release/bundle/accshift-cli_*_linux_* build-macos: @@ -366,28 +463,27 @@ jobs: fi echo "version=${tag#v}" >> "$GITHUB_OUTPUT" - - name: Setup Vite+ + - name: Setup Node uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 with: node-version: 22 - cache: true run-install: false - name: Setup Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, pinned 2026-06-09 - - name: Install dependencies - run: vp install --frozen-lockfile + - name: Install the bundler + run: corepack pnpm install --frozen-lockfile # Bundles are unsigned and not notarized (no Apple Developer identity # yet). Gatekeeper will quarantine the download; the install # instructions in the release notes cover removing the quarantine flag. - - name: Build Tauri bundles + - name: Build the bundles env: ACCSHIFT_TELEMETRY_URL: ${{ vars.ACCSHIFT_TELEMETRY_URL }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: vp exec tauri build + run: node crates/accshift-egui/bundle/bundle.mjs --build - name: Stage standalone CLI artifact run: | @@ -400,14 +496,14 @@ jobs: cp "$cli" "target/release/bundle/accshift-cli_${version}_macos_aarch64" - name: Upload macOS artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: bundle-macos if-no-files-found: error path: | - target/release/bundle/dmg/*.dmg - target/release/bundle/macos/*.app.tar.gz - target/release/bundle/macos/*.app.tar.gz.sig + target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz + target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig target/release/bundle/accshift-cli_*_macos_* release: @@ -418,7 +514,7 @@ jobs: steps: - name: Download build artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: artifacts merge-multiple: true @@ -533,6 +629,9 @@ jobs: cat SHA256SUMS.txt - name: Finalize release notes + env: + WINDOWS_SIGNED: ${{ needs.build-windows.outputs.signed }} + REPO: ${{ github.repository }} run: | cp artifacts/RELEASE_NOTES.md RELEASE_NOTES.md cat >> RELEASE_NOTES.md <<'EOF' @@ -543,6 +642,14 @@ jobs: - **Linux**: install the `.deb` / `.rpm`, or `chmod +x` the `.AppImage` and run it. - **macOS** (Apple Silicon): the app is not yet notarized. After copying Accshift.app to Applications, clear the quarantine flag once: `xattr -cr /Applications/Accshift.app`. EOF + if [ "$WINDOWS_SIGNED" = "true" ]; then + cat >> RELEASE_NOTES.md < + + + + + + + + + + + + + + diff --git a/.signpath/artifact-configurations/installer.xml b/.signpath/artifact-configurations/installer.xml new file mode 100644 index 00000000..88d866b5 --- /dev/null +++ b/.signpath/artifact-configurations/installer.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit deleted file mode 100644 index 85fb65b4..00000000 --- a/.vite-hooks/pre-commit +++ /dev/null @@ -1 +0,0 @@ -vp staged diff --git a/Cargo.lock b/Cargo.lock index 0bbcde55..016fdda1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,150 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" +dependencies = [ + "accesskit", + "hashbrown 0.17.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", +] + +[[package]] +name = "accesskit_macos" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.35.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "accshift-app" +version = "1.0.5" +dependencies = [ + "accshift-core", + "base64 0.23.1", + "dirs", + "dunce", + "flate2", + "image", + "infer", + "jiff", + "minisign", + "minisign-verify", + "osakit", + "percent-encoding", + "plist", + "reqwest", + "rfd", + "rusqlite", + "rust-ini", + "rusty-leveldb", + "semver", + "serde", + "serde_json", + "sha2 0.11.1", + "tar", + "tempfile", + "time", + "tokio", + "url", + "windows-registry", + "windows-sys 0.61.2", + "zbus", +] + [[package]] name = "accshift-cli" version = "1.0.5" @@ -12,6 +156,7 @@ dependencies = [ "is-terminal", "serde", "serde_json", + "tauri-winres", "unicode-width", ] @@ -22,54 +167,58 @@ dependencies = [ "fs4", "keyring", "open", - "pbkdf2", - "reqwest 0.12.28", + "pbkdf2 0.13.0", + "reqwest", "rusqlite", + "rustls", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "sysinfo", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "uuid", "windows-sys 0.61.2", - "winreg 0.56.0", + "winreg", "zeroize", ] [[package]] -name = "accshift-gui" +name = "accshift-egui" version = "1.0.5" dependencies = [ + "accshift-app", "accshift-core", + "accshift-theme", "base64 0.23.1", - "dirs", + "eframe", + "egui", + "egui-winit", + "egui_extras", + "egui_software_backend", "image", - "mimalloc", - "reqwest 0.12.28", - "serde", + "image-webp", + "memmap2", + "png 0.18.1", + "raw-window-handle", + "resvg", + "rfd", "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-deep-link", - "tauri-plugin-mcp-bridge", - "tauri-plugin-process", - "tauri-plugin-single-instance", - "tauri-plugin-updater", - "webview2-com", - "windows 0.62.2", - "windows-core 0.61.2", - "winreg 0.56.0", - "wry", -] - -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" + "skrifa", + "softbuffer", + "tauri-winres", + "window-vibrancy", + "windows", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "accshift-theme" +version = "1.0.5" dependencies = [ - "gimli", + "serde", + "serde_json", ] [[package]] @@ -80,13 +229,26 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.8.4" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "ahash" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "cipher", - "cpufeatures 0.2.17", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", ] [[package]] @@ -99,28 +261,29 @@ dependencies = [ ] [[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" +name = "android-activity" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ - "alloc-no-stdlib", + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", ] [[package]] -name = "android_system_properties" -version = "0.1.6" +name = "android-properties" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] name = "anstream" @@ -173,10 +336,54 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.104" +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] name = "async-broadcast" @@ -229,7 +436,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.5", "slab", "windows-sys 0.61.2", ] @@ -260,18 +467,18 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.5", ] [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -286,7 +493,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.5", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -306,64 +513,57 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] -name = "atk" -version = "0.18.2" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "atk-sys" -version = "0.18.2" +name = "atspi" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", + "atspi-common", + "atspi-proxies", ] [[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" +name = "atspi-common" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] [[package]] -name = "backtrace" -version = "0.3.76" +name = "atspi-proxies" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", + "atspi-common", + "serde", + "zbus", ] [[package]] -name = "base64" -version = "0.21.7" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -377,21 +577,6 @@ 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" @@ -400,12 +585,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -dependencies = [ - "serde_core", -] +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -427,11 +609,11 @@ dependencies = [ [[package]] name = "block-padding" -version = "0.3.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -440,7 +622,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -449,14 +631,14 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -466,47 +648,31 @@ dependencies = [ ] [[package]] -name = "brotli" -version = "8.0.4" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "brotli-decompressor" -version = "5.0.3" +name = "bytemuck" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "bytemuck_derive", ] [[package]] -name = "bs58" -version = "0.5.1" +name = "bytemuck_derive" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ - "tinyvec", + "proc-macro2", + "quote", + "syn 3.0.9", ] -[[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" @@ -524,128 +690,94 @@ 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" +name = "calloop" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.13.1", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", "thiserror 1.0.69", ] [[package]] -name = "cairo-sys-rs" -version = "0.18.2" +name = "calloop" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ - "glib-sys", - "libc", - "system-deps", + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", ] [[package]] -name = "camino" -version = "1.2.5" +name = "calloop-wayland-source" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "serde_core", + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", ] [[package]] -name = "cargo-platform" -version = "0.1.9" +name = "calloop-wayland-source" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" 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.20", -] - -[[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", + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", ] [[package]] name = "cbc" -version = "0.1.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] name = "cc" -version = "1.4.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" 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" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" dependencies = [ - "byteorder", "fnv", "uuid", -] - -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", + "web-time", ] [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -654,43 +786,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] -name = "chacha20" -version = "0.10.1" +name = "cgl" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "libc", ] [[package]] -name = "chrono" -version = "0.4.45" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link 0.2.1", + "crypto-common 0.1.7", + "inout 0.1.4", ] [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common 0.1.7", - "inout", + "crypto-common 0.2.2", + "inout 0.2.4", ] [[package]] name = "clap" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -698,9 +826,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -710,21 +838,30 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "clipboard-win" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] [[package]] name = "cmov" @@ -732,6 +869,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -740,9 +886,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -784,13 +930,14 @@ dependencies = [ ] [[package]] -name = "cookie" -version = "0.18.2" +name = "constify" +version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +checksum = "c3efe35846d8965e78e0e246153416055964610dc2b67ba0e162624dc44fc719" dependencies = [ - "time", - "version_check", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -821,12 +968,12 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core-graphics" -version = "0.25.0" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", + "bitflags 1.3.2", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -834,15 +981,21 @@ dependencies = [ [[package]] name = "core-graphics-types" -version = "0.2.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", + "bitflags 1.3.2", + "core-foundation 0.9.4", "libc", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -854,36 +1007,55 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] -name = "crossbeam-channel" -version = "0.5.16" +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -911,127 +1083,46 @@ dependencies = [ ] [[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" +name = "ct-codecs" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.119", -] +checksum = "8fd9f3db6f2cfef61c10613071955154ffdc9e515daebff26de4b54e35038fdd" [[package]] name = "ctor" -version = "0.8.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" 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 = "ctutils" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", ] [[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "dbus" -version = "0.9.12" +name = "cursor-icon" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" -dependencies = [ - "libc", - "libdbus-sys", - "windows-sys 0.61.2", -] +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] -name = "dbus-secret-service" -version = "4.1.0" +name = "data-url" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" -dependencies = [ - "aes", - "block-padding", - "cbc", - "dbus", - "fastrand", - "hkdf", - "num", - "once_cell", - "sha2 0.10.9", - "zeroize", -] +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -1039,54 +1130,30 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ - "thiserror 2.0.20", + "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_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" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", -] +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "digest" @@ -1122,9 +1189,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -1141,16 +1208,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1161,30 +1234,16 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", -] - -[[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", + "syn 3.0.9", ] [[package]] -name = "dlopen2_derive" -version = "0.4.3" +name = "dlib" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "libloading", ] [[package]] @@ -1197,58 +1256,31 @@ dependencies = [ ] [[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" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "serde", + "litrs", ] [[package]] -name = "dtoa" -version = "1.0.11" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] -name = "dtoa-short" -version = "0.3.5" +name = "dpi" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[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" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" [[package]] name = "dunce" @@ -1257,37 +1289,188 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "dyn-clone" -version = "1.0.20" +name = "ecolor" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" +dependencies = [ + "bytemuck", + "emath", +] [[package]] -name = "embed-resource" -version = "3.0.11" +name = "eframe" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 1.1.4+spec-1.1.0", - "vswhom", - "winreg 0.55.0", + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "send_wrapper", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "windows-sys 0.61.2", + "winit", ] [[package]] -name = "embed_plist" -version = "1.2.2" +name = "egui" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" - -[[package]] -name = "endi" +checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-winit" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" +dependencies = [ + "accesskit_winit", + "arboard", + "block2 0.6.2", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def286cf47194f3fe45b8e37c503ada7a20a64b59a903b4bc704baec943b1827" +dependencies = [ + "ahash", + "egui", + "enum-map", + "itertools", + "log", + "profiling", + "resvg", +] + +[[package]] +name = "egui_glow" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9275ca5cc35a6b7c94e0e8cedf22542f66cb0cf45ed8ab2657c78ff49fec7268" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", +] + +[[package]] +name = "egui_software_backend" +version = "0.0.3" +dependencies = [ + "ahash", + "constify", + "egui", + "rayon", + "strength_reduce", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "emath" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml", + "vswhom", + "winreg", +] + +[[package]] +name = "endi" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-map" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1310,21 +1493,40 @@ dependencies = [ ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "epaint" +version = "0.37.0" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-properties", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" [[package]] -name = "erased-serde" -version = "0.4.10" +name = "equivalent" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1336,6 +1538,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1374,6 +1591,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1384,14 +1607,10 @@ dependencies = [ ] [[package]] -name = "field-offset" -version = "0.3.6" +name = "fearless_simd" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "filetime" @@ -1405,32 +1624,60 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1449,7 +1696,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] @@ -1473,7 +1720,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" dependencies = [ - "rustix", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -1493,17 +1740,6 @@ 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" @@ -1531,7 +1767,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] @@ -1562,105 +1798,6 @@ dependencies = [ "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" @@ -1671,6 +1808,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1678,10 +1825,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -1706,175 +1851,147 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", "wasm-bindgen", ] [[package]] -name = "gimli" -version = "0.32.3" +name = "gl_generator" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] [[package]] -name = "gio" -version = "0.18.4" +name = "glifo" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", "smallvec", - "thiserror 1.0.69", + "vello_common", ] [[package]] -name = "gio-sys" -version = "0.18.1" +name = "glow" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "glib" -version = "0.18.5" +name = "glutin" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "bitflags 2.13.1", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "once_cell", - "smallvec", - "thiserror 1.0.69", + "raw-window-handle", + "windows-sys 0.52.0", ] [[package]] -name = "glib-macros" -version = "0.18.5" +name = "glutin-winit" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.119", + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", ] [[package]] -name = "glib-sys" -version = "0.18.1" +name = "glutin_egl_sys" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" dependencies = [ - "libc", - "system-deps", + "gl_generator", + "windows-sys 0.52.0", ] [[package]] -name = "glob" -version = "0.3.4" +name = "glutin_wgl_sys" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] [[package]] -name = "gobject-sys" -version = "0.18.0" +name = "guillotiere" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" dependencies = [ - "glib-sys", - "libc", - "system-deps", + "euclid", ] [[package]] -name = "gtk" -version = "0.18.2" +name = "half" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", + "cfg-if", + "crunchy", + "zerocopy", ] [[package]] -name = "gtk-sys" -version = "0.18.2" +name = "harfrust" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", ] [[package]] -name = "gtk3-macros" -version = "0.18.2" +name = "hashbrown" +version = "0.14.5" 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 = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] [[package]] name = "hashbrown" @@ -1882,7 +1999,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1891,24 +2008,18 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] name = "hashlink" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" dependencies = [ "hashbrown 0.17.1", ] -[[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" @@ -1917,9 +2028,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -1929,11 +2040,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac 0.12.1", + "hmac 0.13.0", ] [[package]] @@ -1954,16 +2065,6 @@ dependencies = [ "digest 0.11.3", ] -[[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" @@ -2005,22 +2106,21 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", "http", "http-body", @@ -2034,9 +2134,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -2045,73 +2145,37 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "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.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2123,9 +2187,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -2136,9 +2200,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2150,16 +2214,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2170,15 +2235,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2189,12 +2254,6 @@ dependencies = [ "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" @@ -2224,41 +2283,46 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "tiff", "zune-core", "zune-jpeg", ] [[package]] -name = "indexmap" -version = "1.9.3" +name = "image-webp" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", + "byteorder-lite", + "quick-error", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] name = "infer" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" dependencies = [ "cfb", ] @@ -2269,26 +2333,30 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", "generic-array", ] [[package]] -name = "io-uring" -version = "0.7.14" +name = "inout" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64d8ca234d152948ceaede1f419b6a83983a5ecccaac05fb337a809c96d3aa6" +checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f" dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "libc", + "block-padding", + "hybrid-array", ] +[[package]] +name = "integer-encoding" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" + [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-docker" @@ -2327,39 +2395,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[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" +name = "itertools" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", + "either", ] [[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.35" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ "defmt", "jiff-core", @@ -2374,18 +2428,19 @@ dependencies = [ [[package]] name = "jiff-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ "defmt", + "log", ] [[package]] name = "jiff-static" -version = "0.2.35" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", @@ -2395,9 +2450,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" [[package]] name = "jiff-tzdb-platform" @@ -2408,22 +2463,6 @@ 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" @@ -2436,7 +2475,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link 0.2.1", ] @@ -2483,128 +2522,102 @@ dependencies = [ ] [[package]] -name = "js-sys" -version = "0.3.104" +name = "jobserver" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "getrandom 0.4.3", + "libc", ] [[package]] -name = "json-patch" -version = "3.0.1" +name = "js-sys" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "jsonptr", - "serde", - "serde_json", - "thiserror 1.0.69", + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[package]] -name = "jsonptr" -version = "0.6.3" +name = "keyring" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" dependencies = [ - "serde", - "serde_json", + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", ] [[package]] -name = "keyboard-types" -version = "0.7.0" +name = "keyring-core" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" dependencies = [ - "bitflags 2.13.1", - "serde", - "unicode-segmentation", + "log", ] [[package]] -name = "keyring" -version = "3.6.3" +name = "khronos_api" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" -dependencies = [ - "dbus-secret-service", - "log", - "secret-service", - "security-framework 2.11.1", - "security-framework 3.7.0", - "zeroize", -] +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] -name = "libappindicator" -version = "0.9.0" +name = "kurbo" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", + "arrayvec", + "euclid", + "smallvec", ] [[package]] -name = "libappindicator-sys" -version = "0.9.0" +name = "kurbo" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ - "gtk-sys", - "libloading", - "once_cell", + "arrayvec", + "euclid", + "polycool", + "smallvec", ] [[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" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "pkg-config", -] +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" -version = "0.7.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "winapi", -] - -[[package]] -name = "libmimalloc-sys" -version = "0.1.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" -dependencies = [ - "cc", + "windows-link 0.2.1", ] [[package]] name = "libredox" -version = "0.1.19" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ + "bitflags 2.13.2", "libc", + "plain", + "redox_syscall 0.9.5", ] [[package]] @@ -2618,6 +2631,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2626,9 +2651,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -2641,33 +2672,25 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] -name = "lru-slab" -version = "0.1.2" +name = "memchr" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "markup5ever" -version = "0.38.0" +name = "memmap2" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ - "log", - "tendril", - "web_atoms", + "libc", ] -[[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" @@ -2678,25 +2701,22 @@ dependencies = [ ] [[package]] -name = "mimalloc" -version = "0.1.52" +name = "minisign" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +checksum = "3062dbbf4d8f57899e2252804f7512a4fd89c8211c58fa6c786d38794a0c809d" dependencies = [ - "libmimalloc-sys", + "ct-codecs", + "getrandom 0.4.3", + "rpassword", + "scrypt", ] -[[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" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" [[package]] name = "miniz_oxide" @@ -2708,11 +2728,21 @@ dependencies = [ "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.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -2729,34 +2759,13 @@ dependencies = [ "pxfm", ] -[[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 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "once_cell", - "png 0.18.1", - "serde", - "thiserror 2.0.20", - "windows-sys 0.61.2", -] - [[package]] name = "ndk" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2765,6 +2774,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2775,22 +2790,18 @@ dependencies = [ ] [[package]] -name = "new_debug_unreachable" -version = "1.0.6" +name = "nohash-hasher" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" [[package]] -name = "nix" -version = "0.29.0" +name = "nom" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", + "memchr", ] [[package]] @@ -2896,7 +2907,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.119", @@ -2910,9 +2921,9 @@ checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] name = "objc2" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" dependencies = [ "objc-sys", "objc2-encode", @@ -2920,12 +2931,11 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", - "objc2-exception-helper", ] [[package]] @@ -2934,12 +2944,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "libc", - "objc2 0.5.2", - "objc2-core-data 0.2.2", - "objc2-core-image 0.2.2", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", "objc2-foundation 0.2.2", "objc2-quartz-core 0.2.2", ] @@ -2950,11 +2960,13 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -2963,24 +2975,13 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location 0.2.2", + "objc2 0.5.3", + "objc2-core-location", "objc2-foundation 0.2.2", ] -[[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.1", - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - [[package]] name = "objc2-contacts" version = "0.2.2" @@ -2988,7 +2989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2998,31 +2999,21 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - [[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.1", + "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3031,9 +3022,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3045,21 +3036,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal", ] -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - [[package]] name = "objc2-core-location" version = "0.2.2" @@ -3067,58 +3048,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-contacts", "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-core-location" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" -dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - -[[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.1", - "objc2 0.6.4", - "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.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", + "dispatch", "libc", - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -3127,10 +3078,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3150,8 +3101,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", + "bitflags 2.13.2", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3162,7 +3113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -3173,9 +3124,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3185,8 +3136,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", + "bitflags 2.13.2", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -3197,9 +3148,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal", ] @@ -3210,8 +3161,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", + "bitflags 2.13.2", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3222,7 +3173,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3232,19 +3183,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", - "objc2-cloud-kit 0.2.2", - "objc2-core-data 0.2.2", - "objc2-core-image 0.2.2", - "objc2-core-location 0.2.2", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", - "objc2-user-notifications 0.2.2", + "objc2-user-notifications", ] [[package]] @@ -3253,19 +3204,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.13.1", - "block2 0.6.2", - "objc2 0.6.4", - "objc2-cloud-kit 0.3.2", - "objc2-core-data 0.3.2", + "bitflags 2.13.2", + "objc2 0.6.5", "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image 0.3.2", - "objc2-core-location 0.3.2", - "objc2-core-text", "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", - "objc2-user-notifications 0.3.2", ] [[package]] @@ -3275,7 +3217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3285,59 +3227,13 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location 0.2.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" -dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-web-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68bc69301064cebefc6c4c90ce9cba69225239e4b8ff99d445a2b5563797da65" -dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", + "objc2 0.5.3", + "objc2-core-location", "objc2-foundation 0.2.2", ] -[[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.1", - "block2 0.6.2", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -3352,9 +3248,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "open" -version = "5.4.3" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ "dunce", "is-wsl", @@ -3373,6 +3269,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -3393,43 +3299,28 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "osakit" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.20", -] - -[[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", + "thiserror 2.0.21", ] [[package]] @@ -3456,11 +3347,21 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "pbkdf2" version = "0.13.0" @@ -3471,6 +3372,19 @@ dependencies = [ "hmac 0.13.0", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo 0.13.1", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3478,31 +3392,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.13.1" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "phf_macros", - "phf_shared", - "serde", + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", ] [[package]] -name = "phf_codegen" -version = "0.13.1" +name = "phf" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_generator", + "phf_macros", "phf_shared", + "serde", ] [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -3510,9 +3425,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -3523,13 +3438,39 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3549,19 +3490,25 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", - "quick-xml", + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", "serde", "time", ] @@ -3576,7 +3523,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -3585,11 +3532,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -3602,10 +3549,25 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.5", "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -3614,27 +3576,27 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -3645,63 +3607,13 @@ dependencies = [ "zerocopy", ] -[[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.13+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", + "toml_edit", ] [[package]] @@ -3713,12 +3625,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.41.0" @@ -3729,59 +3653,12 @@ dependencies = [ ] [[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.20", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" -dependencies = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.20", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" +name = "quick-xml" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", + "memchr", ] [[package]] @@ -3807,133 +3684,106 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] -name = "rand" -version = "0.9.5" +name = "rand_chacha" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "ppv-lite86", + "rand_core", ] [[package]] -name = "rand" -version = "0.10.2" +name = "rand_core" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", + "getrandom 0.2.17", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "raw-window-handle" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rayon" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "either", + "rayon-core", ] [[package]] -name = "rand_core" -version = "0.6.4" +name = "rayon-core" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ - "getrandom 0.2.17", + "crossbeam-deque", + "crossbeam-utils", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "read-fonts" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ - "getrandom 0.3.4", + "bytemuck", + "font-types", + "once_cell", ] [[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" +name = "redox_syscall" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ - "rand_core 0.10.1", + "bitflags 1.3.2", ] -[[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.1", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.20", + "bitflags 2.13.2", ] [[package]] -name = "ref-cast" -version = "1.0.26" +name = "redox_syscall" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ - "ref-cast-impl", + "bitflags 2.13.2", ] [[package]] -name = "ref-cast-impl" -version = "1.0.26" +name = "redox_users" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "libredox", + "thiserror 2.0.21", ] [[package]] @@ -3967,11 +3817,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-core", @@ -3986,12 +3836,11 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -4002,46 +3851,56 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] -name = "reqwest" -version = "0.13.4" +name = "resvg" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", "js-sys", + "libc", "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", + "pollster", + "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wayland-backend", + "wayland-client", + "wayland-protocols", "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", ] [[package]] @@ -4058,6 +3917,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -4065,7 +3941,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.20", + "thiserror 2.0.21", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", ] [[package]] @@ -4074,7 +3960,7 @@ version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4093,18 +3979,6 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - -[[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" @@ -4116,14 +3990,27 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -4143,14 +4030,14 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.7.0", + "security-framework", ] [[package]] @@ -4159,26 +4046,25 @@ 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" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni 0.22.4", + "jni", "log", "once_cell", "rustls", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.7.0", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -4186,15 +4072,15 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -4208,10 +4094,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] -name = "ryu" -version = "1.0.23" +name = "rusty-leveldb" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43ddda8f34100fca4bf1b0bbf743d12d27b5d84c35c74a52f392aac26f54e82d" +dependencies = [ + "bytes", + "crc32c", + "integer-encoding", + "rand", + "snap", +] + +[[package]] +name = "salsa20" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] [[package]] name = "same-file" @@ -4224,100 +4126,53 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" 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", -] - -[[package]] -name = "schemars" -version = "0.9.0" +name = "scoped-tls" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] -name = "schemars" -version = "1.2.2" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "schemars_derive" -version = "0.8.22" +name = "scrypt" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.119", + "pbkdf2 0.12.2", + "salsa20", + "sha2 0.10.9", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "secret-service" -version = "4.0.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" dependencies = [ "aes", "cbc", "futures-util", - "generic-array", + "getrandom 0.4.3", "hkdf", + "hybrid-array", "num", "once_cell", - "rand 0.8.7", "serde", - "sha2 0.10.9", - "zbus 4.4.0", -] - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", + "sha2 0.11.1", + "zbus", ] [[package]] @@ -4326,7 +4181,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4344,33 +4199,22 @@ dependencies = [ ] [[package]] -name = "selectors" -version = "0.36.1" +name = "self_cell" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" -dependencies = [ - "bitflags 2.13.1", - "cssparser", - "derive_more", - "log", - "new_debug_unreachable", - "phf", - "phf_codegen", - "precomputed-hash", - "rustc-hash", - "servo_arc", - "smallvec", -] +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" [[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 = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -4382,18 +4226,6 @@ dependencies = [ "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" @@ -4411,26 +4243,17 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", -] - -[[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", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash 0.2.0", + "indexmap", "itoa", "memchr", "serde", @@ -4446,191 +4269,201 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ - "serde", + "serde_core", ] [[package]] -name = "serde_spanned" -version = "1.1.1" +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "serde_core", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "sha2" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] -name = "serde_with" -version = "3.22.0" +name = "shlex" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" -dependencies = [ - "base64 0.22.1", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "jiff", - "schemars 0.9.0", - "schemars 1.2.2", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] -name = "serde_with_macros" -version = "3.22.0" +name = "signal-hook-registry" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.119", + "errno", + "libc", ] [[package]] -name = "serialize-to-javascript" -version = "0.1.2" +name = "simd-adler32" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" -dependencies = [ - "serde", - "serde_json", - "serialize-to-javascript-impl", -] +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] -name = "serialize-to-javascript-impl" -version = "0.1.2" +name = "simd_cesu8" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "rustc_version", + "simdutf8", ] [[package]] -name = "servo_arc" -version = "0.4.3" +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" -dependencies = [ - "stable_deref_trait", -] +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] -name = "sha1" -version = "0.10.7" +name = "simplecss" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "log", ] [[package]] -name = "sha2" -version = "0.10.9" +name = "siphasher" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] -name = "sha2" -version = "0.11.0" +name = "skrifa" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "bytemuck", + "read-fonts", ] [[package]] -name = "shlex" -version = "2.0.1" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "slotmap" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ - "errno", - "libc", + "version_check", ] [[package]] -name = "simd-adler32" -version = "0.3.10" +name = "smallvec" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] -name = "simd_cesu8" -version = "1.2.0" +name = "smithay-client-toolkit" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "rustc_version", - "simdutf8", + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", ] [[package]] -name = "simdutf8" -version = "0.1.5" +name = "smithay-client-toolkit" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "1.0.3" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] [[package]] -name = "slab" -version = "0.4.12" +name = "smol_str" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] [[package]] -name = "smallvec" -version = "1.15.2" +name = "snap" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" [[package]] name = "socket2" @@ -4648,46 +4481,29 @@ version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ + "as-raw-xcb-connection", "bytemuck", + "fastrand", "js-sys", + "memmap2", "ndk", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", + "rustix 1.1.5", + "tiny-xlib", "tracing", "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", "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", + "x11rb", ] [[package]] @@ -4715,27 +4531,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "string_cache" -version = "0.9.0" +name = "strength_reduce" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", -] +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" [[package]] -name = "string_cache_codegen" -version = "0.6.1" +name = "strict-num" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", + "float-cmp", ] [[package]] @@ -4751,24 +4558,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "swift-rs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" -dependencies = [ - "base64 0.21.7", - "serde", - "serde_json", -] - -[[package]] -name = "syn" -version = "1.0.109" +name = "svgtypes" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" dependencies = [ - "proc-macro2", - "unicode-ident", + "kurbo 0.11.3", + "siphasher", ] [[package]] @@ -4784,9 +4580,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -4804,13 +4600,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -4824,519 +4620,103 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows 0.62.2", + "windows", ] [[package]] -name = "system-configuration" -version = "0.7.0" +name = "tar" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.9.4", - "system-configuration-sys", + "filetime", + "libc", + "xattr", ] [[package]] -name = "system-configuration-sys" -version = "0.6.0" +name = "tauri-winres" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ - "core-foundation-sys", - "libc", + "dunce", + "embed-resource", + "toml", ] [[package]] -name = "system-deps" -version = "6.2.2" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml 0.8.2", - "version-compare", + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", ] [[package]] -name = "tao" -version = "0.35.3" +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "bitflags 2.13.1", - "block2 0.6.2", - "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 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "objc2-ui-kit 0.3.2", - "once_cell", - "parking_lot", - "percent-encoding", - "raw-window-handle", - "tao-macros", - "unicode-segmentation", - "url", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", - "x11-dl", + "thiserror-impl 1.0.69", ] [[package]] -name = "tao-macros" -version = "0.1.4" +name = "thiserror" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl 2.0.21", ] [[package]] -name = "tar" -version = "0.4.46" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "filetime", - "libc", - "xattr", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "target-lexicon" -version = "0.12.16" +name = "thiserror-impl" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] [[package]] -name = "tauri" -version = "2.11.5" +name = "tiff" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" 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 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "objc2-ui-kit 0.3.2", - "objc2-web-kit 0.3.2", - "percent-encoding", - "plist", - "raw-window-handle", - "reqwest 0.13.4", - "serde", - "serde_json", - "serde_repr", - "serialize-to-javascript", - "swift-rs", - "tauri-build", - "tauri-macros", - "tauri-runtime", - "tauri-runtime-wry", - "tauri-utils", - "thiserror 2.0.20", - "tokio", - "tray-icon", - "url", - "webkit2gtk", - "webview2-com", - "window-vibrancy", - "windows 0.61.3", + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", ] [[package]] -name = "tauri-build" -version = "2.6.3" +name = "time" +version = "0.3.55" 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.20", - "time", - "url", - "uuid", - "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-deep-link" -version = "2.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d489b8ecceae1cd09f6e1f7606f2095ac721cc8d54cf2f0e6bb377cc52cff6" -dependencies = [ - "dunce", - "plist", - "rust-ini", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-utils", - "thiserror 2.0.20", - "tracing", - "url", - "windows-registry", - "windows-result 0.3.4", -] - -[[package]] -name = "tauri-plugin-mcp-bridge" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db0e3b2116693ae3ea616e041d83d8d95898ee2393629b2d1b8ee1d80f2eedb0" -dependencies = [ - "base64 0.22.1", - "block2 0.5.1", - "cairo-rs", - "futures-util", - "gio", - "image", - "jni 0.21.1", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", - "objc2-ui-kit 0.2.2", - "objc2-web-kit 0.2.2", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 1.0.69", - "tokio", - "tokio-tungstenite", - "uuid", - "webkit2gtk", - "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", -] - -[[package]] -name = "tauri-plugin-process" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" -dependencies = [ - "tauri", - "tauri-plugin", -] - -[[package]] -name = "tauri-plugin-single-instance" -version = "2.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0cb5c412a5071b69bab6a6df1583cbb89460d4a83b6a24769b08d15b6b1e1" -dependencies = [ - "serde", - "serde_json", - "tauri", - "tauri-plugin-deep-link", - "thiserror 2.0.20", - "tokio", - "tracing", - "windows-sys 0.60.2", - "zbus 5.19.0", -] - -[[package]] -name = "tauri-plugin-updater" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606" -dependencies = [ - "base64 0.22.1", - "dirs", - "flate2", - "futures-util", - "http", - "infer", - "log", - "minisign-verify", - "osakit", - "percent-encoding", - "reqwest 0.13.4", - "rustls", - "semver", - "serde", - "serde_json", - "tar", - "tauri", - "tauri-plugin", - "tempfile", - "thiserror 2.0.20", - "time", - "tokio", - "url", - "windows-sys 0.60.2", -] - -[[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 0.6.4", - "objc2-ui-kit 0.3.2", - "objc2-web-kit 0.3.2", - "raw-window-handle", - "serde", - "serde_json", - "tauri-utils", - "thiserror 2.0.20", - "url", - "webkit2gtk", - "webview2-com", - "windows 0.61.3", -] - -[[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 0.6.4", - "objc2-app-kit 0.3.2", - "once_cell", - "percent-encoding", - "raw-window-handle", - "softbuffer", - "tao", - "tauri-runtime", - "tauri-utils", - "url", - "webkit2gtk", - "webview2-com", - "windows 0.61.3", - "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.20", - "toml 1.1.4+spec-1.1.0", - "url", - "urlpattern", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-winres" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" -dependencies = [ - "dunce", - "embed-resource", - "toml 1.1.4+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.3.4", - "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.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[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.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -5372,215 +4752,140 @@ dependencies = [ ] [[package]] -name = "tinystr" -version = "0.8.3" +name = "tiny-skia" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" dependencies = [ - "displaydoc", - "zerovec", + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", ] [[package]] -name = "tinyvec" -version = "1.12.0" +name = "tiny-skia-path" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" dependencies = [ - "tinyvec_macros", + "arrayref", + "bytemuck", + "strict-num", ] [[package]] -name = "tinyvec_macros" -version = "0.1.1" +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] [[package]] name = "tokio" -version = "1.47.5" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f885cc8c20014400b9ec7f7d5fa7917176d306f1c0241c0fe183af95f9a3c1f7" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", - "slab", "socket2", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - -[[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", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ - "indexmap 2.14.0", + "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned", + "toml_datetime", "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.0", - "toml_datetime 0.6.3", - "winnow 0.5.40", + "winnow", ] [[package]] -name = "toml_edit" -version = "0.20.2" +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ - "indexmap 2.14.0", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", + "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ - "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", + "indexmap", + "toml_datetime", "toml_parser", - "winnow 1.0.4", + "winnow", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow 1.0.4", + "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -5603,7 +4908,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -5633,6 +4938,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5659,25 +4965,14 @@ dependencies = [ ] [[package]] -name = "tray-icon" -version = "0.24.2" +name = "tree_magic_mini" +version = "3.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "once_cell", - "png 0.18.1", - "serde", - "thiserror 2.0.20", - "windows-sys 0.61.2", + "memchr", + "nom", + "petgraph", ] [[package]] @@ -5686,29 +4981,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.5", - "sha1", - "thiserror 2.0.20", - "utf-8", -] - -[[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" @@ -5727,51 +4999,22 @@ dependencies = [ ] [[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" +name = "unicode-general-category" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] -name = "unic-ucd-version" -version = "0.9.0" +name = "unicode-ident" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "unicode-properties" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" @@ -5805,23 +5048,27 @@ dependencies = [ ] [[package]] -name = "urlpattern" -version = "0.3.0" +name = "usvg" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "regex", - "serde", - "unic-ucd-ident", - "url", + "base64 0.22.1", + "data-url", + "flate2", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "xmlwriter", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5836,9 +5083,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5853,10 +5100,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] -name = "version-compare" -version = "0.2.1" +name = "vello_common" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] [[package]] name = "version_check" @@ -5896,9 +5164,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -5920,9 +5188,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -5933,19 +5201,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5953,169 +5222,212 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-streams" -version = "0.5.0" +name = "wayland-backend" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", ] [[package]] -name = "web-sys" -version = "0.3.104" +name = "wayland-client" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "js-sys", - "wasm-bindgen", + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "wayland-csd-frame" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "js-sys", - "wasm-bindgen", + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", ] [[package]] -name = "web_atoms" -version = "0.2.6" +name = "wayland-cursor" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", + "rustix 1.1.5", + "wayland-client", + "xcursor", ] [[package]] -name = "webkit2gtk" -version = "2.0.2" +name = "wayland-protocols" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" 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", + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", ] [[package]] -name = "webkit2gtk-sys" -version = "2.0.2" +name = "wayland-protocols-experimental" +version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" 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", + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", ] [[package]] -name = "webpki-root-certs" -version = "1.0.9" +name = "wayland-protocols-misc" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "rustls-pki-types", + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", ] [[package]] -name = "webpki-roots" -version = "1.0.9" +name = "wayland-protocols-plasma" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "rustls-pki-types", + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", ] [[package]] -name = "webview2-com" -version = "0.38.2" +name = "wayland-protocols-wlr" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", ] [[package]] -name = "webview2-com-macros" -version = "0.8.1" +name = "wayland-scanner" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", + "quick-xml 0.41.0", "quote", - "syn 2.0.119", ] [[package]] -name = "webview2-com-sys" -version = "0.38.2" +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +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 = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ - "thiserror 2.0.20", - "windows 0.61.3", - "windows-core 0.61.2", + "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -6149,51 +5461,30 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "window-vibrancy" -version = "0.6.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "raw-window-handle", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "windows-version", ] -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[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", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -6202,20 +5493,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.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", + "windows-core", ] [[package]] @@ -6231,26 +5509,15 @@ dependencies = [ "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 0.1.0", -] - [[package]] name = "windows-future" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link 0.2.1", - "windows-threading 0.2.1", + "windows-threading", ] [[package]] @@ -6277,24 +5544,27 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.3" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-link" -version = "0.2.1" +version = "0.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "9b39de7c7fe78858b0144c50c3fc000bab3be17d5e9b85b1053871693c7f7415" [[package]] -name = "windows-numerics" -version = "0.2.0" +name = "windows-native-keyring-store" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", ] [[package]] @@ -6303,28 +5573,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link 0.2.1", ] [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "a14b75dbbc0f2bb1ebbf6dcb6dc0035ae17f10ec280045d10fa27ad8874903b2" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[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", + "windows-link 0.100.0", + "windows-result 0.100.0", + "windows-strings 0.100.0", ] [[package]] @@ -6337,12 +5598,12 @@ dependencies = [ ] [[package]] -name = "windows-strings" -version = "0.4.2" +name = "windows-result" +version = "0.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "44873a1ef61c85d5e2991db91fe656cfcf0731097e00dff1ac860f1cb3b752a0" dependencies = [ - "windows-link 0.1.3", + "windows-link 0.100.0", ] [[package]] @@ -6355,12 +5616,12 @@ dependencies = [ ] [[package]] -name = "windows-sys" -version = "0.45.0" +name = "windows-strings" +version = "0.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +checksum = "6a70b590c684f1d2854f74999896f7c0558f1bdde62f599088599e20ea1d2f60" dependencies = [ - "windows-targets 0.42.2", + "windows-link 0.100.0", ] [[package]] @@ -6369,7 +5630,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -6378,16 +5639,7 @@ 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", + "windows-targets", ] [[package]] @@ -6399,61 +5651,20 @@ 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" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows-link 0.1.3", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -6474,132 +5685,48 @@ 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" @@ -6607,26 +5734,56 @@ 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" +name = "winit" +version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ - "memchr", + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", ] -[[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" @@ -6636,16 +5793,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - [[package]] name = "winreg" version = "0.56.0" @@ -6663,74 +5810,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "writeable" -version = "0.6.3" +name = "wl-clipboard-rs" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "wry" -version = "0.55.1" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" dependencies = [ - "base64 0.22.1", - "block2 0.6.2", - "cookie", - "crossbeam-channel", - "dirs", - "dom_query", - "dpi", - "dunce", - "gdkx11", - "gtk", - "http", - "javascriptcore-rs", - "jni 0.21.1", "libc", - "ndk", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-ui-kit 0.3.2", - "objc2-web-kit 0.3.2", - "once_cell", - "percent-encoding", - "raw-window-handle", - "sha2 0.10.9", - "soup3", - "tao-macros", - "thiserror 2.0.20", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", - "x11-dl", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", ] [[package]] -name = "x11" +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" version = "2.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" dependencies = [ "libc", + "once_cell", "pkg-config", ] [[package]] -name = "x11-dl" -version = "2.21.0" +name = "x11rb" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ + "as-raw-xcb-connection", + "gethostname", "libc", + "libloading", "once_cell", - "pkg-config", + "rustix 1.1.5", + "x11rb-protocol", ] +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -6738,19 +5872,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.5", ] [[package]] -name = "xdg-home" -version = "1.3.0" +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "libc", - "windows-sys 0.59.0", + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yoke" version = "0.8.3" @@ -6764,48 +5925,16 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "synstructure", ] -[[package]] -name = "zbus" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" -dependencies = [ - "async-broadcast", - "async-process", - "async-recursion", - "async-trait", - "enumflags2", - "event-listener", - "futures-core", - "futures-sink", - "futures-util", - "hex", - "nix", - "ordered-stream", - "rand 0.8.7", - "serde", - "serde_repr", - "sha1", - "static_assertions", - "tracing", - "uds_windows", - "windows-sys 0.52.0", - "xdg-home", - "zbus_macros 4.4.0", - "zbus_names 3.0.0", - "zvariant 4.2.0", -] - [[package]] name = "zbus" version = "5.19.0" @@ -6828,30 +5957,52 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.5", "serde", "serde_repr", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.4", - "zbus_macros 5.19.0", - "zbus_names 4.3.4", - "zvariant 5.14.0", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] -name = "zbus_macros" -version = "4.4.0" +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" dependencies = [ - "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.119", - "zvariant_utils 2.1.0", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", ] [[package]] @@ -6860,35 +6011,36 @@ version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", - "zbus_names 4.3.4", - "zvariant 5.14.0", - "zvariant_utils 4.0.0", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", ] [[package]] name = "zbus_names" -version = "3.0.0" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", - "static_assertions", - "zvariant 4.2.0", + "winnow", + "zvariant", ] [[package]] -name = "zbus_names" -version = "4.3.4" +name = "zbus_xml" +version = "5.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" dependencies = [ "serde", - "winnow 1.0.4", - "zvariant 5.14.0", + "winnow", + "zbus_names", + "zvariant", ] [[package]] @@ -6902,18 +6054,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -6931,41 +6083,27 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -6974,9 +6112,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -6985,15 +6123,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] +[[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" @@ -7017,78 +6161,41 @@ dependencies = [ [[package]] name = "zvariant" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" -dependencies = [ - "endi", - "enumflags2", - "serde", - "static_assertions", - "zvariant_derive 4.2.0", -] - -[[package]] -name = "zvariant" -version = "5.14.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.4", + "winnow", "zcheapstr", - "zvariant_derive 5.14.0", - "zvariant_utils 4.0.0", -] - -[[package]] -name = "zvariant_derive" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", - "zvariant_utils 2.1.0", + "zvariant_derive", + "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 3.0.3", - "zvariant_utils 4.0.0", -] - -[[package]] -name = "zvariant_utils" -version = "2.1.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ + "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", + "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "4.0.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.3", - "winnow 1.0.4", + "syn 3.0.9", + "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 54ce5eb0..d392f959 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,14 @@ [workspace] resolver = "2" -members = ["src-tauri", "crates/accshift-core", "crates/accshift-cli"] -exclude = ["vendor"] +members = [ + "crates/accshift-core", + "crates/accshift-cli", + "crates/accshift-theme", + "crates/accshift-app", + "crates/accshift-egui", +] +# Patched copies of upstream crates, built as dependencies only. +exclude = ["crates/accshift-egui/vendor"] [workspace.package] version = "1.0.5" @@ -11,30 +18,21 @@ authors = ["meetsu"] [workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = [ - "rustls-tls", +reqwest = { version = "0.13", default-features = false, features = [ + "rustls-no-provider", "json", "blocking", ] } +# The crypto provider of reqwest's rustls, installed by accshift-core's http +# module: ring, as reqwest 0.12's `rustls-tls` had. +rustls = { version = "0.23", default-features = false, features = [ + "ring", + "std", + "tls12", +] } thiserror = "2" rusqlite = { version = "0.40", features = ["bundled"] } uuid = { version = "1", features = ["v4"] } -tauri = "2.11.5" -tauri-plugin-process = "2.3.1" -# No `zip` feature: it only unpacks v1 updater archives (`.nsis.zip`, -# `.AppImage.tar.gz`), and `createUpdaterArtifacts: true` ships the signed -# setup and AppImage themselves (macOS keeps its own tar and flate2). -tauri-plugin-updater = { version = "2.10.1", default-features = false, features = [ - "rustls-tls", - "system-proxy", -] } -tauri-plugin-deep-link = "2" -tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } -# Dev-only agent bridge, kept out of every release build by the optional -# `mcp-bridge` feature on accshift-gui. Version must stay in step with the -# @hypothesi/tauri-mcp-server pinned in .mcp.json. -tauri-plugin-mcp-bridge = "0.13.0" -tauri-build = "2.5.6" winreg = "0.56" windows-sys = { version = "0.61", features = [ "Win32_Foundation", @@ -44,8 +42,7 @@ windows-sys = { version = "0.61", features = [ "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } -mimalloc = { version = "0.1", default-features = false } -tokio = { version = "=1.47", features = ["rt", "rt-multi-thread", "macros"] } +tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "macros"] } sysinfo = { version = "0.39", default-features = false, features = ["system"] } # ShellExecuteExW instead of a powershell.exe child per link or folder open. open = { version = "5", features = ["shellexecute-on-windows"] } @@ -54,23 +51,49 @@ clap = { version = "4", features = ["derive"] } directories = "6" is-terminal = "0.4" unicode-width = "0.2" -keyring = { version = "3", default-features = false, features = [ - "apple-native", - "sync-secret-service", - "crypto-rust", -] } -base64 = "0.23" +keyring = "4" pbkdf2 = "0.13" sha2 = "0.11" zeroize = "1" +# epaint with one patch: a font can supply its glyph coverage +# (FontData::face_rasterizer), so ui::text rasterizes with DirectWrite like +# WebView2. See the header of crates/accshift-egui/vendor/epaint/Cargo.toml +# and its src/text/face_rasterizer.rs. +[patch.crates-io] +epaint = { path = "crates/accshift-egui/vendor/epaint" } + +# Built for size: a smaller exe reaches `main` sooner (crates/accshift-egui +# build.rs), except the per-frame hot path below. [profile.release] panic = "abort" codegen-units = 1 lto = "fat" -opt-level = 2 +opt-level = "s" strip = true -# Starts WebView2 before the Windows app loads, see vendor/wry/ACCSHIFT.md. -[patch.crates-io] -wry = { path = "vendor/wry" } +[profile.release.package.egui_software_backend] +opt-level = 3 + +[profile.release.package.epaint] +opt-level = 3 + +# Avatar SVG raster and blur, off the UI thread but on the way to `ready`. +[profile.release.package.accshift-egui] +opt-level = 3 + +[profile.release.package.resvg] +opt-level = 3 + +[profile.release.package.tiny-skia] +opt-level = 3 + +[profile.release.package.tiny-skia-path] +opt-level = 3 + +# The `windows` bindings crate alone needs about 2 GB of RAM at opt-level 2 +# with one codegen unit. Fat LTO optimizes its bitcode again at the final link, +# so building it unoptimized changes the compile peak, not the binary. +[profile.release.package.windows] +opt-level = 0 +codegen-units = 16 diff --git a/README.md b/README.md index 2d465e7c..e1c779e1 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,7 @@ Downloads Windows, macOS and Linux License - Tauri 2 - Svelte 5 + Rust and egui

@@ -168,9 +167,8 @@ and the server in [`server/`](./server), both readable in a sitting. ## Building from source ```bash -pnpm install -pnpm tauri dev # run the app with hot reload -pnpm tauri build # installers for your OS +cargo run -p accshift-egui # run the app +pnpm install && node crates/accshift-egui/bundle/bundle.mjs --build # installers for your OS ``` Setup, coding standards and how to propose a new platform are in the @@ -181,30 +179,23 @@ Setup, coding standards and how to propose a new platform are in the

Project structure ```text -src/lib/ # Svelte frontend (GUI) - app/ # app lifecycle, dialogs, navigation - features/folders notifications settings - platforms/ # per-platform UI adapters - shared/components contextMenu platform ... - storage/ # client storage layer - crates/ accshift-core/ # platform logic, config, storage, OS src/ platforms/steam riot ... # hand-written platforms platforms/descriptor/ # JSON-described platforms and their engine os/windows linux macos # per-OS primitives (sysinfo/open/keyring) - context.rs # AppContext trait (replaces tauri::AppHandle) + context.rs # AppContext trait (the app's folders) lock.rs # fs4 exclusive lock runtime.rs # tokio block_on helper config storage logging themes accshift-cli/ # CLI binary (list, switch, platforms) - -src-tauri/ # Tauri GUI thin wrapper - src/lib.rs commands.rs app_runtime.rs tauri_context.rs - src/launcher.rs # Windows exe: starts WebView2, loads the app DLL - -vendor/wry/ # wry with an early WebView2 start (Windows) + accshift-app/ # UI-free app logic: backend, stores, i18n, updater + accshift-theme/ # theme engine: tokens, CSS variables + accshift-egui/ # the GUI (accshift-gui), drawn with egui + src/screens ui app window # screens, widgets, app state, native window + tauri.conf.json bundle/ # installer config, packed by the Tauri bundler + parity/ # UI scenes and mock data for regression shots ``` diff --git a/crates/accshift-app/Cargo.toml b/crates/accshift-app/Cargo.toml new file mode 100644 index 00000000..a6e4026d --- /dev/null +++ b/crates/accshift-app/Cargo.toml @@ -0,0 +1,109 @@ +[package] +name = "accshift-app" +description = "UI-free logic of the accshift egui port: the backend trait, its mock and the job runner" +version.workspace = true +edition.workspace = true +publish = false + +[dependencies] +accshift-core = { path = "../accshift-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# The crate Tauri's path resolver calls (dirs 6 in tauri 2.11), so +# `EguiAppContext` lands on the very same folders as the Tauri app. dirs 7 +# only moves Windows' `preference_dir`, unused here. +dirs = "7" +# The shared HTTP client the Tauri app keeps in its state. Same spec as the +# root workspace so features unify with accshift-core's. +reqwest = { version = "0.13", default-features = false, features = [ + "rustls-no-provider", + "json", + "blocking", +] } +# `descriptor_select_file`. +rfd = "0.17" +# The async half of the Steam, CS2 bridge and Roblox commands (Tauri runs them +# on its tokio runtime). Same minor as the root workspace. +tokio = { version = "1.53", features = ["rt-multi-thread"] } +# `legacy_web_storage`: the WebKit origin folder name (SHA-256). Same major +# as accshift-core, so nothing new is built. +sha2 = "0.11" +# `time`: the local time zone with its DST rules, as WebView2 formats dates. +# Default features: `tz-system` reads the Windows zone id and maps it to +# IANA, `tzdb-bundle-platform` embeds the tzdb on Windows (no network, no +# installed database) and reads /usr/share/zoneinfo elsewhere. Same version +# as the root workspace lock. +jiff = "0.2.35" +# `lifecycle::updater`: the port of tauri-plugin-updater 2.11, on the crates +# the plugin builds with: minisign-verify for the `.sig`, semver for +# the version compare, base64 for the key and signature strings, time for the +# RFC 3339 `pub_date`, url and percent-encoding for the endpoint templating, +# infer and tempfile for the downloaded installer. +minisign-verify = "0.3" +semver = "1" +base64 = "0.23" +time = { version = "0.3", features = ["parsing", "formatting"] } +url = { version = "2", features = ["serde"] } +percent-encoding = "2" +infer = "0.22" +tempfile = "3" +# `lifecycle::deep_link`: `dunce::simplified` of the canonical exe path, as +# tauri-utils resolves it before the plugin writes `"" "%1"`. +dunce = "1" + +[target.'cfg(windows)'.dependencies] +# `get_desktop_wallpaper`: the Progman capture of +# src-tauri/src/commands/backdrop.rs, on windows-sys rather than the windows +# crate (accshift-core already builds windows-sys 0.61). +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Security", + "Win32_Storage_Xps", + "Win32_System_DataExchange", + "Win32_System_LibraryLoader", + "Win32_System_Threading", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } +image = { version = "0.25", default-features = false, features = ["jpeg"] } +# `lifecycle::deep_link`: the HKCU scheme registration, on the crate +# tauri-plugin-deep-link 2.4 writes it with. +windows-registry = "0.100" +# `legacy_web_storage`: reads the WebView2 localStorage LevelDB of v0.x +# installs. Pure Rust, Snappy blocks and the `.log` replay included. No `fs` +# feature: the files are copied into its in-memory env, never opened on disk. +rusty-leveldb = { version = "4", default-features = false } + +[target.'cfg(not(windows))'.dependencies] +# `legacy_web_storage`: the WebKit localStorage sqlite of WebKitGTK and +# WKWebView. Same spec as the root workspace. +rusqlite = { version = "0.40", features = ["bundled"] } +# `lifecycle::updater`: the `.tar.gz` bundles of AppImage and macOS. +flate2 = "1" +tar = "0.4" + +[target.'cfg(target_os = "linux")'.dependencies] +# `lifecycle::single_instance`: the D-Bus name tauri-plugin-single-instance +# 2.4 owns on Linux. Major 5, the one accesskit_unix (egui) and +# secret-service (keyring) link: zbus 6 drops the blocking API and would be a +# second D-Bus stack in the binary. +zbus = "5.9" +# `lifecycle::deep_link`: the `.desktop` handler file the plugin merges. +rust-ini = "0.21" + +[target.'cfg(target_os = "macos")'.dependencies] +# `lifecycle::updater`: the admin prompt of the macOS install fallback. +osakit = { version = "0.3", features = ["full"] } +# `lifecycle::process`: the `CFBundleExecutable` a relaunch starts, as +# Tauri's `restart_macos_app` reads it. +plist = "1" + +[dev-dependencies] +# Signs the updater fixtures with a key generated in the test, as the +# `tauri signer` command does (tauri-cli signs with this crate). +minisign = "0.10" +# The WebKit fixtures are built and read on every OS, Windows included +# (tests/legacy_web_storage.rs compiles src/legacy_web_storage/webkit_sqlite.rs +# by path), without growing the Windows app. +rusqlite = { version = "0.40", features = ["bundled"] } diff --git a/crates/accshift-app/i18n/en.json b/crates/accshift-app/i18n/en.json new file mode 100644 index 00000000..74ef5398 --- /dev/null +++ b/crates/accshift-app/i18n/en.json @@ -0,0 +1,753 @@ +{ + "app.loadingSettings": "Loading settings...", + "app.loading": "Loading...", + "app.searchPlaceholder": "Search account...", + "app.noAccountsFound": "No {platform} accounts found", + "app.noAccountsHint": "Make sure {platform} is installed and you have logged in at least once.", + "app.switchingAccount": "Switching account...", + "app.platformRestarting": "{platform} is restarting", + "app.comingSoon": "{platform} - Coming soon", + "app.platformUnsupportedOs": "{platform} is not available on this OS yet", + "app.noCompatiblePlatforms": "No compatible platform is available on this OS", + "app.noCompatiblePlatformsHint": "Check back on Windows support updates or switch to a supported machine.", + "update.ctaAvailable": "Update available", + "update.ctaInstalling": "Installing...", + "update.restartToApplyVersion": "Restart to apply update {version}", + "update.restartToApply": "Restart to apply update", + "update.readyToastVersion": "Update {version} ready", + "update.readyToast": "Update ready", + "update.restartFailed": "Could not restart to apply update", + "pin.lockedTitle": "App Locked", + "pin.lockedPrompt": "Enter PIN to unlock", + "pin.placeholder": "4-digit PIN", + "pin.unlock": "Unlock", + "pin.invalid": "Invalid PIN", + "pin.tooManyAttempts": "Too many attempts. Try again in {seconds} s.", + "common.ok": "OK", + "common.cancel": "Cancel", + "common.confirm": "Confirm", + "common.close": "Close", + "common.back": "Back", + "common.active": "Active", + "common.choose": "Choose...", + "common.none": "None", + "common.custom": "Custom", + "common.configured": "Configured", + "common.missing": "Missing", + "common.enabled": "Enabled", + "common.disabled": "Disabled", + "common.on": "On", + "common.off": "Off", + "common.add": "Add", + "common.visible": "Visible", + "common.hidden": "Hidden", + "common.inline": "Inline", + "common.tooltip": "Tooltip", + "titlebar.refresh": "Refresh", + "titlebar.addAccount": "Add account", + "titlebar.settings": "Settings", + "titlebar.personas": "Personas", + "titlebar.minimize": "Minimize", + "titlebar.maximize": "Maximize", + "titlebar.restore": "Restore", + "titlebar.close": "Close", + "view.grid": "Grid view", + "view.list": "List view", + "settings.title": "Settings", + "settings.general": "General", + "settings.appearance": "Appearance", + "settings.language": "Language", + "settings.theme": "Theme", + "settings.uiZoom": "UI zoom", + "settings.backgroundOpacity": "Background opacity", + "settings.animations": "Animations", + "settings.animationsSystem": "Follow system", + "settings.animationsOn": "Always on", + "settings.animationsOff": "Off", + "settings.performance": "Performance", + "settings.suspendGraphicsWhenMinimized": "Suspend graphics when minimized", + "settings.minimizeOnAccountSwitch": "Minimize on account switch", + "settings.system": "System", + "settings.startWithSystem": "Launch at startup", + "settings.startWithSystemHint": "Start Accshift with Windows, minimized to the notification area.", + "settings.closeToTray": "Close to the notification area", + "settings.closeToTrayHint": "The close button hides Accshift in the notification area instead of quitting.", + "settings.autostartFailed": "Could not change the launch-at-startup setting.", + "tray.open": "Open Accshift", + "tray.quit": "Quit", + "settings.platforms": "Platforms", + "settings.platformSearchPlaceholder": "Search platforms...", + "settings.platformSearchNoResults": "No platform matches your search.", + "settings.accountDisplay": "Account display", + "settings.startupAndExtras": "Startup", + "settings.defaultOnStartup": "Default platform on startup", + "settings.platformDisabledSuffix": "(disabled)", + "settings.platformUnsupportedOs": "Unsupported OS", + "settings.platformNotImplemented": "Planned", + "settings.platformUserProvided": "Added from your descriptors folder", + "settings.customPlatforms": "Custom platforms", + "settings.customPlatformsHint": "Descriptor files in this folder are read when the app starts.", + "settings.customPlatformsNone": "No descriptor loaded from this folder.", + "settings.customPlatformsProblems": "Files that did not load", + "descriptor.addFromFile": "Add from a file", + "descriptor.reload": "Reload", + "descriptor.openFolder": "Open folder", + "descriptor.remove": "Remove", + "descriptor.removeConfirmTitle": "Remove {platform}?", + "descriptor.removeConfirmMessage": "Its descriptor file leaves your folder. Accounts already captured stay on disk.", + "descriptor.previewTitle": "Add a platform", + "descriptor.previewOs": "Described for {list}", + "descriptor.previewRoots": "Folders it may touch", + "descriptor.previewSteps": "What a switch would do", + "descriptor.previewWarnings": "Warnings", + "descriptor.previewNoPlan": "No dry run could be built: {problem}", + "descriptor.previewReplaces": "This replaces the descriptor already in your folder.", + "descriptor.previewNothingWritten": "Nothing is written until you add it.", + "descriptor.replace": "Replace", + "descriptor.installed": "{platform} added", + "descriptor.removed": "{platform} removed", + "descriptor.actionRead": "read", + "descriptor.actionCapture": "capture", + "descriptor.actionRestore": "restore", + "descriptor.actionDelete": "delete", + "descriptor.actionClose": "close", + "descriptor.actionLaunch": "launch", + "settings.dataRefresh": "Data Refresh", + "settings.avatarRefresh": "Avatar refresh", + "settings.banCheckDelay": "Ban check delay", + "settings.refreshNow": "Refresh now", + "settings.zeroEachLaunch": "0 = each launch", + "settings.privacy": "Privacy", + "settings.inactivityTimeout": "Inactivity timeout", + "settings.zeroDisabled": "0 = disabled", + "settings.streamerMode": "Streamer mode", + "settings.streamerModeHint": "Blur account details automatically when streaming software is running.", + "settings.personasHint": "Show the personas identity switcher", + "settings.replayOnboarding": "Replay onboarding", + "settings.replayOnboardingHint": "Show the welcome tour and the usage-statistics choice again.", + "streamer.title": "Streamer mode", + "streamer.disable": "Disable", + "streamer.disablePermanently": "Disable permanently", + "personas.title": "Personas", + "personas.create": "New persona", + "personas.activate": "Switch to this persona", + "personas.edit": "Edit", + "personas.delete": "Delete", + "personas.finish": "Done", + "personas.switching": "Switching...", + "personas.switched": "Switched to {name}", + "personas.switchPartial": "{name}: failed on {failed}", + "personas.switchFailed": "Could not switch to {name}", + "personas.namePlaceholder": "Persona name", + "personas.choosePlatforms": "Pick an account for each platform to include", + "personas.chooseAccount": "Pick the {platform} account", + "personas.removeFromPersona": "Remove from this persona", + "personas.customImage": "Choose an image...", + "personas.removeImage": "Remove image", + "personas.imageError": "Couldn't load that image.", + "personas.loadingAccounts": "Loading accounts...", + "personas.noAccountsOnPlatform": "No accounts on this platform yet", + "personas.emptyHint": "One click switches every included platform to its account.", + "settings.security": "Security", + "settings.pinLockOnAfk": "PIN lock on AFK", + "settings.pinCode": "PIN code", + "settings.pinPlaceholder": "4 digits", + "settings.pinCurrentCode": "Current PIN", + "settings.pinCurrentHint": "Enter the current PIN to change or turn off the lock.", + "settings.telemetry": "Usage statistics (anonymous)", + "settings.telemetryModeA": "Anonymous basic stats", + "settings.telemetryModeAHint": "Counts launches and which features get used. A random local ID prevents duplicate installation counts; no personal data is sent.", + "settings.telemetryModeB": "Enhanced stats with local UUID (opt-in)", + "settings.telemetryModeBHint": "Adds a random UUID stored only on this machine. Lets us measure retention and which features matter. Still no personal data sent.", + "settings.telemetryExport": "Export my data", + "settings.telemetryExported": "Data exported to clipboard", + "settings.telemetryExportFailed": "Export failed", + "settings.telemetryEnableFailed": "Could not enable enhanced stats", + "settings.telemetryDisableFailed": "Could not disable enhanced stats", + "settings.telemetryDeletePending": "Enhanced stats are off; server deletion will need another attempt", + "settings.telemetryDeletePendingHint": "Server deletion is pending. The old local ID is kept only so you can retry deletion or export the associated data.", + "settings.telemetryRetryDelete": "Retry data deletion", + "settings.telemetryDeleteCompleted": "Server data deleted", + "settings.telemetryDeleteRetryFailed": "Server deletion is still pending", + "settings.telemetryLearnMore": "What data is sent?", + "onboarding.welcome.title": "Welcome to Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "Your operating system is compatible with:", + "onboarding.welcome.detectedPlatforms": "We found these platforms on your machine:", + "onboarding.welcome.next": "Show me around", + "onboarding.welcome.skip": "Skip", + "onboarding.features.hint": "This part of the app isn't visible right now.", + "onboarding.features.addAccount.label": "Add an account", + "onboarding.features.addAccount.body": "The + button at the top right adds an account to the current platform. Log in once, Accshift keeps it.", + "onboarding.features.switch.label": "Switch account", + "onboarding.features.switch.body": "Click a card and you're on that account. That's the whole app, honestly.", + "onboarding.features.contextMenu.label": "Right-click for actions", + "onboarding.features.contextMenu.body": "Right-click a card to rename it, change its color, move it to a folder or delete it.", + "onboarding.features.platformSwitch.label": "Switch platform", + "onboarding.features.platformSwitch.body": "Same deal for Riot, Battle.net, Epic and the rest. The tabs up top switch platforms.", + "onboarding.features.next": "Next", + "onboarding.features.back": "Back", + "onboarding.features.step": "Step {step}", + "onboarding.features.mockRename": "Rename", + "onboarding.features.mockSetColor": "Set color", + "onboarding.features.mockMoveToFolder": "Move to folder", + "onboarding.features.mockDelete": "Delete", + "onboarding.features.mockAccount": "Account {number}", + "onboarding.telemetry.title": "SELL YOUR SOUL TO ACCSHIFT!", + "onboarding.telemetry.intro": "Accshift counts a few anonymous usage stats so I know what to fix first. Nothing personal leaves your machine, the app works the same either way, and you can change your mind anytime in Settings.", + "onboarding.telemetry.question": "Deal?", + "onboarding.telemetry.gifAlt": "Trade offer meme", + "onboarding.telemetry.basic": "NO! Enough is enough", + "onboarding.telemetry.basicDefault": "(default)", + "onboarding.telemetry.basicHint": "Anonymous counters and nothing else: no profile, no identifier that follows you around.", + "onboarding.telemetry.deal": "DEAL", + "onboarding.telemetry.dealHint": "Adds a separate UUID for enhanced stats, stored only on your machine. Still no idea who you are, but you help a lot. Thank you!", + "onboarding.telemetry.optOutNote": "Whatever you pick, you can change it later in Settings -> Privacy. :c", + "onboarding.telemetry.refuse": "No thanks, turn it all off", + "onboarding.telemetry.refuseHint": "Nothing is measured and nothing is sent, not even the anonymous counters.", + "onboarding.telemetry.back": "Back", + "onboarding.persistFailed": "Could not save that choice. Try again.", + "onboarding.persistLater": "Ask me next launch", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Run Steam as admin", + "settings.showUsernames": "Show usernames", + "settings.showSteamLastLogin": "Show Steam last login", + "settings.showRiotLastLogin": "Show Riot last login", + "settings.showBattleNetLastLogin": "Show Battle.net last login", + "settings.showUbisoftLastLogin": "Show Ubisoft last login", + "settings.showNotesUnderCards": "Show notes under cards", + "settings.expandedFolders": "Expand all folders", + "list.rootSection": "Root", + "settings.steamLaunch": "Switch", + "settings.steamInstallation": "Installation", + "settings.steamApiAndData": "API & data", + "settings.launchOptions": "Launch options", + "settings.shutdownMode": "Shutdown mode", + "settings.shutdownModeGraceful": "Graceful", + "settings.shutdownModeForce": "Force kill", + "settings.autoDetected": "Auto-detected", + "settings.steamFolder": "Steam folder", + "settings.steamFolderRegistry": "Registry", + "settings.riotClientPath": "Riot client executable", + "settings.battleNetPath": "Battle.net executable", + "settings.ubisoftPath": "Ubisoft Connect folder", + "settings.steamWebApiKey": "Steam Web API key", + "settings.getKey": "Get key", + "settings.pasteApiKey": "Paste your Steam Web API key", + "settings.saved": "Settings saved", + "settings.openApiKeyFailed": "Could not open Steam API key page", + "settings.integrations": "Integrations", + "settings.deepLinks": "Allow accshift:// links", + "settings.deepLinksHint": "Let other apps switch accounts for you: bookmarks, Stream Deck, scripts.", + "settings.cliEnabled": "Allow the accshift CLI", + "settings.cliEnabledHint": "Let the command line tool list and switch accounts. When off, every accshift command refuses to run, diag included; the app itself is unaffected.", + "settings.help": "Open the accshift wiki", + "settings.helpShort": "Help", + "settings.openHelpFailed": "Could not open the wiki", + "settings.logs": "Logs", + "settings.openLogs": "Open the logs folder", + "settings.openLogsFailed": "Could not open the logs folder", + "context.loading": "Loading...", + "context.noGamesFound": "No games found", + "context.field.cut": "Cut", + "context.field.copy": "Copy", + "context.field.paste": "Paste", + "context.field.selectAll": "Select all", + "context.menu.newFolder": "New folder", + "context.menu.refresh": "Refresh", + "context.menu.rename": "Rename", + "context.menu.copy": "Copy...", + "context.menu.deleteFolder": "Delete folder", + "context.menu.folderColor": "Folder color", + "context.menu.moveToFolder": "Move to folder", + "context.menu.moveToRoot": "No folder", + "context.menu.editCardAndColor": "Edit card and color", + "context.menu.cardColor": "Card color", + "context.menu.addNote": "Add note", + "context.menu.editNote": "Edit note", + "platform.rename": "Rename", + "platform.renameTitle": "Rename account", + "platform.renamePlaceholder": "Account name", + "platform.newAccountPending": "Waiting...", + "platform.setupBusy": "Another accshift operation is running. Waiting for it to finish.", + "platform.renameNewAccount": "Name this account", + "platform.copyLabelAccountId": "Account ID", + "platform.forget": "Forget", + "platform.forgetConfirmTitle": "Forget \"{display}\"?", + "platform.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "platform.forgotAccount": "Forgot {display}", + "dialog.newFolderTitle": "New folder", + "dialog.renameFolderTitle": "Rename folder", + "dialog.folderNamePlaceholder": "Folder name", + "dialog.cardNoteTitle": "Card note", + "dialog.cardNotePlaceholder": "Write a short note", + "card.noteAttached": "Note attached", + "card.clickAgainToSwitch": "Click again to switch", + "card.extensionWarnings": "Warnings", + "card.extensionNote": "Note", + "card.cs2Section": "CS2", + "card.cs2Level": "Level {level}", + "card.cs2CaseEarned": "Weekly case earned", + "card.cs2CaseNotEarned": "Weekly case pending", + "list.selectAccountPreview": "Select an account to preview", + "preview.noteLabel": "Note", + "preview.currentlyActive": "Currently active", + "preview.switchAgain": "Switch Again", + "preview.switchAccount": "Switch Account", + "ban.community": "Community ban", + "ban.vac.single": "{count} VAC ban", + "ban.vac.multiple": "{count} VAC bans", + "ban.game.single": "{count} game ban", + "ban.game.multiple": "{count} game bans", + "time.unknown": "unknown", + "time.justNow": "just now", + "time.minuteAgo": "{count} minute ago", + "time.minutesAgo": "{count} minutes ago", + "time.hourAgo": "{count} hour ago", + "time.hoursAgo": "{count} hours ago", + "time.dayAgo": "{count} day ago", + "time.daysAgo": "{count} days ago", + "time.neverConnected": "never connected", + "time.compactWrapper": "({value})", + "toast.copied": "{label} copied", + "toast.legacyConfigMigrated": "Settings migrated to new format", + "toast.legacyConfigMigrationFailed": "Failed to migrate old settings, check logs", + "toast.failedLoadSettingsPanel": "Failed to load settings panel", + "toast.banChecking": "Checking bans...", + "toast.banCheckSummary.single": "Ban check: {count} account with bans", + "toast.banCheckSummary.multiple": "Ban check: {count} accounts with bans", + "toast.banCheckFailed": "Ban check failed: {error}", + "toast.avatarRefreshComplete": "Avatar refresh finished for {count} accounts", + "toast.banRefreshComplete": "Ban refresh finished for {count} accounts", + "toast.banRefreshNoApiKey": "Ban refresh skipped: add a Steam Web API key in settings", + "toast.refreshPartial": "Refresh finished for {ok} of {count} accounts", + "toast.noSteamAccountsFound": "No Steam accounts found. Sign in to Steam at least once, then refresh.", + "toast.accountsRefreshed.single": "{count} account refreshed", + "toast.accountsRefreshed.multiple": "{count} accounts refreshed", + "toast.steamFolderNotFound": "Steam folder was not found. Set it manually in Settings > Steam folder.", + "toast.steamElevated": "Steam is running as admin. Enable 'Run Steam as admin' in Settings > Steam.", + "toast.noRiotProfilesFound": "No Riot profiles yet. Click + to add one.", + "toast.noBattleNetAccountsFound": "No Battle.net accounts found. Sign in to Battle.net at least once, then refresh.", + "toast.noUbisoftAccountsFound": "No Ubisoft accounts found. Sign in to Ubisoft Connect at least once, then refresh.", + "toast.deepLinkInvalid": "Unrecognized accshift:// link", + "toast.deepLinkDisabled": "accshift:// links are disabled in Settings", + "toast.deepLinkUnknownPlatform": "Unknown platform in link: {platform}", + "toast.deepLinkPlatformDisabled": "{platform} is disabled in Settings", + "toast.deepLinkAccountNotFound": "No {platform} account matching \"{account}\"", + "toast.deepLinkSwitched": "Switched to {account}", + "dialog.deepLinkSwitchTitle": "Switch account?", + "dialog.deepLinkSwitchMessage": "An accshift:// link is asking to switch your {platform} account to \"{account}\". Continue?", + "dialog.deepLinkSwitchConfirm": "Switch", + "steam.launchOnline": "Switch online", + "steam.launchInvisible": "Switch invisible", + "steam.switchAndRunGame": "Switch + run {game}", + "steam.switchAndRunPick": "Switch + run a game…", + "steam.runGame": "Run {game}", + "steam.runPick": "Run a game…", + "steam.switchAndRunNoGames": "No games found", + "steam.clearDefaultGame": "Clear default game", + "steam.copySteamId64": "Copy SteamID64", + "steam.copyFriendCode": "Copy Friend Code", + "steam.copyProfileUrl": "Copy profile URL", + "steam.copyLabelUsername": "Username", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Friend Code", + "steam.copyLabelProfileUrl": "Profile URL", + "steam.openUserdataFolder": "Open userdata folder", + "steam.clearIntegratedBrowserCache": "Reset browser session", + "steam.clearIntegratedBrowserCacheConfirmTitle": "Reset Steam browser session?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "This closes Steam and clears the integrated browser cache used by Store and Community.", + "steam.clearedIntegratedBrowserCache": "Steam browser session reset", + "steam.copySettingsFrom": "Copy settings from", + "steam.copiedSettingsToCurrent": "Copied {game} settings to current account", + "steam.forget": "Forget", + "steam.forgetConfirmTitle": "Forget \"{display}\"?", + "steam.forgetConfirmMessage": "This will remove this account from your Steam account list on this PC.", + "steam.forgotAccount": "Forgot {username}", + "steam.setupPendingLabel": "Steam Account", + "steam.setupWaitingForClient": "Waiting for Steam...", + "steam.setupWaitingForLogin": "Sign in to the Steam account you want to add.", + "steam.setupDetected": "Detected account", + "steam.setupConnected": "Connected", + "steam.setupReady": "Steam account added", + "steam.setupReadyWithProfile": "Added {profile}", + "steam.setupFailed": "Steam setup failed", + "steam.setupFailedMessage": "Accshift could not finish adding this Steam account.", + "riot.captureSession": "Capture current session", + "riot.recaptureSession": "Re-capture session", + "riot.capturedSession": "Captured session for {profile}", + "riot.setupPendingLabel": "Riot Account", + "riot.setupWaitingForClient": "Waiting for Riot Client...", + "riot.setupWaitingForLogin": "Sign in to the Riot account you want to add.", + "riot.setupCapturing": "Saving Riot session...", + "riot.setupReady": "Riot account added", + "riot.setupReadyWithProfile": "Added {profile}", + "riot.setupFailed": "Riot setup failed", + "riot.setupFailedMessage": "Accshift could not finish adding this Riot account.", + "riot.setupStaySignedIn": "Keep 'Stay signed in' enabled.", + "riot.setupDetected": "Detected account", + "riot.setupConnected": "Connected", + "riot.forget": "Forget account", + "riot.forgetConfirmTitle": "Forget \"{display}\"?", + "riot.forgetConfirmMessage": "This removes the local Riot profile and its saved session from Accshift.", + "riot.forgotProfile": "Forgot {profile}", + "battlenet.copyUsername": "Copy username", + "battlenet.copyLabelUsername": "Username", + "battlenet.copyEmail": "Copy email", + "battlenet.copyLabelEmail": "Email", + "battlenet.forget": "Forget", + "battlenet.forgetConfirmTitle": "Forget \"{display}\"?", + "battlenet.forgetConfirmMessage": "This will remove this account from the Battle.net account list on this PC.", + "battlenet.forgotAccount": "Forgot {email}", + "battlenet.setupPendingLabel": "Battle.net Account", + "battlenet.setupWaitingForClient": "Waiting for Battle.net...", + "battlenet.setupWaitingForLogin": "Sign in to the Battle.net account you want to add.", + "battlenet.setupKeepMeLoggedIn": "Keep 'Keep me logged in' enabled when possible.", + "battlenet.setupDetected": "Detected account", + "battlenet.setupConnected": "Connected", + "battlenet.setupReady": "Battle.net account added", + "battlenet.setupReadyWithProfile": "Added {profile}", + "battlenet.setupFailed": "Battle.net setup failed", + "battlenet.setupFailedMessage": "Accshift could not finish adding this Battle.net account.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Forget", + "ubisoft.forgetConfirmTitle": "Forget \"{display}\"?", + "ubisoft.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "ubisoft.forgotAccount": "Forgot {display}", + "ubisoft.setupPendingLabel": "Ubisoft Account", + "ubisoft.setupWaitingForClient": "Waiting for Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Sign in to the Ubisoft account you want to add.", + "ubisoft.setupKeepMeLoggedIn": "Keep 'Remember me' enabled.", + "ubisoft.setupDetected": "Detected account", + "ubisoft.setupConnected": "Connected", + "ubisoft.setupReady": "Ubisoft account added", + "ubisoft.setupReadyWithProfile": "Added {profile}", + "ubisoft.setupFailed": "Ubisoft setup failed", + "ubisoft.setupFailedMessage": "Accshift could not finish adding this Ubisoft account.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "No Roblox accounts found. Add an account using Quick Login or paste a cookie.", + "roblox.noAccountsHint": "Press + to add a Roblox account via Quick Login.", + "roblox.sessionExpired": "Session expired", + "roblox.sessionExpiredTooltip": "This account is logged out. Re-add it before switching.", + "roblox.sessionExpiredSwitchError": "Session expired. Re-add this account to switch. Notes and settings are kept.", + "roblox.cookiePasteTitle": "Add account by cookie", + "roblox.cookiePasteHint": "Paste a .ROBLOSECURITY cookie from your browser to add an account directly.", + "settings.showRobloxLastLogin": "Show last login", + "settings.robloxHealthCheck": "Session check", + "settings.robloxHealthCheckHint": "Flags accounts whose Roblox session expired; one network request per launch.", + "roblox.copyLabelUsername": "Username", + "roblox.copyLabelUserId": "User ID", + "roblox.forget": "Forget", + "roblox.forgetConfirmTitle": "Forget \"{display}\"?", + "roblox.forgetConfirmMessage": "This will remove this account and its stored cookie from this PC.", + "roblox.forgotAccount": "Forgot {display}", + "roblox.setupPendingLabel": "Roblox Account", + "roblox.setupWaitingForClient": "Connecting to Roblox...", + "roblox.setupWaitingForLogin": "Open the Roblox app on another device and enter the code below.", + "roblox.setupQuickLoginCode": "Quick Login code", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Copy code", + "roblox.setupDetected": "Detected account", + "roblox.setupConnected": "Connected", + "roblox.setupReady": "Roblox account added", + "roblox.setupReadyWithProfile": "Added {profile}", + "roblox.setupFailed": "Roblox setup failed", + "roblox.setupFailedMessage": "Accshift could not finish adding this Roblox account.", + "settings.epic": "Epic Games", + "settings.epicPath": "Epic Games Launcher executable", + "settings.showEpicLastLogin": "Show last login", + "toast.noEpicAccountsFound": "No Epic Games accounts found. Sign in to Epic Games Launcher at least once, then refresh.", + "epic.noAccountsHint": "Press + to add an Epic Games account.", + "epic.copyLabelAccountId": "Account ID", + "epic.forget": "Forget", + "epic.forgetConfirmTitle": "Forget \"{display}\"?", + "epic.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "epic.forgotAccount": "Forgot {display}", + "epic.setupPendingLabel": "Epic Games Account", + "epic.setupWaitingForClient": "Waiting for Epic Games Launcher...", + "epic.setupWaitingForLogin": "Sign in to the Epic Games account you want to add.", + "epic.setupKeepMeLoggedIn": "Keep 'Remember me' enabled.", + "epic.setupDetected": "Detected account", + "epic.setupConnected": "Connected", + "epic.setupReady": "Epic Games account added", + "epic.setupReadyWithProfile": "Added {profile}", + "epic.setupFailed": "Epic Games setup failed", + "epic.setupFailedMessage": "Accshift could not finish adding this Epic Games account.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "GOG Galaxy executable", + "settings.showGogLastLogin": "Show last login", + "toast.noGogAccountsFound": "No GOG Galaxy accounts found. Sign in to GOG Galaxy at least once, then refresh.", + "gog.noAccountsHint": "Press + to add a GOG Galaxy account.", + "gog.copyLabelAccountId": "Account ID", + "gog.forget": "Forget", + "gog.forgetConfirmTitle": "Forget \"{display}\"?", + "gog.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "gog.forgotAccount": "Forgot {display}", + "gog.setupPendingLabel": "GOG Galaxy Account", + "gog.setupWaitingForClient": "Waiting for GOG Galaxy...", + "gog.setupWaitingForLogin": "Sign in to the GOG Galaxy account you want to add.", + "gog.setupKeepMeLoggedIn": "Keep 'Stay signed in' enabled.", + "gog.setupDetected": "Detected account", + "gog.setupConnected": "Connected", + "gog.setupReady": "GOG Galaxy account added", + "gog.setupReadyWithProfile": "Added {profile}", + "gog.setupFailed": "GOG Galaxy setup failed", + "gog.setupFailedMessage": "Accshift could not finish adding this GOG Galaxy account.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Jagex Launcher executable", + "settings.showJagexLastLogin": "Show last login", + "toast.noJagexAccountsFound": "No Jagex accounts saved yet. Press + to add one through the Jagex Launcher.", + "jagex.noAccountsHint": "Press + to add a Jagex account.", + "jagex.copyLabelAccountId": "Account ID", + "jagex.forget": "Forget", + "jagex.forgetConfirmTitle": "Forget \"{display}\"?", + "jagex.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "jagex.forgotAccount": "Forgot {display}", + "jagex.setupPendingLabel": "Jagex Account", + "jagex.setupWaitingForClient": "Waiting for the Jagex Launcher...", + "jagex.setupWaitingForLogin": "Sign in to the Jagex account you want to add.", + "jagex.setupKeepMeLoggedIn": "Keep 'Remember me' enabled.", + "jagex.setupDetected": "Detected account", + "jagex.setupConnected": "Connected", + "jagex.setupReady": "Jagex account added", + "jagex.setupReadyWithProfile": "Added {profile}", + "jagex.setupFailed": "Jagex setup failed", + "jagex.setupFailedMessage": "Accshift could not finish adding this Jagex account.", + "settings.discord": "Discord", + "settings.discordPath": "Discord executable", + "settings.showDiscordLastLogin": "Show last login", + "toast.noDiscordAccountsFound": "No Discord accounts found. Add a Discord account, then refresh.", + "discord.noAccountsHint": "Press + to add a Discord account.", + "discord.copyLabelAccountId": "Account ID", + "discord.forget": "Forget", + "discord.forgetConfirmTitle": "Forget \"{display}\"?", + "discord.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "discord.forgotAccount": "Forgot {display}", + "discord.setupPendingLabel": "Discord Account", + "discord.setupWaitingForClient": "Waiting for Discord...", + "discord.setupWaitingForLogin": "Sign in to the Discord account you want to add.", + "discord.setupKeepMeLoggedIn": "Stay signed in to keep this account.", + "discord.setupDetected": "Detected account", + "discord.setupConnected": "Connected", + "discord.setupReady": "Discord account added", + "discord.setupReadyWithProfile": "Added {profile}", + "discord.setupFailed": "Discord setup failed", + "discord.setupFailedMessage": "Accshift could not finish adding this Discord account.", + "color.none": "Default", + "color.blue": "Blue", + "color.cyan": "Cyan", + "color.green": "Green", + "color.lime": "Lime", + "color.yellow": "Yellow", + "color.orange": "Orange", + "color.red": "Red", + "color.pink": "Pink", + "color.violet": "Violet", + "color.gray": "Gray", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Dark", + "theme.light": "Light", + "theme.midnight": "Midnight", + "theme.glassDark": "Glass Dark", + "theme.glassLight": "Glass Light", + "theme.liquidGlass": "Liquid Glass", + "theme.custom": "Custom", + "settings.themeImport": "Import", + "settings.themeExport": "Export", + "settings.themeDelete": "Delete theme", + "settings.themeImported": "Theme imported", + "settings.themeDeleted": "Theme deleted", + "settings.themeExportCopied": "Theme JSON copied to clipboard", + "settings.themeInvalidJson": "Invalid theme JSON", + "settings.themeCustomize": "Customize", + "settings.themeEdit": "Edit", + "settings.themeCopyName": "{name} copy", + "settings.themeSaved": "Theme saved", + "settings.themeMigrated": "Theme imported and updated to the current format", + "settings.themeInvalidId": "The theme identifier is missing or invalid", + "settings.themeInvalidName": "The theme has no name", + "settings.themeUnsupportedVersion": "This theme needs a newer version of accshift", + "themeGroup.surface": "Surfaces", + "themeGroup.text": "Text", + "themeGroup.semantic": "Status colors", + "themeGroup.shape": "Shape and density", + "themeGroup.typography": "Typography", + "themeGroup.motion": "Motion", + "themeKind.rgbTriplet": "RGB triplet", + "themeKind.hexColor": "hex color", + "themeKind.color": "color", + "themeKind.length": "length", + "themeKind.shadow": "shadow", + "themeKind.choice": "choice", + "themeKind.fontStack": "font", + "themeKind.signedLength": "spacing", + "themeKind.gradient": "gradient", + "themeKind.number": "number", + "themeDensity.compact": "Compact", + "themeDensity.cozy": "Cozy", + "themeDensity.comfortable": "Comfortable", + "themeAvatarShape.circle": "Circle", + "themeAvatarShape.rounded": "Rounded", + "themeAvatarShape.square": "Square", + "themeSmoothing.auto": "Smoothed", + "themeSmoothing.none": "Pixel", + "themeToken.bgRgb": "Window background", + "themeToken.bgCard": "Card background", + "themeToken.bgCardHover": "Card background, hovered", + "themeToken.bgMuted": "Muted background", + "themeToken.bgElevated": "Elevated background", + "themeToken.border": "Borders", + "themeToken.fg": "Main text", + "themeToken.fgMuted": "Secondary text", + "themeToken.fgSubtle": "Subtle text", + "themeToken.afkText": "Away label", + "themeToken.accent": "Accent", + "themeToken.accentFg": "Text on accent", + "themeToken.success": "Success", + "themeToken.warning": "Warning", + "themeToken.danger": "Danger", + "themeToken.radiusSm": "Small radius", + "themeToken.radiusMd": "Medium radius", + "themeToken.radiusLg": "Large radius", + "themeToken.elevationLow": "Low shadow", + "themeToken.elevationMedium": "Medium shadow", + "themeToken.elevationHigh": "High shadow", + "themeToken.density": "Density", + "themeToken.fontUi": "Interface font", + "themeToken.bgImage": "Window gradient", + "themeToken.cardBgImage": "Surface gradient", + "themeToken.borderWidth": "Border width", + "themeToken.borderStyle": "Border style", + "themeToken.avatarShape": "Avatar shape", + "themeToken.focusRing": "Focus ring", + "themeToken.overlayBlur": "Overlay blur", + "themeToken.fontDisplay": "Heading font", + "themeToken.lineHeight": "Line height", + "themeToken.letterSpacing": "Letter spacing", + "themeToken.labelCase": "Label case", + "themeToken.fontSmoothing": "Font smoothing", + "themeToken.motionScale": "Animation speed", + "themeEditor.title": "Theme editor", + "themeEditor.name": "Name", + "themeEditor.author": "Author", + "themeEditor.version": "Version", + "themeEditor.base": "Inherits from", + "themeEditor.colorScheme": "Color scheme", + "themeEditor.glass": "Translucent surfaces", + "themeEditor.inherited": "Inherited", + "themeEditor.builtInBase": "Built-in theme", + "themeEditor.reset": "Reset to the inherited value", + "themeEditor.issues": "Checks", + "themeEditor.noIssues": "Nothing to report", + "themeEditor.fixErrorsFirst": "Fix the refused values before saving", + "themeEditor.save": "Save", + "themeEditor.saveFailed": "Could not save the theme. Try again.", + "themeEditor.export": "Copy", + "themeEditor.issueInvalidValue": "{token}: not a valid {kind}, for example {example}", + "themeEditor.issueUnknownToken": "{token} is not a theme value, it is ignored", + "themeEditor.issueMissingToken": "{token} is not set, the built-in value is used", + "themeEditor.issueUnknownBase": "Base theme {base} does not exist, built-in values are used", + "themeEditor.issueContrast": "{token} on {against}: {ratio} to 1, below the {target} to 1 target", + "themeEditor.beta": "Beta", + "themeEditor.betaNote": "The theme format is still moving: a theme written today may need a pass after an update.", + "themeEditor.customCss": "Custom CSS", + "themeEditor.customCssHelp": "Applied on top of the theme, for what the values above cannot reach. No @import and no url(): a theme never fetches anything.", + "themeEditor.issueUnsafeCss": "{construct} is not allowed in a theme, the CSS is not applied", + "themeEditor.issueCssTooLong": "CSS too long: {length} characters, the limit is {limit}", + "bulkEdit.title": "Bulk edit", + "bulkEdit.selectAccounts": "Select accounts", + "bulkEdit.selectAll": "Select all", + "bulkEdit.deselectAll": "Deselect all", + "bulkEdit.next": "Next", + "bulkEdit.apply": "Apply", + "bulkEdit.settings": "Settings", + "bulkEdit.newsPopup": "Game news popup", + "bulkEdit.doNotDisturb": "Do not disturb", + "bulkEdit.launchOptions": "Launch options per game", + "bulkEdit.unchanged": "Unchanged", + "bulkEdit.addLaunchOption": "Add", + "bulkEdit.selectGame": "Select a game...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "The currently active account is selected. Changes will apply on next Steam restart.", + "bulkEdit.toastSuccess": "Bulk edit: {count} succeeded", + "bulkEdit.toastPartial": "Bulk edit: {succeeded} succeeded, {failed} failed", + "bulkEdit.toastFailed": "Bulk edit failed", + "bulkEdit.noAccountsSelected": "No accounts selected", + "bulkEdit.selected": "{count} selected", + "bulkEdit.loadingGames": "Loading games...", + "bulkEdit.copyUrls": "Copy URLs", + "bulkEdit.urlsCopied": "{count} profile URL(s) copied", + "bulkEdit.cardColor": "Card color", + "bulkEdit.colorApplied": "Color applied to {count} card(s)", + "bulkEdit.colorCleared": "Color cleared on {count} card(s)", + "common.retry": "Retry", + "app.searchNoResults": "No results for \"{query}\"", + "app.folderEmpty": "This folder is empty", + "app.loadErrorGeneric": "Failed to load accounts. See the console for details.", + "toast.dismiss": "Dismiss", + "toast.switchFailed": "Account switch failed. See the console for details.", + "toast.addAccountFailed": "Couldn't add the account. Check the app logs for details.", + "toast.addAccountClientMissing": "{platform} doesn't seem to be installed. Install it, or set its location in Settings.", + "toast.banCheckFailedGeneric": "Ban check failed. See the console for details.", + "toast.renameAccountFailed": "Rename failed: {error}", + "toast.copyFailed": "Couldn't copy to the clipboard", + "toast.refreshFailed": "Refresh failed. See the console for details.", + "dialog.deleteFolderTitle": "Delete \"{name}\"?", + "dialog.deleteFolderMessage": "Subfolders are deleted too. Accounts inside are moved back up, not removed.", + "personas.deleteConfirm": "Click again to delete", + "personas.discardConfirm": "Click again to discard", + "personas.switchConfirmTitle": "Switch persona", + "personas.switchConfirmMessage": "Switch {count} platform(s) to \"{name}\"? Each client will close and relaunch.", + "personas.switchConfirmAction": "Switch", + "personas.deleteTitle": "Delete persona", + "personas.deleteMessage": "Delete \"{name}\"? The accounts themselves are not touched.", + "context.submenuError": "Couldn't load this menu", + "settings.apiKeySaveFailed": "Couldn't save the Steam API key", + "settings.apiKeyInvalidHint": "The key wasn't saved. Check it and try again.", + "settings.apiKeyCleared": "Steam API key cleared", + "settings.apiKeyClearFailed": "Couldn't clear the Steam API key", + "settings.clearApiKey": "Clear key", + "settings.cs2Bridge": "CS2 stats bridge", + "settings.cs2BridgeLink": "Bridge link", + "settings.cs2BridgeLinkPlaceholder": "https://your-server/api/bridge/accshift/", + "settings.cs2BridgeTest": "Test", + "settings.cs2BridgeStatusOk": "Connected · {count} accounts · {ms} ms", + "settings.cs2BridgeStatusFail": "Connection failed", + "settings.cs2BridgeStatusTesting": "Testing...", + "settings.cs2BridgeStatusUnknown": "Not tested", + "settings.cs2BridgeToken": "API token", + "settings.cs2BridgeTokenOptional": "optional, for custom servers", + "settings.cs2BridgeTokenPlaceholder": "Bearer token if your server needs one", + "settings.cs2BridgeSaveFailed": "Couldn't save the CS2 bridge settings", + "settings.cs2BridgeClearToken": "Clear token", + "settings.pathSaveFailed": "Couldn't save the {platform} path", + "settings.pathInvalidHint": "This path couldn't be saved. Check it and try again.", + "settings.pathSteamNeverSignedIn": "Steam found, sign in once so it creates its login history", + "settings.pathNotSteamFolder": "This folder does not look like a Steam installation", + "settings.pathNotADirectory": "The path must be an existing folder", + "settings.pinSaved": "PIN saved", + "settings.pinTakesEffectNextLaunch": "Takes effect the next time the app starts", + "settings.pinRequiredAfterInactivity": "Required after inactivity and when the app starts", + "settings.pinScreenLockOnly": "The PIN locks this window and account switches. It protects nothing on disk.", + "settings.unitSeconds": "seconds", + "settings.unitDays": "days", + "settings.telemetryLoadFailed": "Couldn't load telemetry settings", + "palette.placeholder": "Type a command or account name...", + "palette.noResults": "No matching commands", + "palette.sectionAccounts": "Accounts", + "palette.sectionActions": "Actions", + "palette.sectionNavigation": "Navigation", + "palette.openTab": "Go to {platform}", + "palette.openFolder": "Open folder \"{name}\"", + "palette.backToParent": "Back to parent folder", + "palette.zoomReset": "Reset zoom" +} diff --git a/crates/accshift-app/i18n/es.json b/crates/accshift-app/i18n/es.json new file mode 100644 index 00000000..0cd5821a --- /dev/null +++ b/crates/accshift-app/i18n/es.json @@ -0,0 +1,753 @@ +{ + "app.loadingSettings": "Cargando la configuración...", + "app.loading": "Cargando...", + "app.searchPlaceholder": "Buscar cuenta...", + "app.noAccountsFound": "No se encontraron cuentas de {platform}", + "app.noAccountsHint": "Asegúrate de que {platform} esté instalado y de haber iniciado sesión al menos una vez.", + "app.switchingAccount": "Cambiando de cuenta...", + "app.platformRestarting": "{platform} se está reiniciando", + "app.comingSoon": "{platform} - Muy pronto", + "app.platformUnsupportedOs": "{platform} aún no está disponible en este sistema", + "app.noCompatiblePlatforms": "No hay ninguna plataforma compatible en este sistema", + "app.noCompatiblePlatformsHint": "Consulta las novedades de compatibilidad con Windows o usa una máquina compatible.", + "update.ctaAvailable": "Actualización disponible", + "update.ctaInstalling": "Instalando...", + "update.restartToApplyVersion": "Reiniciar para aplicar la actualización {version}", + "update.restartToApply": "Reiniciar para aplicar la actualización", + "update.readyToastVersion": "Actualización {version} lista", + "update.readyToast": "Actualización lista", + "update.restartFailed": "No se pudo reiniciar para aplicar la actualización", + "pin.lockedTitle": "Aplicación bloqueada", + "pin.lockedPrompt": "Escribe el PIN para desbloquear", + "pin.placeholder": "PIN de 4 dígitos", + "pin.unlock": "Desbloquear", + "pin.invalid": "PIN inválido", + "pin.tooManyAttempts": "Demasiados intentos. Vuelve a intentarlo en {seconds} s.", + "common.ok": "OK", + "common.cancel": "Cancelar", + "common.confirm": "Confirmar", + "common.close": "Cerrar", + "common.back": "Atrás", + "common.active": "Activa", + "common.choose": "Elegir...", + "common.none": "Ninguno", + "common.custom": "Personalizado", + "common.configured": "Configurado", + "common.missing": "Falta", + "common.enabled": "Habilitado", + "common.disabled": "Deshabilitado", + "common.on": "Activado", + "common.off": "Desactivado", + "common.add": "Añadir", + "common.visible": "Visible", + "common.hidden": "Oculto", + "common.inline": "En línea", + "common.tooltip": "Emergente", + "titlebar.refresh": "Actualizar", + "titlebar.addAccount": "Añadir cuenta", + "titlebar.settings": "Configuración", + "titlebar.personas": "Identidades", + "titlebar.minimize": "Minimizar", + "titlebar.maximize": "Maximizar", + "titlebar.restore": "Restaurar", + "titlebar.close": "Cerrar", + "view.grid": "Vista de cuadrícula", + "view.list": "Vista de lista", + "settings.title": "Configuración", + "settings.general": "General", + "settings.appearance": "Apariencia", + "settings.language": "Idioma", + "settings.theme": "Tema", + "settings.uiZoom": "Zoom de la interfaz", + "settings.backgroundOpacity": "Opacidad del fondo", + "settings.animations": "Animaciones", + "settings.animationsSystem": "Seguir al sistema", + "settings.animationsOn": "Siempre activas", + "settings.animationsOff": "Desactivadas", + "settings.performance": "Rendimiento", + "settings.suspendGraphicsWhenMinimized": "Suspender gráficos al minimizar", + "settings.minimizeOnAccountSwitch": "Minimizar al cambiar de cuenta", + "settings.system": "Sistema", + "settings.startWithSystem": "Iniciar al arrancar", + "settings.startWithSystemHint": "Inicia Accshift con Windows, minimizado en el área de notificación.", + "settings.closeToTray": "Cerrar al área de notificación", + "settings.closeToTrayHint": "El botón de cerrar oculta Accshift en el área de notificación en lugar de salir.", + "settings.autostartFailed": "No se pudo cambiar el inicio automático.", + "tray.open": "Abrir Accshift", + "tray.quit": "Salir", + "settings.platforms": "Plataformas", + "settings.platformSearchPlaceholder": "Buscar plataformas...", + "settings.platformSearchNoResults": "Ninguna plataforma coincide con tu búsqueda.", + "settings.accountDisplay": "Visualización de cuentas", + "settings.startupAndExtras": "Inicio", + "settings.defaultOnStartup": "Plataforma predeterminada al iniciar", + "settings.platformDisabledSuffix": "(desactivada)", + "settings.platformUnsupportedOs": "Sistema no compatible", + "settings.platformNotImplemented": "Planeado", + "settings.platformUserProvided": "Añadida desde tu carpeta de descriptores", + "settings.customPlatforms": "Plataformas personalizadas", + "settings.customPlatformsHint": "Los archivos de descriptor de esta carpeta se leen al iniciar la aplicación.", + "settings.customPlatformsNone": "Ningún descriptor cargado desde esta carpeta.", + "settings.customPlatformsProblems": "Archivos que no se cargaron", + "descriptor.addFromFile": "Añadir desde un archivo", + "descriptor.reload": "Recargar", + "descriptor.openFolder": "Abrir carpeta", + "descriptor.remove": "Eliminar", + "descriptor.removeConfirmTitle": "¿Eliminar {platform}?", + "descriptor.removeConfirmMessage": "Su archivo de descriptor sale de tu carpeta. Las cuentas ya capturadas siguen en el disco.", + "descriptor.previewTitle": "Añadir una plataforma", + "descriptor.previewOs": "Descrita para {list}", + "descriptor.previewRoots": "Carpetas que puede tocar", + "descriptor.previewSteps": "Lo que haría un cambio de cuenta", + "descriptor.previewWarnings": "Avisos", + "descriptor.previewNoPlan": "No se pudo crear una simulación: {problem}", + "descriptor.previewReplaces": "Esto reemplaza el descriptor que ya está en tu carpeta.", + "descriptor.previewNothingWritten": "No se escribe nada hasta que lo añadas.", + "descriptor.replace": "Reemplazar", + "descriptor.installed": "{platform} añadida", + "descriptor.removed": "{platform} eliminada", + "descriptor.actionRead": "leer", + "descriptor.actionCapture": "capturar", + "descriptor.actionRestore": "restaurar", + "descriptor.actionDelete": "eliminar", + "descriptor.actionClose": "cerrar", + "descriptor.actionLaunch": "iniciar", + "settings.dataRefresh": "Actualización de datos", + "settings.avatarRefresh": "Actualizar avatares", + "settings.banCheckDelay": "Intervalo de revisión de baneos", + "settings.refreshNow": "Actualizar ahora", + "settings.zeroEachLaunch": "0 = en cada inicio", + "settings.privacy": "Privacidad", + "settings.inactivityTimeout": "Tiempo de inactividad", + "settings.zeroDisabled": "0 = desactivado", + "settings.streamerMode": "Modo streamer", + "settings.streamerModeHint": "Difumina los datos de la cuenta automáticamente cuando hay software de streaming en ejecución.", + "settings.personasHint": "Mostrar el selector de identidades", + "settings.replayOnboarding": "Repetir la introducción", + "settings.replayOnboardingHint": "Vuelve a mostrar el tour de bienvenida y la elección sobre estadísticas de uso.", + "streamer.title": "Modo streamer", + "streamer.disable": "Desactivar", + "streamer.disablePermanently": "Desactivar permanentemente", + "personas.title": "Identidades", + "personas.create": "Nueva identidad", + "personas.activate": "Cambiar a esta identidad", + "personas.edit": "Editar", + "personas.delete": "Eliminar", + "personas.finish": "Listo", + "personas.switching": "Cambiando...", + "personas.switched": "Cambiado a {name}", + "personas.switchPartial": "{name}: falló en {failed}", + "personas.switchFailed": "No se pudo cambiar a {name}", + "personas.namePlaceholder": "Nombre de la identidad", + "personas.choosePlatforms": "Elige una cuenta para cada plataforma a incluir", + "personas.chooseAccount": "Elige la cuenta de {platform}", + "personas.removeFromPersona": "Quitar de esta identidad", + "personas.customImage": "Elegir una imagen...", + "personas.removeImage": "Quitar imagen", + "personas.imageError": "No se pudo cargar esa imagen.", + "personas.loadingAccounts": "Cargando cuentas...", + "personas.noAccountsOnPlatform": "Aún no hay cuentas en esta plataforma", + "personas.emptyHint": "Un clic cambia cada plataforma incluida a su cuenta.", + "settings.security": "Seguridad", + "settings.pinLockOnAfk": "Bloqueo con PIN al ausentarte", + "settings.pinCode": "Código PIN", + "settings.pinPlaceholder": "4 dígitos", + "settings.pinCurrentCode": "PIN actual", + "settings.pinCurrentHint": "Introduce el PIN actual para cambiar o desactivar el bloqueo.", + "settings.telemetry": "Estadísticas de uso (anónimas)", + "settings.telemetryModeA": "Estadísticas básicas anónimas", + "settings.telemetryModeAHint": "Cuenta los inicios y qué funciones se usan. Un ID local aleatorio evita contar instalaciones duplicadas; no se envía ningún dato personal.", + "settings.telemetryModeB": "Estadísticas ampliadas con UUID local (opcional)", + "settings.telemetryModeBHint": "Añade un UUID aleatorio guardado solo en esta máquina. Nos permite medir la retención y qué funciones importan. Tampoco se envía ningún dato personal.", + "settings.telemetryExport": "Exportar mis datos", + "settings.telemetryExported": "Datos exportados al portapapeles", + "settings.telemetryExportFailed": "Error al exportar", + "settings.telemetryEnableFailed": "No se pudieron activar las estadísticas ampliadas", + "settings.telemetryDisableFailed": "No se pudieron desactivar las estadísticas ampliadas", + "settings.telemetryDeletePending": "Las estadísticas ampliadas están desactivadas; el borrado en el servidor necesitará otro intento", + "settings.telemetryDeletePendingHint": "El borrado en el servidor está pendiente. El ID local antiguo se conserva solo para que puedas reintentar el borrado o exportar los datos asociados.", + "settings.telemetryRetryDelete": "Reintentar el borrado de datos", + "settings.telemetryDeleteCompleted": "Datos del servidor eliminados", + "settings.telemetryDeleteRetryFailed": "El borrado en el servidor sigue pendiente", + "settings.telemetryLearnMore": "¿Qué datos se envían?", + "onboarding.welcome.title": "Te damos la bienvenida a Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "Tu sistema operativo es compatible con:", + "onboarding.welcome.detectedPlatforms": "Estas son las plataformas detectadas en tu equipo:", + "onboarding.welcome.next": "Dame un tour", + "onboarding.welcome.skip": "Omitir", + "onboarding.features.hint": "Esta parte de la app no está visible ahora mismo.", + "onboarding.features.addAccount.label": "Añadir una cuenta", + "onboarding.features.addAccount.body": "El botón + de arriba a la derecha añade una cuenta a la plataforma actual. Inicia sesión una vez y Accshift la guarda.", + "onboarding.features.switch.label": "Cambiar de cuenta", + "onboarding.features.switch.body": "Haz clic en una tarjeta y ya estás en esa cuenta. Sinceramente, eso es toda la app.", + "onboarding.features.contextMenu.label": "Clic derecho para más acciones", + "onboarding.features.contextMenu.body": "Haz clic derecho en una tarjeta para renombrarla, cambiar su color, moverla a una carpeta o eliminarla.", + "onboarding.features.platformSwitch.label": "Cambiar de plataforma", + "onboarding.features.platformSwitch.body": "Lo mismo con Riot, Battle.net, Epic y las demás. Las pestañas de arriba cambian de plataforma.", + "onboarding.features.next": "Siguiente", + "onboarding.features.back": "Atrás", + "onboarding.features.step": "Paso {step}", + "onboarding.features.mockRename": "Renombrar", + "onboarding.features.mockSetColor": "Elegir color", + "onboarding.features.mockMoveToFolder": "Mover a carpeta", + "onboarding.features.mockDelete": "Eliminar", + "onboarding.features.mockAccount": "Cuenta {number}", + "onboarding.telemetry.title": "¡VENDE TU ALMA A ACCSHIFT!", + "onboarding.telemetry.intro": "Accshift cuenta unas pocas estadísticas de uso anónimas para saber qué arreglar primero. Nada personal sale de tu máquina, la app funciona igual de todos modos y puedes cambiar de idea cuando quieras en Configuración.", + "onboarding.telemetry.question": "¿Trato hecho?", + "onboarding.telemetry.gifAlt": "Meme de oferta de intercambio", + "onboarding.telemetry.basic": "¡NO! Ya está bien", + "onboarding.telemetry.basicDefault": "(predeterminado)", + "onboarding.telemetry.basicHint": "Solo contadores anónimos y nada más: sin perfil, sin identificador que te siga.", + "onboarding.telemetry.deal": "TRATO", + "onboarding.telemetry.dealHint": "Añade un UUID aparte para las estadísticas ampliadas, guardado solo en tu máquina. Seguimos sin saber quién eres, pero ayudas muchísimo. ¡Gracias!", + "onboarding.telemetry.optOutNote": "Elijas lo que elijas, puedes cambiarlo luego en Configuración -> Privacidad. :c", + "onboarding.telemetry.refuse": "No gracias, apágalo todo", + "onboarding.telemetry.refuseHint": "No se mide ni se envía nada, ni siquiera los contadores anónimos.", + "onboarding.telemetry.back": "Atrás", + "onboarding.persistFailed": "No se pudo guardar esa elección. Inténtalo de nuevo.", + "onboarding.persistLater": "Preguntarme en el próximo inicio", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Ejecutar Steam como administrador", + "settings.showUsernames": "Mostrar nombres de usuario", + "settings.showSteamLastLogin": "Mostrar última conexión de Steam", + "settings.showRiotLastLogin": "Mostrar última conexión de Riot", + "settings.showBattleNetLastLogin": "Mostrar última conexión de Battle.net", + "settings.showUbisoftLastLogin": "Mostrar última conexión de Ubisoft", + "settings.showNotesUnderCards": "Mostrar notas bajo las tarjetas", + "settings.expandedFolders": "Expandir todas las carpetas", + "list.rootSection": "Raíz", + "settings.steamLaunch": "Cambio", + "settings.steamInstallation": "Instalación", + "settings.steamApiAndData": "API y datos", + "settings.launchOptions": "Opciones de inicio", + "settings.shutdownMode": "Modo de cierre", + "settings.shutdownModeGraceful": "Ordenado", + "settings.shutdownModeForce": "Forzar cierre", + "settings.autoDetected": "Detectado automáticamente", + "settings.steamFolder": "Carpeta de Steam", + "settings.steamFolderRegistry": "Registro", + "settings.riotClientPath": "Ejecutable del cliente de Riot", + "settings.battleNetPath": "Ejecutable de Battle.net", + "settings.ubisoftPath": "Carpeta de Ubisoft Connect", + "settings.steamWebApiKey": "Clave de la API web de Steam", + "settings.getKey": "Obtener clave", + "settings.pasteApiKey": "Pega tu clave de la API web de Steam", + "settings.saved": "Configuración guardada", + "settings.openApiKeyFailed": "No se pudo abrir la página de la clave API de Steam", + "settings.integrations": "Integraciones", + "settings.deepLinks": "Permitir enlaces accshift://", + "settings.deepLinksHint": "Deja que otras apps cambien de cuenta por ti: marcadores, Stream Deck, scripts.", + "settings.cliEnabled": "Permitir la CLI de accshift", + "settings.cliEnabledHint": "Deja que la herramienta de línea de comandos liste y cambie de cuenta. Desactivado, todos los comandos accshift se niegan a ejecutarse, diag incluido; la aplicación en sí no se ve afectada.", + "settings.help": "Abrir la wiki de accshift", + "settings.helpShort": "Ayuda", + "settings.openHelpFailed": "No se pudo abrir la wiki", + "settings.logs": "Registros", + "settings.openLogs": "Abrir la carpeta de registros", + "settings.openLogsFailed": "No se pudo abrir la carpeta de registros", + "context.loading": "Cargando...", + "context.noGamesFound": "No se encontraron juegos", + "context.field.cut": "Cortar", + "context.field.copy": "Copiar", + "context.field.paste": "Pegar", + "context.field.selectAll": "Seleccionar todo", + "context.menu.newFolder": "Nueva carpeta", + "context.menu.refresh": "Actualizar", + "context.menu.rename": "Renombrar", + "context.menu.copy": "Copiar...", + "context.menu.deleteFolder": "Eliminar carpeta", + "context.menu.folderColor": "Color de la carpeta", + "context.menu.moveToFolder": "Mover a una carpeta", + "context.menu.moveToRoot": "Sin carpeta", + "context.menu.editCardAndColor": "Editar tarjeta y color", + "context.menu.cardColor": "Color de la tarjeta", + "context.menu.addNote": "Añadir nota", + "context.menu.editNote": "Editar nota", + "platform.rename": "Renombrar", + "platform.renameTitle": "Renombrar cuenta", + "platform.renamePlaceholder": "Nombre de la cuenta", + "platform.newAccountPending": "Esperando...", + "platform.setupBusy": "Hay otra operación de accshift en curso. Esperando a que termine.", + "platform.renameNewAccount": "Ponle nombre a esta cuenta", + "platform.copyLabelAccountId": "ID de cuenta", + "platform.forget": "Olvidar", + "platform.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "platform.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "platform.forgotAccount": "{display} olvidada", + "dialog.newFolderTitle": "Nueva carpeta", + "dialog.renameFolderTitle": "Renombrar carpeta", + "dialog.folderNamePlaceholder": "Nombre de la carpeta", + "dialog.cardNoteTitle": "Nota de la tarjeta", + "dialog.cardNotePlaceholder": "Escribe una nota breve", + "card.noteAttached": "Nota adjunta", + "card.clickAgainToSwitch": "Haz clic otra vez para cambiar", + "card.extensionWarnings": "Advertencias", + "card.extensionNote": "Nota", + "card.cs2Section": "CS2", + "card.cs2Level": "Nivel {level}", + "card.cs2CaseEarned": "Caja semanal obtenida", + "card.cs2CaseNotEarned": "Caja semanal pendiente", + "list.selectAccountPreview": "Selecciona una cuenta para verla", + "preview.noteLabel": "Nota", + "preview.currentlyActive": "Activa actualmente", + "preview.switchAgain": "Cambiar de nuevo", + "preview.switchAccount": "Cambiar de cuenta", + "ban.community": "Baneo de comunidad", + "ban.vac.single": "{count} baneo VAC", + "ban.vac.multiple": "{count} baneos VAC", + "ban.game.single": "{count} baneo de juego", + "ban.game.multiple": "{count} baneos de juego", + "time.unknown": "desconocido", + "time.justNow": "ahora mismo", + "time.minuteAgo": "hace {count} minuto", + "time.minutesAgo": "hace {count} minutos", + "time.hourAgo": "hace {count} hora", + "time.hoursAgo": "hace {count} horas", + "time.dayAgo": "hace {count} día", + "time.daysAgo": "hace {count} días", + "time.neverConnected": "nunca conectado", + "time.compactWrapper": "({value})", + "toast.copied": "{label} copiado", + "toast.legacyConfigMigrated": "Configuración migrada al nuevo formato", + "toast.legacyConfigMigrationFailed": "No se pudo migrar la configuración antigua, revisa los registros", + "toast.failedLoadSettingsPanel": "No se pudo cargar el panel de configuración", + "toast.banChecking": "Comprobando baneos...", + "toast.banCheckSummary.single": "Revisión de baneos: {count} cuenta con baneos", + "toast.banCheckSummary.multiple": "Revisión de baneos: {count} cuentas con baneos", + "toast.banCheckFailed": "Falló la revisión de baneos: {error}", + "toast.avatarRefreshComplete": "Actualización de avatares terminada para {count} cuentas", + "toast.banRefreshComplete": "Actualización de baneos terminada para {count} cuentas", + "toast.banRefreshNoApiKey": "Actualización de baneos omitida: añade una clave de Steam Web API en los ajustes", + "toast.refreshPartial": "Actualización terminada para {ok} de {count} cuentas", + "toast.noSteamAccountsFound": "No se encontraron cuentas de Steam. Inicia sesión en Steam al menos una vez y actualiza.", + "toast.accountsRefreshed.single": "{count} cuenta actualizada", + "toast.accountsRefreshed.multiple": "{count} cuentas actualizadas", + "toast.steamFolderNotFound": "No se encontró la carpeta de Steam. Configúrala manualmente en Configuración > Carpeta de Steam.", + "toast.steamElevated": "Steam se está ejecutando como administrador. Activa 'Ejecutar Steam como administrador' en Configuración > Steam.", + "toast.noRiotProfilesFound": "Aún no hay perfiles de Riot. Haz clic en + para añadir uno.", + "toast.noBattleNetAccountsFound": "No se encontraron cuentas de Battle.net. Inicia sesión en Battle.net al menos una vez y actualiza.", + "toast.noUbisoftAccountsFound": "No se encontraron cuentas de Ubisoft. Inicia sesión en Ubisoft Connect al menos una vez y actualiza.", + "toast.deepLinkInvalid": "Enlace accshift:// no reconocido", + "toast.deepLinkDisabled": "Los enlaces accshift:// están desactivados en Configuración", + "toast.deepLinkUnknownPlatform": "Plataforma desconocida en el enlace: {platform}", + "toast.deepLinkPlatformDisabled": "{platform} está desactivado en Configuración", + "toast.deepLinkAccountNotFound": "Ninguna cuenta de {platform} coincide con \"{account}\"", + "toast.deepLinkSwitched": "Cambiado a {account}", + "dialog.deepLinkSwitchTitle": "¿Cambiar de cuenta?", + "dialog.deepLinkSwitchMessage": "Un enlace accshift:// pide cambiar tu cuenta de {platform} a \"{account}\". ¿Continuar?", + "dialog.deepLinkSwitchConfirm": "Cambiar", + "steam.launchOnline": "Cambiar en línea", + "steam.launchInvisible": "Cambiar invisible", + "steam.switchAndRunGame": "Cambiar + iniciar {game}", + "steam.switchAndRunPick": "Cambiar + iniciar un juego...", + "steam.runGame": "Iniciar {game}", + "steam.runPick": "Iniciar un juego...", + "steam.switchAndRunNoGames": "No se encontraron juegos", + "steam.clearDefaultGame": "Quitar el juego predeterminado", + "steam.copySteamId64": "Copiar SteamID64", + "steam.copyFriendCode": "Copiar código de amigo", + "steam.copyProfileUrl": "Copiar URL del perfil", + "steam.copyLabelUsername": "Nombre de usuario", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Código de amigo", + "steam.copyLabelProfileUrl": "URL del perfil", + "steam.openUserdataFolder": "Abrir la carpeta userdata", + "steam.clearIntegratedBrowserCache": "Reiniciar la sesión del navegador", + "steam.clearIntegratedBrowserCacheConfirmTitle": "¿Reiniciar la sesión del navegador de Steam?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "Esto cierra Steam y borra la caché del navegador integrado que usan la Tienda y la Comunidad.", + "steam.clearedIntegratedBrowserCache": "Sesión del navegador de Steam reiniciada", + "steam.copySettingsFrom": "Copiar la configuración de", + "steam.copiedSettingsToCurrent": "Configuración de {game} copiada a la cuenta actual", + "steam.forget": "Olvidar", + "steam.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "steam.forgetConfirmMessage": "Esto quitará esta cuenta de tu lista de cuentas de Steam en este PC.", + "steam.forgotAccount": "{username} olvidada", + "steam.setupPendingLabel": "Cuenta de Steam", + "steam.setupWaitingForClient": "Esperando a Steam...", + "steam.setupWaitingForLogin": "Inicia sesión en la cuenta de Steam que quieres añadir.", + "steam.setupDetected": "Cuenta detectada", + "steam.setupConnected": "Conectado", + "steam.setupReady": "Cuenta de Steam añadida", + "steam.setupReadyWithProfile": "{profile} añadido", + "steam.setupFailed": "Falló la configuración de Steam", + "steam.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Steam.", + "riot.captureSession": "Capturar la sesión actual", + "riot.recaptureSession": "Volver a capturar la sesión", + "riot.capturedSession": "Sesión capturada para {profile}", + "riot.setupPendingLabel": "Cuenta de Riot", + "riot.setupWaitingForClient": "Esperando al Riot Client...", + "riot.setupWaitingForLogin": "Inicia sesión en la cuenta de Riot que quieres añadir.", + "riot.setupCapturing": "Guardando la sesión de Riot...", + "riot.setupReady": "Cuenta de Riot añadida", + "riot.setupReadyWithProfile": "{profile} añadido", + "riot.setupFailed": "Falló la configuración de Riot", + "riot.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Riot.", + "riot.setupStaySignedIn": "Mantén activada la opción 'Stay signed in'.", + "riot.setupDetected": "Cuenta detectada", + "riot.setupConnected": "Conectado", + "riot.forget": "Olvidar cuenta", + "riot.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "riot.forgetConfirmMessage": "Esto elimina el perfil local de Riot y su sesión guardada de Accshift.", + "riot.forgotProfile": "{profile} olvidado", + "battlenet.copyUsername": "Copiar nombre de usuario", + "battlenet.copyLabelUsername": "Nombre de usuario", + "battlenet.copyEmail": "Copiar correo", + "battlenet.copyLabelEmail": "Correo", + "battlenet.forget": "Olvidar", + "battlenet.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "battlenet.forgetConfirmMessage": "Esto quitará esta cuenta de la lista de cuentas de Battle.net en este PC.", + "battlenet.forgotAccount": "{email} olvidada", + "battlenet.setupPendingLabel": "Cuenta de Battle.net", + "battlenet.setupWaitingForClient": "Esperando a Battle.net...", + "battlenet.setupWaitingForLogin": "Inicia sesión en la cuenta de Battle.net que quieres añadir.", + "battlenet.setupKeepMeLoggedIn": "Mantén activada la opción 'Keep me logged in' cuando sea posible.", + "battlenet.setupDetected": "Cuenta detectada", + "battlenet.setupConnected": "Conectado", + "battlenet.setupReady": "Cuenta de Battle.net añadida", + "battlenet.setupReadyWithProfile": "{profile} añadido", + "battlenet.setupFailed": "Falló la configuración de Battle.net", + "battlenet.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Battle.net.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Olvidar", + "ubisoft.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "ubisoft.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "ubisoft.forgotAccount": "{display} olvidada", + "ubisoft.setupPendingLabel": "Cuenta de Ubisoft", + "ubisoft.setupWaitingForClient": "Esperando a Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Inicia sesión en la cuenta de Ubisoft que quieres añadir.", + "ubisoft.setupKeepMeLoggedIn": "Mantén activada la opción 'Remember me'.", + "ubisoft.setupDetected": "Cuenta detectada", + "ubisoft.setupConnected": "Conectado", + "ubisoft.setupReady": "Cuenta de Ubisoft añadida", + "ubisoft.setupReadyWithProfile": "{profile} añadido", + "ubisoft.setupFailed": "Falló la configuración de Ubisoft", + "ubisoft.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Ubisoft.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "No se encontraron cuentas de Roblox. Añade una cuenta con Quick Login o pega una cookie.", + "roblox.noAccountsHint": "Pulsa + para añadir una cuenta de Roblox con Quick Login.", + "roblox.sessionExpired": "Sesión caducada", + "roblox.sessionExpiredTooltip": "Esta cuenta cerró sesión. Vuelve a añadirla antes de cambiar.", + "roblox.sessionExpiredSwitchError": "Sesión caducada. Vuelve a añadir esta cuenta para cambiar. Las notas y la configuración se conservan.", + "roblox.cookiePasteTitle": "Añadir cuenta con cookie", + "roblox.cookiePasteHint": "Pega una cookie .ROBLOSECURITY de tu navegador para añadir una cuenta directamente.", + "settings.showRobloxLastLogin": "Mostrar última conexión", + "settings.robloxHealthCheck": "Comprobación de sesión", + "settings.robloxHealthCheckHint": "Marca las cuentas cuya sesión de Roblox haya caducado; una petición de red por inicio.", + "roblox.copyLabelUsername": "Nombre de usuario", + "roblox.copyLabelUserId": "ID de usuario", + "roblox.forget": "Olvidar", + "roblox.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "roblox.forgetConfirmMessage": "Esto quitará esta cuenta y su cookie guardada de este PC.", + "roblox.forgotAccount": "{display} olvidada", + "roblox.setupPendingLabel": "Cuenta de Roblox", + "roblox.setupWaitingForClient": "Conectando con Roblox...", + "roblox.setupWaitingForLogin": "Abre la app de Roblox en otro dispositivo e introduce el código de abajo.", + "roblox.setupQuickLoginCode": "Código de Quick Login", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Copiar código", + "roblox.setupDetected": "Cuenta detectada", + "roblox.setupConnected": "Conectado", + "roblox.setupReady": "Cuenta de Roblox añadida", + "roblox.setupReadyWithProfile": "{profile} añadido", + "roblox.setupFailed": "Falló la configuración de Roblox", + "roblox.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Roblox.", + "settings.epic": "Epic Games", + "settings.epicPath": "Ejecutable de Epic Games Launcher", + "settings.showEpicLastLogin": "Mostrar última conexión", + "toast.noEpicAccountsFound": "No se encontraron cuentas de Epic Games. Inicia sesión en Epic Games Launcher al menos una vez y actualiza.", + "epic.noAccountsHint": "Pulsa + para añadir una cuenta de Epic Games.", + "epic.copyLabelAccountId": "ID de cuenta", + "epic.forget": "Olvidar", + "epic.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "epic.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "epic.forgotAccount": "{display} olvidada", + "epic.setupPendingLabel": "Cuenta de Epic Games", + "epic.setupWaitingForClient": "Esperando a Epic Games Launcher...", + "epic.setupWaitingForLogin": "Inicia sesión en la cuenta de Epic Games que quieres añadir.", + "epic.setupKeepMeLoggedIn": "Mantén activada la opción 'Remember me'.", + "epic.setupDetected": "Cuenta detectada", + "epic.setupConnected": "Conectado", + "epic.setupReady": "Cuenta de Epic Games añadida", + "epic.setupReadyWithProfile": "{profile} añadido", + "epic.setupFailed": "Falló la configuración de Epic Games", + "epic.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Epic Games.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "Ejecutable de GOG Galaxy", + "settings.showGogLastLogin": "Mostrar última conexión", + "toast.noGogAccountsFound": "No se encontraron cuentas de GOG Galaxy. Inicia sesión en GOG Galaxy al menos una vez y actualiza.", + "gog.noAccountsHint": "Pulsa + para añadir una cuenta de GOG Galaxy.", + "gog.copyLabelAccountId": "ID de cuenta", + "gog.forget": "Olvidar", + "gog.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "gog.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "gog.forgotAccount": "{display} olvidada", + "gog.setupPendingLabel": "Cuenta de GOG Galaxy", + "gog.setupWaitingForClient": "Esperando a GOG Galaxy...", + "gog.setupWaitingForLogin": "Inicia sesión en la cuenta de GOG Galaxy que quieres añadir.", + "gog.setupKeepMeLoggedIn": "Mantén activada la opción 'Stay signed in'.", + "gog.setupDetected": "Cuenta detectada", + "gog.setupConnected": "Conectado", + "gog.setupReady": "Cuenta de GOG Galaxy añadida", + "gog.setupReadyWithProfile": "{profile} añadido", + "gog.setupFailed": "Falló la configuración de GOG Galaxy", + "gog.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de GOG Galaxy.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Ejecutable de Jagex Launcher", + "settings.showJagexLastLogin": "Mostrar última conexión", + "toast.noJagexAccountsFound": "Aún no hay cuentas de Jagex guardadas. Pulsa + para añadir una con el Jagex Launcher.", + "jagex.noAccountsHint": "Pulsa + para añadir una cuenta de Jagex.", + "jagex.copyLabelAccountId": "ID de cuenta", + "jagex.forget": "Olvidar", + "jagex.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "jagex.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "jagex.forgotAccount": "{display} olvidada", + "jagex.setupPendingLabel": "Cuenta de Jagex", + "jagex.setupWaitingForClient": "Esperando al Jagex Launcher...", + "jagex.setupWaitingForLogin": "Inicia sesión en la cuenta de Jagex que quieres añadir.", + "jagex.setupKeepMeLoggedIn": "Mantén activada la opción 'Remember me'.", + "jagex.setupDetected": "Cuenta detectada", + "jagex.setupConnected": "Conectado", + "jagex.setupReady": "Cuenta de Jagex añadida", + "jagex.setupReadyWithProfile": "{profile} añadido", + "jagex.setupFailed": "Falló la configuración de Jagex", + "jagex.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Jagex.", + "settings.discord": "Discord", + "settings.discordPath": "Ejecutable de Discord", + "settings.showDiscordLastLogin": "Mostrar última conexión", + "toast.noDiscordAccountsFound": "No se encontraron cuentas de Discord. Añade una cuenta de Discord y actualiza.", + "discord.noAccountsHint": "Pulsa + para añadir una cuenta de Discord.", + "discord.copyLabelAccountId": "ID de cuenta", + "discord.forget": "Olvidar", + "discord.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "discord.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "discord.forgotAccount": "{display} olvidada", + "discord.setupPendingLabel": "Cuenta de Discord", + "discord.setupWaitingForClient": "Esperando a Discord...", + "discord.setupWaitingForLogin": "Inicia sesión en la cuenta de Discord que quieres añadir.", + "discord.setupKeepMeLoggedIn": "Mantén la sesión iniciada para conservar esta cuenta.", + "discord.setupDetected": "Cuenta detectada", + "discord.setupConnected": "Conectado", + "discord.setupReady": "Cuenta de Discord añadida", + "discord.setupReadyWithProfile": "{profile} añadido", + "discord.setupFailed": "Falló la configuración de Discord", + "discord.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Discord.", + "color.none": "Predeterminado", + "color.blue": "Azul", + "color.cyan": "Cian", + "color.green": "Verde", + "color.lime": "Lima", + "color.yellow": "Amarillo", + "color.orange": "Naranja", + "color.red": "Rojo", + "color.pink": "Rosa", + "color.violet": "Violeta", + "color.gray": "Gris", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Oscuro", + "theme.light": "Claro", + "theme.midnight": "Medianoche", + "theme.glassDark": "Cristal oscuro", + "theme.glassLight": "Cristal claro", + "theme.liquidGlass": "Cristal líquido", + "theme.custom": "Personalizado", + "settings.themeImport": "Importar", + "settings.themeExport": "Exportar", + "settings.themeDelete": "Eliminar tema", + "settings.themeImported": "Tema importado", + "settings.themeDeleted": "Tema eliminado", + "settings.themeExportCopied": "JSON del tema copiado al portapapeles", + "settings.themeCustomize": "Personalizar", + "settings.themeEdit": "Editar", + "settings.themeCopyName": "{name} copia", + "settings.themeSaved": "Tema guardado", + "settings.themeMigrated": "Tema importado y actualizado al formato actual", + "settings.themeInvalidId": "El identificador del tema falta o no es válido", + "settings.themeInvalidName": "El tema no tiene nombre", + "settings.themeUnsupportedVersion": "Este tema necesita una versión más reciente de accshift", + "themeGroup.surface": "Superficies", + "themeGroup.text": "Texto", + "themeGroup.semantic": "Colores de estado", + "themeGroup.shape": "Forma y densidad", + "themeGroup.typography": "Tipografía", + "themeGroup.motion": "Movimiento", + "themeKind.rgbTriplet": "triplete RGB", + "themeKind.hexColor": "color hexadecimal", + "themeKind.color": "color", + "themeKind.length": "longitud", + "themeKind.shadow": "sombra", + "themeKind.choice": "opción", + "themeKind.fontStack": "fuente", + "themeKind.signedLength": "espaciado", + "themeKind.gradient": "degradado", + "themeKind.number": "número", + "themeDensity.compact": "Compacta", + "themeDensity.cozy": "Normal", + "themeDensity.comfortable": "Amplia", + "themeAvatarShape.circle": "Redondo", + "themeAvatarShape.rounded": "Redondeado", + "themeAvatarShape.square": "Cuadrado", + "themeSmoothing.auto": "Suavizado", + "themeSmoothing.none": "Píxel", + "themeToken.bgRgb": "Fondo de la ventana", + "themeToken.bgCard": "Fondo de las tarjetas", + "themeToken.bgCardHover": "Fondo de las tarjetas al pasar el cursor", + "themeToken.bgMuted": "Fondo atenuado", + "themeToken.bgElevated": "Fondo elevado", + "themeToken.border": "Bordes", + "themeToken.fg": "Texto principal", + "themeToken.fgMuted": "Texto secundario", + "themeToken.fgSubtle": "Texto sutil", + "themeToken.afkText": "Etiqueta de ausente", + "themeToken.accent": "Acento", + "themeToken.accentFg": "Texto sobre el acento", + "themeToken.success": "Éxito", + "themeToken.warning": "Advertencia", + "themeToken.danger": "Peligro", + "themeToken.radiusSm": "Radio pequeño", + "themeToken.radiusMd": "Radio medio", + "themeToken.radiusLg": "Radio grande", + "themeToken.elevationLow": "Sombra suave", + "themeToken.elevationMedium": "Sombra media", + "themeToken.elevationHigh": "Sombra fuerte", + "themeToken.density": "Densidad", + "themeToken.fontUi": "Fuente de la interfaz", + "themeToken.bgImage": "Degradado de la ventana", + "themeToken.cardBgImage": "Degradado de las superficies", + "themeToken.borderWidth": "Grosor de los bordes", + "themeToken.borderStyle": "Estilo de los bordes", + "themeToken.avatarShape": "Forma de los avatares", + "themeToken.focusRing": "Contorno de foco", + "themeToken.overlayBlur": "Desenfoque del fondo", + "themeToken.fontDisplay": "Fuente de los títulos", + "themeToken.lineHeight": "Altura de línea", + "themeToken.letterSpacing": "Espaciado entre letras", + "themeToken.labelCase": "Mayúsculas de las etiquetas", + "themeToken.fontSmoothing": "Suavizado del texto", + "themeToken.motionScale": "Velocidad de las animaciones", + "themeEditor.title": "Editor de temas", + "themeEditor.name": "Nombre", + "themeEditor.author": "Autor", + "themeEditor.version": "Versión", + "themeEditor.base": "Hereda de", + "themeEditor.colorScheme": "Modo de color", + "themeEditor.glass": "Superficies translúcidas", + "themeEditor.inherited": "Heredado", + "themeEditor.builtInBase": "Tema integrado", + "themeEditor.reset": "Volver al valor heredado", + "themeEditor.issues": "Comprobaciones", + "themeEditor.noIssues": "Nada que señalar", + "themeEditor.fixErrorsFirst": "Corrige los valores rechazados antes de guardar", + "themeEditor.save": "Guardar", + "themeEditor.saveFailed": "No se pudo guardar el tema. Inténtalo de nuevo.", + "themeEditor.export": "Copiar", + "themeEditor.issueInvalidValue": "{token}: {kind} no válido, por ejemplo {example}", + "themeEditor.issueUnknownToken": "{token} no es un valor de tema, se ignora", + "themeEditor.issueMissingToken": "{token} no está definido, se usa el valor integrado", + "themeEditor.issueUnknownBase": "El tema base {base} no existe, se usan los valores integrados", + "themeEditor.issueContrast": "{token} sobre {against}: {ratio} a 1, por debajo del objetivo de {target} a 1", + "themeEditor.beta": "Beta", + "themeEditor.betaNote": "El formato de tema aún cambia: un tema escrito hoy puede necesitar un ajuste tras una actualización.", + "themeEditor.customCss": "CSS personalizado", + "themeEditor.customCssHelp": "Se aplica sobre el tema, para lo que los valores de arriba no alcanzan. Sin @import ni url(): un tema nunca descarga nada.", + "themeEditor.issueUnsafeCss": "{construct} no está permitido en un tema, el CSS no se aplica", + "themeEditor.issueCssTooLong": "CSS demasiado largo: {length} caracteres, el límite es {limit}", + "settings.themeInvalidJson": "JSON de tema inválido", + "bulkEdit.title": "Edición masiva", + "bulkEdit.selectAccounts": "Seleccionar cuentas", + "bulkEdit.selectAll": "Seleccionar todo", + "bulkEdit.deselectAll": "Deseleccionar todo", + "bulkEdit.next": "Siguiente", + "bulkEdit.apply": "Aplicar", + "bulkEdit.settings": "Configuración", + "bulkEdit.newsPopup": "Ventana de noticias del juego", + "bulkEdit.doNotDisturb": "No molestar", + "bulkEdit.launchOptions": "Opciones de inicio por juego", + "bulkEdit.unchanged": "Sin cambios", + "bulkEdit.addLaunchOption": "Añadir", + "bulkEdit.selectGame": "Selecciona un juego...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "La cuenta activa está seleccionada. Los cambios se aplicarán al reiniciar Steam.", + "bulkEdit.toastSuccess": "Edición masiva: {count} con éxito", + "bulkEdit.toastPartial": "Edición masiva: {succeeded} con éxito, {failed} con error", + "bulkEdit.toastFailed": "Falló la edición masiva", + "bulkEdit.noAccountsSelected": "Ninguna cuenta seleccionada", + "bulkEdit.selected": "{count} seleccionadas", + "bulkEdit.loadingGames": "Cargando juegos...", + "bulkEdit.copyUrls": "Copiar URL", + "bulkEdit.urlsCopied": "{count} URL de perfil copiada(s)", + "bulkEdit.cardColor": "Color de tarjeta", + "bulkEdit.colorApplied": "Color aplicado a {count} tarjeta(s)", + "bulkEdit.colorCleared": "Color eliminado de {count} tarjeta(s)", + "common.retry": "Reintentar", + "app.searchNoResults": "Sin resultados para \"{query}\"", + "app.folderEmpty": "Esta carpeta está vacía", + "app.loadErrorGeneric": "No se pudieron cargar las cuentas. Consulta la consola para más detalles.", + "toast.dismiss": "Descartar", + "toast.switchFailed": "Falló el cambio de cuenta. Consulta la consola para más detalles.", + "toast.addAccountFailed": "No se pudo añadir la cuenta. Revisa los registros de la app para más detalles.", + "toast.addAccountClientMissing": "{platform} no parece estar instalado. Instálalo o indica su ubicación en Configuración.", + "toast.banCheckFailedGeneric": "Falló la revisión de baneos. Consulta la consola para más detalles.", + "toast.renameAccountFailed": "Falló el cambio de nombre: {error}", + "toast.copyFailed": "No se pudo copiar al portapapeles", + "toast.refreshFailed": "Falló la actualización. Consulta la consola para más detalles.", + "dialog.deleteFolderTitle": "¿Eliminar \"{name}\"?", + "dialog.deleteFolderMessage": "Las subcarpetas también se eliminan. Las cuentas que hay dentro suben un nivel, no se borran.", + "personas.deleteConfirm": "Haz clic de nuevo para eliminar", + "personas.discardConfirm": "Haz clic de nuevo para descartar", + "personas.switchConfirmTitle": "Cambiar de identidad", + "personas.switchConfirmMessage": "¿Cambiar {count} plataforma(s) a \"{name}\"? Cada cliente se cerrará y se reiniciará.", + "personas.switchConfirmAction": "Cambiar", + "personas.deleteTitle": "Eliminar identidad", + "personas.deleteMessage": "¿Eliminar \"{name}\"? Las cuentas en sí no se tocan.", + "context.submenuError": "No se pudo cargar este menú", + "settings.apiKeySaveFailed": "No se pudo guardar la clave API de Steam", + "settings.apiKeyInvalidHint": "La clave no se guardó. Revísala e inténtalo de nuevo.", + "settings.apiKeyCleared": "Clave API de Steam borrada", + "settings.apiKeyClearFailed": "No se pudo borrar la clave API de Steam", + "settings.clearApiKey": "Borrar clave", + "settings.cs2Bridge": "Puente de estadísticas de CS2", + "settings.cs2BridgeLink": "Enlace del puente", + "settings.cs2BridgeLinkPlaceholder": "https://tu-servidor/api/bridge/accshift/", + "settings.cs2BridgeTest": "Probar", + "settings.cs2BridgeStatusOk": "Conectado · {count} cuentas · {ms} ms", + "settings.cs2BridgeStatusFail": "Falló la conexión", + "settings.cs2BridgeStatusTesting": "Probando...", + "settings.cs2BridgeStatusUnknown": "Sin probar", + "settings.cs2BridgeToken": "Token de API", + "settings.cs2BridgeTokenOptional": "opcional, para servidores propios", + "settings.cs2BridgeTokenPlaceholder": "Token Bearer si tu servidor lo necesita", + "settings.cs2BridgeSaveFailed": "No se pudo guardar la configuración del puente CS2", + "settings.cs2BridgeClearToken": "Borrar token", + "settings.pathSaveFailed": "No se pudo guardar la ruta de {platform}", + "settings.pathInvalidHint": "No se pudo guardar esta ruta. Revísala e inténtalo de nuevo.", + "settings.pathSteamNeverSignedIn": "Steam encontrado, inicia sesión una vez para que cree su historial de inicio de sesión", + "settings.pathNotSteamFolder": "Esta carpeta no parece una instalación de Steam", + "settings.pathNotADirectory": "La ruta debe ser una carpeta existente", + "settings.pinSaved": "PIN guardado", + "settings.pinTakesEffectNextLaunch": "Se aplica la próxima vez que se abra la app", + "settings.pinRequiredAfterInactivity": "Obligatorio tras la inactividad y al abrir la app", + "settings.pinScreenLockOnly": "El PIN bloquea esta ventana y los cambios de cuenta. No protege nada en el disco.", + "settings.unitSeconds": "segundos", + "settings.unitDays": "días", + "settings.telemetryLoadFailed": "No se pudo cargar la configuración de telemetría", + "palette.placeholder": "Escribe un comando o el nombre de una cuenta...", + "palette.noResults": "Ningún comando coincide", + "palette.sectionAccounts": "Cuentas", + "palette.sectionActions": "Acciones", + "palette.sectionNavigation": "Navegación", + "palette.openTab": "Ir a {platform}", + "palette.openFolder": "Abrir la carpeta \"{name}\"", + "palette.backToParent": "Volver a la carpeta superior", + "palette.zoomReset": "Restablecer el zoom" +} diff --git a/crates/accshift-app/i18n/fr.json b/crates/accshift-app/i18n/fr.json new file mode 100644 index 00000000..ef59ede2 --- /dev/null +++ b/crates/accshift-app/i18n/fr.json @@ -0,0 +1,753 @@ +{ + "app.loadingSettings": "Chargement des paramètres...", + "app.loading": "Chargement...", + "app.searchPlaceholder": "Rechercher un compte...", + "app.noAccountsFound": "Aucun compte {platform} trouvé", + "app.noAccountsHint": "Vérifie que {platform} est installé et que tu t'es connecté au moins une fois.", + "app.switchingAccount": "Changement de compte...", + "app.platformRestarting": "{platform} redémarre", + "app.comingSoon": "{platform} - Bientôt disponible", + "app.platformUnsupportedOs": "{platform} n'est pas encore disponible sur cet OS", + "app.noCompatiblePlatforms": "Aucune plateforme compatible n'est disponible sur cet OS", + "app.noCompatiblePlatformsHint": "Vérifie les mises à jour de support Windows ou utilise une machine compatible.", + "update.ctaAvailable": "Mise à jour dispo", + "update.ctaInstalling": "Installation...", + "update.restartToApplyVersion": "Redémarrer pour appliquer la mise à jour {version}", + "update.restartToApply": "Redémarrer pour appliquer la mise à jour", + "update.readyToastVersion": "Mise à jour {version} prête", + "update.readyToast": "Mise à jour prête", + "update.restartFailed": "Impossible de redémarrer pour appliquer la mise à jour", + "pin.lockedTitle": "Application verrouillée", + "pin.lockedPrompt": "Entre le PIN pour déverrouiller", + "pin.placeholder": "PIN 4 chiffres", + "pin.unlock": "Déverrouiller", + "pin.invalid": "PIN invalide", + "pin.tooManyAttempts": "Trop de tentatives. Réessayez dans {seconds} s.", + "common.ok": "OK", + "common.cancel": "Annuler", + "common.confirm": "Confirmer", + "common.close": "Fermer", + "common.back": "Retour", + "common.active": "Actif", + "common.choose": "Choisir...", + "common.none": "Aucun", + "common.custom": "Personnalisé", + "common.configured": "Configuré", + "common.missing": "Manquant", + "common.enabled": "Activé", + "common.disabled": "Désactivé", + "common.on": "Oui", + "common.off": "Non", + "common.add": "Ajouter", + "common.visible": "Visible", + "common.hidden": "Masqué", + "common.inline": "Sous la carte", + "common.tooltip": "Infobulle", + "titlebar.refresh": "Rafraîchir", + "titlebar.addAccount": "Ajouter un compte", + "titlebar.settings": "Paramètres", + "titlebar.personas": "Personas", + "titlebar.minimize": "Réduire", + "titlebar.maximize": "Agrandir", + "titlebar.restore": "Restaurer", + "titlebar.close": "Fermer", + "view.grid": "Vue grille", + "view.list": "Vue liste", + "settings.title": "Paramètres", + "settings.general": "Général", + "settings.appearance": "Apparence", + "settings.language": "Langue", + "settings.theme": "Thème", + "settings.uiZoom": "Zoom UI", + "settings.backgroundOpacity": "Opacité du fond", + "settings.animations": "Animations", + "settings.animationsSystem": "Suivre le système", + "settings.animationsOn": "Toujours actives", + "settings.animationsOff": "Désactivées", + "settings.performance": "Performance", + "settings.suspendGraphicsWhenMinimized": "Couper le rendu quand minimisé", + "settings.minimizeOnAccountSwitch": "Minimiser lors d'un changement de compte", + "settings.system": "Système", + "settings.startWithSystem": "Lancer au démarrage", + "settings.startWithSystemHint": "Démarre Accshift avec Windows, réduit dans la zone de notification.", + "settings.closeToTray": "Fermer vers la zone de notification", + "settings.closeToTrayHint": "Le bouton de fermeture masque Accshift dans la zone de notification au lieu de quitter.", + "settings.autostartFailed": "Impossible de modifier le lancement au démarrage.", + "tray.open": "Ouvrir Accshift", + "tray.quit": "Quitter", + "settings.platforms": "Plateformes", + "settings.platformSearchPlaceholder": "Rechercher une plateforme...", + "settings.platformSearchNoResults": "Aucune plateforme ne correspond à la recherche.", + "settings.accountDisplay": "Affichage des comptes", + "settings.startupAndExtras": "Démarrage", + "settings.defaultOnStartup": "Plateforme par défaut au démarrage", + "settings.platformDisabledSuffix": "(désactivé)", + "settings.platformUnsupportedOs": "OS non supporté", + "settings.platformNotImplemented": "Prévu", + "settings.platformUserProvided": "Ajoutée depuis votre dossier de descripteurs", + "settings.customPlatforms": "Plateformes personnalisées", + "settings.customPlatformsHint": "Les fichiers de descripteur de ce dossier sont lus au démarrage de l'application.", + "settings.customPlatformsNone": "Aucun descripteur chargé depuis ce dossier.", + "settings.customPlatformsProblems": "Fichiers non chargés", + "descriptor.addFromFile": "Ajouter depuis un fichier", + "descriptor.reload": "Recharger", + "descriptor.openFolder": "Ouvrir le dossier", + "descriptor.remove": "Supprimer", + "descriptor.removeConfirmTitle": "Supprimer {platform} ?", + "descriptor.removeConfirmMessage": "Son fichier de descripteur quitte votre dossier. Les comptes déjà capturés restent sur le disque.", + "descriptor.previewTitle": "Ajouter une plateforme", + "descriptor.previewOs": "Décrite pour {list}", + "descriptor.previewRoots": "Dossiers auxquels elle peut toucher", + "descriptor.previewSteps": "Ce que ferait un changement de compte", + "descriptor.previewWarnings": "Avertissements", + "descriptor.previewNoPlan": "Impossible de construire un essai à blanc : {problem}", + "descriptor.previewReplaces": "Ceci remplace le descripteur déjà présent dans votre dossier.", + "descriptor.previewNothingWritten": "Rien n'est écrit tant que vous n'avez pas ajouté.", + "descriptor.replace": "Remplacer", + "descriptor.installed": "{platform} ajoutée", + "descriptor.removed": "{platform} supprimée", + "descriptor.actionRead": "lire", + "descriptor.actionCapture": "capturer", + "descriptor.actionRestore": "restaurer", + "descriptor.actionDelete": "supprimer", + "descriptor.actionClose": "fermer", + "descriptor.actionLaunch": "lancer", + "settings.dataRefresh": "Rafraîchissement des données", + "settings.avatarRefresh": "Rafraîchissement avatar", + "settings.banCheckDelay": "Délai vérification ban", + "settings.refreshNow": "Rafraîchir maintenant", + "settings.zeroEachLaunch": "0 = à chaque lancement", + "settings.privacy": "Confidentialité", + "settings.inactivityTimeout": "Délai d'inactivité", + "settings.zeroDisabled": "0 = désactivé", + "settings.streamerMode": "Mode streamer", + "settings.streamerModeHint": "Floute automatiquement les infos des comptes quand un logiciel de streaming tourne.", + "settings.personasHint": "Afficher le sélecteur d'identités personas", + "settings.replayOnboarding": "Refaire l'onboarding", + "settings.replayOnboardingHint": "Réaffiche la visite guidée et le choix des statistiques d'usage.", + "streamer.title": "Mode streamer", + "streamer.disable": "Désactiver", + "streamer.disablePermanently": "Désactiver définitivement", + "personas.title": "Personas", + "personas.create": "Nouveau persona", + "personas.activate": "Basculer vers ce persona", + "personas.edit": "Modifier", + "personas.delete": "Supprimer", + "personas.finish": "Terminer", + "personas.switching": "Bascule en cours...", + "personas.switched": "Basculé vers {name}", + "personas.switchPartial": "{name} : échec sur {failed}", + "personas.switchFailed": "Impossible de basculer vers {name}", + "personas.namePlaceholder": "Nom du persona", + "personas.choosePlatforms": "Choisis un compte pour chaque plateforme à inclure", + "personas.chooseAccount": "Choisis le compte {platform}", + "personas.removeFromPersona": "Retirer de ce persona", + "personas.customImage": "Choisir une image...", + "personas.removeImage": "Retirer l'image", + "personas.imageError": "Impossible de charger cette image.", + "personas.loadingAccounts": "Chargement des comptes...", + "personas.noAccountsOnPlatform": "Aucun compte sur cette plateforme pour l'instant", + "personas.emptyHint": "Un clic bascule chaque plateforme incluse vers son compte.", + "settings.security": "Sécurité", + "settings.pinLockOnAfk": "Verrou PIN en AFK", + "settings.pinCode": "Code PIN", + "settings.pinPlaceholder": "4 chiffres", + "settings.pinCurrentCode": "PIN actuel", + "settings.pinCurrentHint": "Saisissez le PIN actuel pour le modifier ou désactiver le verrou.", + "settings.telemetry": "Statistiques d'utilisation (anonymes)", + "settings.telemetryModeA": "Stats anonymes de base", + "settings.telemetryModeAHint": "Compte les lancements et les fonctionnalités utilisées. Un identifiant local aléatoire évite les doublons d'installation ; aucune donnée personnelle n'est envoyée.", + "settings.telemetryModeB": "Stats avancées avec UUID local (opt-in)", + "settings.telemetryModeBHint": "Ajoute un UUID aléatoire stocké uniquement sur ta machine. Permet de mesurer la rétention et quelles fonctionnalités comptent. Toujours aucune donnée personnelle envoyée.", + "settings.telemetryExport": "Exporter mes données", + "settings.telemetryExported": "Données copiées dans le presse-papiers", + "settings.telemetryExportFailed": "Échec de l'export", + "settings.telemetryEnableFailed": "Impossible d'activer les stats avancées", + "settings.telemetryDisableFailed": "Impossible de désactiver les stats avancées", + "settings.telemetryDeletePending": "Les stats avancées sont désactivées ; il faudra réessayer l'effacement serveur", + "settings.telemetryDeletePendingHint": "L'effacement serveur est en attente. L'ancien identifiant local est conservé uniquement pour réessayer l'effacement ou exporter les données associées.", + "settings.telemetryRetryDelete": "Réessayer l'effacement", + "settings.telemetryDeleteCompleted": "Données serveur effacées", + "settings.telemetryDeleteRetryFailed": "L'effacement serveur est toujours en attente", + "settings.telemetryLearnMore": "Quelles données sont envoyées ?", + "onboarding.welcome.title": "Bienvenue dans Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "Ton système d'exploitation est compatible avec :", + "onboarding.welcome.detectedPlatforms": "Voici les plateformes détectées sur ta machine :", + "onboarding.welcome.next": "Fais-moi visiter", + "onboarding.welcome.skip": "Passer", + "onboarding.features.hint": "Cette partie de l'app n'est pas visible pour l'instant.", + "onboarding.features.addAccount.label": "Ajouter un compte", + "onboarding.features.addAccount.body": "Le bouton + en haut à droite ajoute un compte sur la plateforme active. Tu te connectes une fois, Accshift le garde.", + "onboarding.features.switch.label": "Changer de compte", + "onboarding.features.switch.body": "Clique sur une carte et t'es sur ce compte. C'est toute l'app, honnêtement.", + "onboarding.features.contextMenu.label": "Clic droit pour les actions", + "onboarding.features.contextMenu.body": "Clic droit sur une carte pour la renommer, changer sa couleur, la ranger dans un dossier ou la supprimer.", + "onboarding.features.platformSwitch.label": "Changer de plateforme", + "onboarding.features.platformSwitch.body": "Pareil pour Riot, Battle.net, Epic et les autres. Les onglets en haut changent de plateforme.", + "onboarding.features.next": "Suivant", + "onboarding.features.back": "Retour", + "onboarding.features.step": "Étape {step}", + "onboarding.features.mockRename": "Renommer", + "onboarding.features.mockSetColor": "Définir la couleur", + "onboarding.features.mockMoveToFolder": "Déplacer vers un dossier", + "onboarding.features.mockDelete": "Supprimer", + "onboarding.features.mockAccount": "Compte {number}", + "onboarding.telemetry.title": "VENDS TON ÂME À ACCSHIFT !", + "onboarding.telemetry.intro": "Accshift compte quelques stats d'utilisation anonymes pour que je sache quoi corriger en premier. Rien de personnel ne quitte ta machine, l'app marche pareil dans tous les cas, et tu peux changer d'avis quand tu veux dans les paramètres.", + "onboarding.telemetry.question": "Marché conclu ?", + "onboarding.telemetry.gifAlt": "Mème Trade Offer", + "onboarding.telemetry.basic": "NON ! ça suffit", + "onboarding.telemetry.basicDefault": "(par défaut)", + "onboarding.telemetry.basicHint": "Des compteurs anonymes et rien d'autre : aucun profil, aucun identifiant qui te suit.", + "onboarding.telemetry.deal": "MARCHÉ CONCLU", + "onboarding.telemetry.dealHint": "Ajoute un UUID distinct pour les stats avancées, stocké uniquement sur ta machine. Je ne sais toujours pas qui tu es, mais tu aides beaucoup. Merci !", + "onboarding.telemetry.optOutNote": "Quel que soit ton choix, tu pourras le changer plus tard dans Paramètres -> Confidentialité. :c", + "onboarding.telemetry.refuse": "Non merci, tout couper", + "onboarding.telemetry.refuseHint": "Rien n'est mesuré, rien n'est envoyé, pas même les compteurs anonymes.", + "onboarding.telemetry.back": "Retour", + "onboarding.persistFailed": "Impossible d'enregistrer ce choix. Réessaie.", + "onboarding.persistLater": "Me redemander au prochain lancement", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Lancer Steam en admin", + "settings.showUsernames": "Afficher les usernames", + "settings.showSteamLastLogin": "Afficher la dernière connexion Steam", + "settings.showRiotLastLogin": "Afficher la dernière connexion Riot", + "settings.showBattleNetLastLogin": "Afficher la dernière connexion Battle.net", + "settings.showUbisoftLastLogin": "Afficher la dernière connexion Ubisoft", + "settings.showNotesUnderCards": "Afficher les notes sous les cartes", + "settings.expandedFolders": "Tout dérouler les dossiers", + "list.rootSection": "Racine", + "settings.steamLaunch": "Bascule", + "settings.steamInstallation": "Installation", + "settings.steamApiAndData": "API & données", + "settings.launchOptions": "Options de lancement", + "settings.shutdownMode": "Mode d'arrêt", + "settings.shutdownModeGraceful": "Gracieux", + "settings.shutdownModeForce": "Force kill", + "settings.autoDetected": "Détection auto", + "settings.steamFolder": "Dossier Steam", + "settings.steamFolderRegistry": "Registre", + "settings.riotClientPath": "Exécutable du client Riot", + "settings.battleNetPath": "Exécutable Battle.net", + "settings.ubisoftPath": "Dossier Ubisoft Connect", + "settings.steamWebApiKey": "Clé API Steam Web", + "settings.getKey": "Obtenir la clé", + "settings.pasteApiKey": "Colle ta clé API Steam Web", + "settings.saved": "Paramètres enregistrés", + "settings.openApiKeyFailed": "Impossible d'ouvrir la page de clé API Steam", + "settings.integrations": "Intégrations", + "settings.deepLinks": "Autoriser les liens accshift://", + "settings.deepLinksHint": "Laisse d'autres applications changer de compte pour toi : favoris, Stream Deck, scripts.", + "settings.cliEnabled": "Autoriser le CLI accshift", + "settings.cliEnabledHint": "Laisse l'outil en ligne de commande lister et changer de compte. Désactivé, toute commande accshift refuse de s'exécuter, diag compris ; l'application elle-même n'est pas concernée.", + "settings.help": "Ouvrir le wiki accshift", + "settings.helpShort": "Aide", + "settings.openHelpFailed": "Impossible d'ouvrir le wiki", + "settings.logs": "Logs", + "settings.openLogs": "Ouvrir le dossier des logs", + "settings.openLogsFailed": "Impossible d'ouvrir le dossier des logs", + "context.loading": "Chargement...", + "context.noGamesFound": "Aucun jeu trouvé", + "context.field.cut": "Couper", + "context.field.copy": "Copier", + "context.field.paste": "Coller", + "context.field.selectAll": "Tout sélectionner", + "context.menu.newFolder": "Nouveau dossier", + "context.menu.refresh": "Rafraîchir", + "context.menu.rename": "Renommer", + "context.menu.copy": "Copier...", + "context.menu.deleteFolder": "Supprimer le dossier", + "context.menu.folderColor": "Couleur du dossier", + "context.menu.moveToFolder": "Déplacer vers un dossier", + "context.menu.moveToRoot": "Aucun dossier", + "context.menu.editCardAndColor": "Modifier carte et couleur", + "context.menu.cardColor": "Couleur de carte", + "context.menu.addNote": "Ajouter une note", + "context.menu.editNote": "Modifier la note", + "platform.rename": "Renommer", + "platform.renameTitle": "Renommer le compte", + "platform.renamePlaceholder": "Nom du compte", + "platform.newAccountPending": "En attente...", + "platform.setupBusy": "Une autre opération accshift est en cours. En attente de sa fin.", + "platform.renameNewAccount": "Nommer ce compte", + "platform.copyLabelAccountId": "ID de compte", + "platform.forget": "Oublier", + "platform.forgetConfirmTitle": "Oublier \"{display}\" ?", + "platform.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "platform.forgotAccount": "{display} oublié", + "dialog.newFolderTitle": "Nouveau dossier", + "dialog.renameFolderTitle": "Renommer le dossier", + "dialog.folderNamePlaceholder": "Nom du dossier", + "dialog.cardNoteTitle": "Note de carte", + "dialog.cardNotePlaceholder": "Écris une note courte", + "card.noteAttached": "Note attachée", + "card.clickAgainToSwitch": "Cliquez encore pour changer", + "card.extensionWarnings": "Alertes", + "card.extensionNote": "Note", + "card.cs2Section": "CS2", + "card.cs2Level": "Niveau {level}", + "card.cs2CaseEarned": "Caisse hebdo obtenue", + "card.cs2CaseNotEarned": "Caisse hebdo à faire", + "list.selectAccountPreview": "Sélectionne un compte pour l'aperçu", + "preview.noteLabel": "Note", + "preview.currentlyActive": "Compte actuellement actif", + "preview.switchAgain": "Rebasculer", + "preview.switchAccount": "Changer de compte", + "ban.community": "Ban communautaire", + "ban.vac.single": "{count} ban VAC", + "ban.vac.multiple": "{count} bans VAC", + "ban.game.single": "{count} ban jeu", + "ban.game.multiple": "{count} bans jeu", + "time.unknown": "inconnu", + "time.justNow": "à l'instant", + "time.minuteAgo": "il y a {count} minute", + "time.minutesAgo": "il y a {count} minutes", + "time.hourAgo": "il y a {count} heure", + "time.hoursAgo": "il y a {count} heures", + "time.dayAgo": "il y a {count} jour", + "time.daysAgo": "il y a {count} jours", + "time.neverConnected": "jamais connecté", + "time.compactWrapper": "({value})", + "toast.copied": "{label} copié", + "toast.legacyConfigMigrated": "Paramètres migrés vers le nouveau format", + "toast.legacyConfigMigrationFailed": "La migration des anciens paramètres a échoué", + "toast.failedLoadSettingsPanel": "Impossible de charger le panneau paramètres", + "toast.banChecking": "Vérification des bans...", + "toast.banCheckSummary.single": "Vérification: {count} compte avec ban", + "toast.banCheckSummary.multiple": "Vérification: {count} comptes avec ban", + "toast.banCheckFailed": "Échec de la vérification des bans: {error}", + "toast.avatarRefreshComplete": "Rafraîchissement avatar terminé pour {count} comptes", + "toast.banRefreshComplete": "Rafraîchissement des bans terminé pour {count} comptes", + "toast.banRefreshNoApiKey": "Rafraîchissement des bans ignoré : ajoute une clé Steam Web API dans les paramètres", + "toast.refreshPartial": "Rafraîchissement terminé pour {ok} comptes sur {count}", + "toast.noSteamAccountsFound": "Aucun compte Steam trouvé. Connecte-toi à Steam au moins une fois puis rafraîchis.", + "toast.accountsRefreshed.single": "{count} compte rafraîchi", + "toast.accountsRefreshed.multiple": "{count} comptes rafraîchis", + "toast.steamFolderNotFound": "Dossier Steam introuvable. Configure-le manuellement dans Paramètres > Dossier Steam.", + "toast.steamElevated": "Steam tourne en admin. Active 'Lancer Steam en admin' dans Paramètres > Steam.", + "toast.noRiotProfilesFound": "Aucun profil Riot pour l'instant. Clique sur + pour en ajouter un.", + "toast.noBattleNetAccountsFound": "Aucun compte Battle.net trouvé. Connecte-toi à Battle.net au moins une fois puis rafraîchis.", + "toast.noUbisoftAccountsFound": "Aucun compte Ubisoft trouvé. Connecte-toi à Ubisoft Connect au moins une fois puis rafraîchis.", + "toast.deepLinkInvalid": "Lien accshift:// non reconnu", + "toast.deepLinkDisabled": "Les liens accshift:// sont désactivés dans les paramètres", + "toast.deepLinkUnknownPlatform": "Plateforme inconnue dans le lien : {platform}", + "toast.deepLinkPlatformDisabled": "{platform} est désactivé dans les paramètres", + "toast.deepLinkAccountNotFound": "Aucun compte {platform} correspondant à \"{account}\"", + "toast.deepLinkSwitched": "Basculé sur {account}", + "dialog.deepLinkSwitchTitle": "Changer de compte ?", + "dialog.deepLinkSwitchMessage": "Un lien accshift:// demande à basculer votre compte {platform} sur « {account} ». Continuer ?", + "dialog.deepLinkSwitchConfirm": "Basculer", + "steam.launchOnline": "Basculer en ligne", + "steam.launchInvisible": "Basculer en invisible", + "steam.switchAndRunGame": "Basculer + lancer {game}", + "steam.switchAndRunPick": "Basculer + lancer un jeu…", + "steam.runGame": "Lancer {game}", + "steam.runPick": "Lancer un jeu…", + "steam.switchAndRunNoGames": "Aucun jeu trouvé", + "steam.clearDefaultGame": "Oublier le jeu par défaut", + "steam.copySteamId64": "Copier SteamID64", + "steam.copyFriendCode": "Copier le code ami", + "steam.copyProfileUrl": "Copier l'URL du profil", + "steam.copyLabelUsername": "Login", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Code ami", + "steam.copyLabelProfileUrl": "URL du profil", + "steam.openUserdataFolder": "Ouvrir le dossier userdata", + "steam.clearIntegratedBrowserCache": "Réinitialiser la session navigateur", + "steam.clearIntegratedBrowserCacheConfirmTitle": "Réinitialiser la session navigateur Steam ?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "Cela ferme Steam et vide le cache du navigateur intégré utilisé par le magasin et la communauté.", + "steam.clearedIntegratedBrowserCache": "Session navigateur Steam réinitialisée", + "steam.copySettingsFrom": "Copier les paramètres depuis", + "steam.copiedSettingsToCurrent": "Paramètres de {game} copiés vers le compte courant", + "steam.forget": "Oublier", + "steam.forgetConfirmTitle": "Oublier \"{display}\" ?", + "steam.forgetConfirmMessage": "Ce compte sera retiré de ta liste de comptes Steam sur ce PC.", + "steam.forgotAccount": "{username} oublié", + "steam.setupPendingLabel": "Compte Steam", + "steam.setupWaitingForClient": "En attente de Steam...", + "steam.setupWaitingForLogin": "Connecte-toi au compte Steam que tu veux ajouter.", + "steam.setupDetected": "Compte détecté", + "steam.setupConnected": "Connecté", + "steam.setupReady": "Compte Steam ajouté", + "steam.setupReadyWithProfile": "{profile} ajouté", + "steam.setupFailed": "Échec de la configuration Steam", + "steam.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Steam.", + "riot.captureSession": "Capturer la session actuelle", + "riot.recaptureSession": "Recapturer la session", + "riot.capturedSession": "Session capturée pour {profile}", + "riot.setupPendingLabel": "Compte Riot", + "riot.setupWaitingForClient": "En attente du client Riot...", + "riot.setupWaitingForLogin": "Connecte-toi au compte Riot que tu veux ajouter.", + "riot.setupCapturing": "Sauvegarde de la session Riot...", + "riot.setupReady": "Compte Riot ajouté", + "riot.setupReadyWithProfile": "{profile} ajouté", + "riot.setupFailed": "Échec de la configuration Riot", + "riot.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Riot.", + "riot.setupStaySignedIn": "Garde 'Rester connecté' activé.", + "riot.setupDetected": "Compte détecté", + "riot.setupConnected": "Connecté", + "riot.forget": "Oublier le compte", + "riot.forgetConfirmTitle": "Oublier \"{display}\" ?", + "riot.forgetConfirmMessage": "Cela retire le profil Riot local et sa session sauvegardée d'Accshift.", + "riot.forgotProfile": "{profile} oublié", + "battlenet.copyUsername": "Copier le pseudo", + "battlenet.copyLabelUsername": "Pseudo", + "battlenet.copyEmail": "Copier l'email", + "battlenet.copyLabelEmail": "Email", + "battlenet.forget": "Oublier", + "battlenet.forgetConfirmTitle": "Oublier \"{display}\" ?", + "battlenet.forgetConfirmMessage": "Ce compte sera retiré de la liste de comptes Battle.net sur ce PC.", + "battlenet.forgotAccount": "{email} oublié", + "battlenet.setupPendingLabel": "Compte Battle.net", + "battlenet.setupWaitingForClient": "En attente de Battle.net...", + "battlenet.setupWaitingForLogin": "Connecte-toi au compte Battle.net que tu veux ajouter.", + "battlenet.setupKeepMeLoggedIn": "Garde 'Rester connecté' activé si possible.", + "battlenet.setupDetected": "Compte détecté", + "battlenet.setupConnected": "Connecté", + "battlenet.setupReady": "Compte Battle.net ajouté", + "battlenet.setupReadyWithProfile": "{profile} ajouté", + "battlenet.setupFailed": "Échec de la configuration Battle.net", + "battlenet.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Battle.net.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Oublier", + "ubisoft.forgetConfirmTitle": "Oublier \"{display}\" ?", + "ubisoft.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "ubisoft.forgotAccount": "{display} oublié", + "ubisoft.setupPendingLabel": "Compte Ubisoft", + "ubisoft.setupWaitingForClient": "En attente d'Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Connecte-toi au compte Ubisoft que tu veux ajouter.", + "ubisoft.setupKeepMeLoggedIn": "Garde 'Se souvenir de moi' activé.", + "ubisoft.setupDetected": "Compte détecté", + "ubisoft.setupConnected": "Connecté", + "ubisoft.setupReady": "Compte Ubisoft ajouté", + "ubisoft.setupReadyWithProfile": "{profile} ajouté", + "ubisoft.setupFailed": "Échec de la configuration Ubisoft", + "ubisoft.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Ubisoft.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "Aucun compte Roblox trouvé. Ajoute un compte via Quick Login ou colle un cookie.", + "roblox.noAccountsHint": "Appuie sur + pour ajouter un compte Roblox via Quick Login.", + "roblox.sessionExpired": "Session expirée", + "roblox.sessionExpiredTooltip": "Ce compte est déconnecté. Reconnecte-le avant de switcher.", + "roblox.sessionExpiredSwitchError": "Session expirée. Re-lie ce compte pour switcher. Notes et réglages conservés.", + "roblox.cookiePasteTitle": "Ajouter un compte par cookie", + "roblox.cookiePasteHint": "Colle un cookie .ROBLOSECURITY depuis ton navigateur pour ajouter un compte directement.", + "settings.showRobloxLastLogin": "Afficher la dernière connexion", + "settings.robloxHealthCheck": "Vérification de session", + "settings.robloxHealthCheckHint": "Signale les comptes dont la session Roblox a expiré ; une requête réseau par lancement.", + "roblox.copyLabelUsername": "Pseudo", + "roblox.copyLabelUserId": "ID utilisateur", + "roblox.forget": "Oublier", + "roblox.forgetConfirmTitle": "Oublier \"{display}\" ?", + "roblox.forgetConfirmMessage": "Ce compte et son cookie stocké seront supprimés de ce PC.", + "roblox.forgotAccount": "{display} oublié", + "roblox.setupPendingLabel": "Compte Roblox", + "roblox.setupWaitingForClient": "Connexion à Roblox...", + "roblox.setupWaitingForLogin": "Ouvre l'app Roblox sur un autre appareil et entre le code ci-dessous.", + "roblox.setupQuickLoginCode": "Code Quick Login", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Copier le code", + "roblox.setupDetected": "Compte détecté", + "roblox.setupConnected": "Connecté", + "roblox.setupReady": "Compte Roblox ajouté", + "roblox.setupReadyWithProfile": "{profile} ajouté", + "roblox.setupFailed": "Échec de la configuration Roblox", + "roblox.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Roblox.", + "settings.epic": "Epic Games", + "settings.epicPath": "Exécutable Epic Games Launcher", + "settings.showEpicLastLogin": "Afficher la dernière connexion", + "toast.noEpicAccountsFound": "Aucun compte Epic Games trouvé. Connecte-toi à Epic Games Launcher au moins une fois puis rafraîchis.", + "epic.noAccountsHint": "Appuie sur + pour ajouter un compte Epic Games.", + "epic.copyLabelAccountId": "ID de compte", + "epic.forget": "Oublier", + "epic.forgetConfirmTitle": "Oublier \"{display}\" ?", + "epic.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "epic.forgotAccount": "{display} oublié", + "epic.setupPendingLabel": "Compte Epic Games", + "epic.setupWaitingForClient": "En attente d'Epic Games Launcher...", + "epic.setupWaitingForLogin": "Connecte-toi au compte Epic Games que tu veux ajouter.", + "epic.setupKeepMeLoggedIn": "Garde 'Se souvenir de moi' activé.", + "epic.setupDetected": "Compte détecté", + "epic.setupConnected": "Connecté", + "epic.setupReady": "Compte Epic Games ajouté", + "epic.setupReadyWithProfile": "{profile} ajouté", + "epic.setupFailed": "Échec de la configuration Epic Games", + "epic.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Epic Games.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "Exécutable GOG Galaxy", + "settings.showGogLastLogin": "Afficher la dernière connexion", + "toast.noGogAccountsFound": "Aucun compte GOG Galaxy trouvé. Connecte-toi à GOG Galaxy au moins une fois puis rafraîchis.", + "gog.noAccountsHint": "Appuie sur + pour ajouter un compte GOG Galaxy.", + "gog.copyLabelAccountId": "ID de compte", + "gog.forget": "Oublier", + "gog.forgetConfirmTitle": "Oublier \"{display}\" ?", + "gog.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "gog.forgotAccount": "{display} oublié", + "gog.setupPendingLabel": "Compte GOG Galaxy", + "gog.setupWaitingForClient": "En attente de GOG Galaxy...", + "gog.setupWaitingForLogin": "Connecte-toi au compte GOG Galaxy que tu veux ajouter.", + "gog.setupKeepMeLoggedIn": "Garde 'Rester connecté' activé.", + "gog.setupDetected": "Compte détecté", + "gog.setupConnected": "Connecté", + "gog.setupReady": "Compte GOG Galaxy ajouté", + "gog.setupReadyWithProfile": "{profile} ajouté", + "gog.setupFailed": "Échec de la configuration GOG Galaxy", + "gog.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte GOG Galaxy.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Exécutable Jagex Launcher", + "settings.showJagexLastLogin": "Afficher la dernière connexion", + "toast.noJagexAccountsFound": "Aucun compte Jagex enregistré. Appuie sur + pour en ajouter un via le Jagex Launcher.", + "jagex.noAccountsHint": "Appuie sur + pour ajouter un compte Jagex.", + "jagex.copyLabelAccountId": "ID de compte", + "jagex.forget": "Oublier", + "jagex.forgetConfirmTitle": "Oublier \"{display}\" ?", + "jagex.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "jagex.forgotAccount": "{display} oublié", + "jagex.setupPendingLabel": "Compte Jagex", + "jagex.setupWaitingForClient": "En attente du Jagex Launcher...", + "jagex.setupWaitingForLogin": "Connecte-toi au compte Jagex que tu veux ajouter.", + "jagex.setupKeepMeLoggedIn": "Garde 'Se souvenir de moi' activé.", + "jagex.setupDetected": "Compte détecté", + "jagex.setupConnected": "Connecté", + "jagex.setupReady": "Compte Jagex ajouté", + "jagex.setupReadyWithProfile": "{profile} ajouté", + "jagex.setupFailed": "Échec de la configuration Jagex", + "jagex.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Jagex.", + "settings.discord": "Discord", + "settings.discordPath": "Exécutable Discord", + "settings.showDiscordLastLogin": "Afficher la dernière connexion", + "toast.noDiscordAccountsFound": "Aucun compte Discord trouvé. Ajoute un compte Discord puis rafraîchis.", + "discord.noAccountsHint": "Appuie sur + pour ajouter un compte Discord.", + "discord.copyLabelAccountId": "ID de compte", + "discord.forget": "Oublier", + "discord.forgetConfirmTitle": "Oublier \"{display}\" ?", + "discord.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "discord.forgotAccount": "{display} oublié", + "discord.setupPendingLabel": "Compte Discord", + "discord.setupWaitingForClient": "En attente de Discord...", + "discord.setupWaitingForLogin": "Connecte-toi au compte Discord que tu veux ajouter.", + "discord.setupKeepMeLoggedIn": "Reste connecté pour conserver ce compte.", + "discord.setupDetected": "Compte détecté", + "discord.setupConnected": "Connecté", + "discord.setupReady": "Compte Discord ajouté", + "discord.setupReadyWithProfile": "{profile} ajouté", + "discord.setupFailed": "Échec de la configuration Discord", + "discord.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Discord.", + "color.none": "Défaut", + "color.blue": "Bleu", + "color.cyan": "Cyan", + "color.green": "Vert", + "color.lime": "Citron vert", + "color.yellow": "Jaune", + "color.orange": "Orange", + "color.red": "Rouge", + "color.pink": "Rose", + "color.violet": "Violet", + "color.gray": "Gris", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Sombre", + "theme.light": "Clair", + "theme.midnight": "Minuit", + "theme.glassDark": "Verre sombre", + "theme.glassLight": "Verre clair", + "theme.liquidGlass": "Verre liquide", + "theme.custom": "Personnalisé", + "settings.themeImport": "Importer", + "settings.themeExport": "Exporter", + "settings.themeDelete": "Supprimer le thème", + "settings.themeImported": "Thème importé", + "settings.themeDeleted": "Thème supprimé", + "settings.themeExportCopied": "JSON du thème copié dans le presse-papiers", + "settings.themeInvalidJson": "JSON de thème invalide", + "settings.themeCustomize": "Personnaliser", + "settings.themeEdit": "Modifier", + "settings.themeCopyName": "{name} copie", + "settings.themeSaved": "Thème enregistré", + "settings.themeMigrated": "Thème importé et mis au format actuel", + "settings.themeInvalidId": "L'identifiant du thème est absent ou invalide", + "settings.themeInvalidName": "Le thème n'a pas de nom", + "settings.themeUnsupportedVersion": "Ce thème nécessite une version plus récente d'accshift", + "themeGroup.surface": "Surfaces", + "themeGroup.text": "Texte", + "themeGroup.semantic": "Couleurs d'état", + "themeGroup.shape": "Forme et densité", + "themeGroup.typography": "Typographie", + "themeGroup.motion": "Mouvement", + "themeKind.rgbTriplet": "triplet RVB", + "themeKind.hexColor": "couleur hexadécimale", + "themeKind.color": "couleur", + "themeKind.length": "longueur", + "themeKind.shadow": "ombre", + "themeKind.choice": "choix", + "themeKind.fontStack": "police", + "themeKind.signedLength": "espacement", + "themeKind.gradient": "dégradé", + "themeKind.number": "nombre", + "themeDensity.compact": "Compact", + "themeDensity.cozy": "Normal", + "themeDensity.comfortable": "Confortable", + "themeAvatarShape.circle": "Rond", + "themeAvatarShape.rounded": "Arrondi", + "themeAvatarShape.square": "Carré", + "themeSmoothing.auto": "Lissé", + "themeSmoothing.none": "Pixel", + "themeToken.bgRgb": "Fond de la fenêtre", + "themeToken.bgCard": "Fond des cartes", + "themeToken.bgCardHover": "Fond des cartes au survol", + "themeToken.bgMuted": "Fond atténué", + "themeToken.bgElevated": "Fond en relief", + "themeToken.border": "Bordures", + "themeToken.fg": "Texte principal", + "themeToken.fgMuted": "Texte secondaire", + "themeToken.fgSubtle": "Texte discret", + "themeToken.afkText": "Mention absent", + "themeToken.accent": "Accent", + "themeToken.accentFg": "Texte sur l'accent", + "themeToken.success": "Succès", + "themeToken.warning": "Avertissement", + "themeToken.danger": "Danger", + "themeToken.radiusSm": "Petit arrondi", + "themeToken.radiusMd": "Arrondi moyen", + "themeToken.radiusLg": "Grand arrondi", + "themeToken.elevationLow": "Ombre légère", + "themeToken.elevationMedium": "Ombre moyenne", + "themeToken.elevationHigh": "Ombre forte", + "themeToken.density": "Densité", + "themeToken.fontUi": "Police de l'interface", + "themeToken.bgImage": "Dégradé de la fenêtre", + "themeToken.cardBgImage": "Dégradé des surfaces", + "themeToken.borderWidth": "Épaisseur des bordures", + "themeToken.borderStyle": "Style des bordures", + "themeToken.avatarShape": "Forme des avatars", + "themeToken.focusRing": "Contour de focus", + "themeToken.overlayBlur": "Flou d'arrière-plan", + "themeToken.fontDisplay": "Police des titres", + "themeToken.lineHeight": "Hauteur de ligne", + "themeToken.letterSpacing": "Interlettrage", + "themeToken.labelCase": "Casse des libellés", + "themeToken.fontSmoothing": "Lissage du texte", + "themeToken.motionScale": "Vitesse des animations", + "themeEditor.title": "Éditeur de thème", + "themeEditor.name": "Nom", + "themeEditor.author": "Auteur", + "themeEditor.version": "Version", + "themeEditor.base": "Hérite de", + "themeEditor.colorScheme": "Mode de couleurs", + "themeEditor.glass": "Surfaces translucides", + "themeEditor.inherited": "Hérité", + "themeEditor.builtInBase": "Thème intégré", + "themeEditor.reset": "Revenir à la valeur héritée", + "themeEditor.issues": "Vérifications", + "themeEditor.noIssues": "Rien à signaler", + "themeEditor.fixErrorsFirst": "Corrigez les valeurs refusées avant d'enregistrer", + "themeEditor.save": "Enregistrer", + "themeEditor.saveFailed": "Impossible d'enregistrer le thème. Réessaie.", + "themeEditor.export": "Copier", + "themeEditor.issueInvalidValue": "{token} : {kind} invalide, par exemple {example}", + "themeEditor.issueUnknownToken": "{token} n'est pas une valeur de thème, elle est ignorée", + "themeEditor.issueMissingToken": "{token} n'est pas défini, la valeur intégrée est utilisée", + "themeEditor.issueUnknownBase": "Le thème de base {base} n'existe pas, les valeurs intégrées sont utilisées", + "themeEditor.issueContrast": "{token} sur {against} : {ratio} pour 1, sous la cible de {target} pour 1", + "themeEditor.beta": "Bêta", + "themeEditor.betaNote": "Le format de thème bouge encore : un thème écrit aujourd'hui peut demander une retouche après une mise à jour.", + "themeEditor.customCss": "CSS personnalisé", + "themeEditor.customCssHelp": "Appliqué par-dessus le thème, pour ce que les valeurs ci-dessus n'atteignent pas. Ni @import ni url() : un thème ne va rien chercher à l'extérieur.", + "themeEditor.issueUnsafeCss": "{construct} est interdit dans un thème, le CSS n'est pas appliqué", + "themeEditor.issueCssTooLong": "CSS trop long : {length} caractères, la limite est {limit}", + "bulkEdit.title": "Édition en masse", + "bulkEdit.selectAccounts": "Sélectionner les comptes", + "bulkEdit.selectAll": "Tout sélectionner", + "bulkEdit.deselectAll": "Tout désélectionner", + "bulkEdit.next": "Suivant", + "bulkEdit.apply": "Appliquer", + "bulkEdit.settings": "Paramètres", + "bulkEdit.newsPopup": "Popup d'actualités jeux", + "bulkEdit.doNotDisturb": "Ne pas déranger", + "bulkEdit.launchOptions": "Options de lancement par jeu", + "bulkEdit.unchanged": "Inchangé", + "bulkEdit.addLaunchOption": "Ajouter", + "bulkEdit.selectGame": "Sélectionner un jeu...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "Le compte actuellement actif est sélectionné. Les modifications seront prises en compte au prochain redémarrage de Steam.", + "bulkEdit.toastSuccess": "Édition en masse : {count} réussi(s)", + "bulkEdit.toastPartial": "Édition en masse : {succeeded} réussi(s), {failed} échoué(s)", + "bulkEdit.toastFailed": "Échec de l'édition en masse", + "bulkEdit.noAccountsSelected": "Aucun compte sélectionné", + "bulkEdit.selected": "{count} sélectionné(s)", + "bulkEdit.loadingGames": "Chargement des jeux...", + "bulkEdit.copyUrls": "Copier les URLs", + "bulkEdit.urlsCopied": "{count} URL(s) de profil copiée(s)", + "bulkEdit.cardColor": "Couleur de carte", + "bulkEdit.colorApplied": "Couleur appliquée à {count} carte(s)", + "bulkEdit.colorCleared": "Couleur retirée de {count} carte(s)", + "common.retry": "Réessayer", + "app.searchNoResults": "Aucun résultat pour « {query} »", + "app.folderEmpty": "Ce dossier est vide", + "app.loadErrorGeneric": "Impossible de charger les comptes. Regarde la console pour les détails.", + "toast.dismiss": "Fermer", + "toast.switchFailed": "Le changement de compte a échoué. Regarde la console pour les détails.", + "toast.addAccountFailed": "Impossible d'ajouter le compte. Consulte les logs de l'application pour les détails.", + "toast.addAccountClientMissing": "{platform} ne semble pas installé. Installe-le, ou renseigne son emplacement dans les Paramètres.", + "toast.banCheckFailedGeneric": "La vérification des bans a échoué. Regarde la console pour les détails.", + "toast.renameAccountFailed": "Renommage échoué : {error}", + "toast.copyFailed": "Impossible de copier dans le presse-papiers", + "toast.refreshFailed": "Le rafraîchissement a échoué. Regarde la console pour les détails.", + "dialog.deleteFolderTitle": "Supprimer « {name} » ?", + "dialog.deleteFolderMessage": "Les sous-dossiers sont supprimés aussi. Les comptes qu'ils contiennent remontent d'un niveau, ils ne sont pas supprimés.", + "personas.deleteConfirm": "Clique encore pour supprimer", + "personas.discardConfirm": "Clique encore pour abandonner", + "personas.switchConfirmTitle": "Basculer de persona", + "personas.switchConfirmMessage": "Basculer {count} plateforme(s) vers « {name} » ? Chaque client va fermer et se relancer.", + "personas.switchConfirmAction": "Basculer", + "personas.deleteTitle": "Supprimer le persona", + "personas.deleteMessage": "Supprimer « {name} » ? Les comptes eux-mêmes ne sont pas touchés.", + "context.submenuError": "Impossible de charger ce menu", + "settings.apiKeySaveFailed": "Impossible d'enregistrer la clé API Steam", + "settings.apiKeyInvalidHint": "La clé n'a pas été enregistrée. Vérifie-la et réessaie.", + "settings.apiKeyCleared": "Clé API Steam effacée", + "settings.apiKeyClearFailed": "Impossible d'effacer la clé API Steam", + "settings.clearApiKey": "Effacer la clé", + "settings.cs2Bridge": "Pont stats CS2", + "settings.cs2BridgeLink": "Lien du pont", + "settings.cs2BridgeLinkPlaceholder": "https://ton-serveur/api/bridge/accshift/", + "settings.cs2BridgeTest": "Tester", + "settings.cs2BridgeStatusOk": "Connecté · {count} comptes · {ms} ms", + "settings.cs2BridgeStatusFail": "Connexion échouée", + "settings.cs2BridgeStatusTesting": "Test en cours...", + "settings.cs2BridgeStatusUnknown": "Pas testé", + "settings.cs2BridgeToken": "Token API", + "settings.cs2BridgeTokenOptional": "optionnel, pour serveurs custom", + "settings.cs2BridgeTokenPlaceholder": "Token Bearer si ton serveur en demande un", + "settings.cs2BridgeSaveFailed": "Impossible d'enregistrer les réglages du pont CS2", + "settings.cs2BridgeClearToken": "Effacer le token", + "settings.pathSaveFailed": "Impossible d'enregistrer le chemin {platform}", + "settings.pathInvalidHint": "Ce chemin n'a pas pu être enregistré. Vérifie-le et réessaie.", + "settings.pathSteamNeverSignedIn": "Steam trouvé, connecte-toi une fois pour qu'il crée son historique de connexion", + "settings.pathNotSteamFolder": "Ce dossier ne ressemble pas à une installation Steam", + "settings.pathNotADirectory": "Le chemin doit être un dossier existant", + "settings.pinSaved": "Code PIN enregistré", + "settings.pinTakesEffectNextLaunch": "Prend effet au prochain lancement de l'app", + "settings.pinRequiredAfterInactivity": "Demandé après inactivité et au lancement de l'app", + "settings.pinScreenLockOnly": "Le PIN verrouille cette fenêtre et les changements de compte. Il ne protège rien sur le disque.", + "settings.unitSeconds": "secondes", + "settings.unitDays": "jours", + "settings.telemetryLoadFailed": "Impossible de charger les paramètres de télémétrie", + "palette.placeholder": "Tape une commande ou un nom de compte...", + "palette.noResults": "Aucune commande ne correspond", + "palette.sectionAccounts": "Comptes", + "palette.sectionActions": "Actions", + "palette.sectionNavigation": "Navigation", + "palette.openTab": "Aller à {platform}", + "palette.openFolder": "Ouvrir le dossier « {name} »", + "palette.backToParent": "Revenir au dossier parent", + "palette.zoomReset": "Réinitialiser le zoom" +} diff --git a/crates/accshift-app/i18n/pt-br.json b/crates/accshift-app/i18n/pt-br.json new file mode 100644 index 00000000..0e7581f9 --- /dev/null +++ b/crates/accshift-app/i18n/pt-br.json @@ -0,0 +1,753 @@ +{ + "app.loadingSettings": "Carregando configurações...", + "app.loading": "Carregando...", + "app.searchPlaceholder": "Buscar conta...", + "app.noAccountsFound": "Nenhuma conta {platform} encontrada", + "app.noAccountsHint": "Verifique se o {platform} está instalado e se você já fez login pelo menos uma vez.", + "app.switchingAccount": "Trocando de conta...", + "app.platformRestarting": "{platform} está reiniciando", + "app.comingSoon": "{platform} - Em breve", + "app.platformUnsupportedOs": "{platform} ainda não está disponível neste sistema", + "app.noCompatiblePlatforms": "Nenhuma plataforma compatível está disponível neste sistema", + "app.noCompatiblePlatformsHint": "Acompanhe as novidades de suporte no Windows ou use uma máquina compatível.", + "update.ctaAvailable": "Atualização disponível", + "update.ctaInstalling": "Instalando...", + "update.restartToApplyVersion": "Reiniciar para aplicar a atualização {version}", + "update.restartToApply": "Reiniciar para aplicar a atualização", + "update.readyToastVersion": "Atualização {version} pronta", + "update.readyToast": "Atualização pronta", + "update.restartFailed": "Não foi possível reiniciar para aplicar a atualização", + "pin.lockedTitle": "Aplicativo bloqueado", + "pin.lockedPrompt": "Digite o PIN para desbloquear", + "pin.placeholder": "PIN de 4 dígitos", + "pin.unlock": "Desbloquear", + "pin.invalid": "PIN inválido", + "pin.tooManyAttempts": "Muitas tentativas. Tente novamente em {seconds} s.", + "common.ok": "OK", + "common.cancel": "Cancelar", + "common.confirm": "Confirmar", + "common.close": "Fechar", + "common.back": "Voltar", + "common.active": "Ativa", + "common.choose": "Escolher...", + "common.none": "Nenhum", + "common.custom": "Personalizado", + "common.configured": "Configurado", + "common.missing": "Ausente", + "common.enabled": "Ativado", + "common.disabled": "Desativado", + "common.on": "Ligado", + "common.off": "Desligado", + "common.add": "Adicionar", + "common.visible": "Visível", + "common.hidden": "Oculto", + "common.inline": "Na linha", + "common.tooltip": "Dica", + "titlebar.refresh": "Atualizar", + "titlebar.addAccount": "Adicionar conta", + "titlebar.settings": "Configurações", + "titlebar.personas": "Personas", + "titlebar.minimize": "Minimizar", + "titlebar.maximize": "Maximizar", + "titlebar.restore": "Restaurar", + "titlebar.close": "Fechar", + "view.grid": "Visualização em grade", + "view.list": "Visualização em lista", + "settings.title": "Configurações", + "settings.general": "Geral", + "settings.appearance": "Aparência", + "settings.language": "Idioma", + "settings.theme": "Tema", + "settings.uiZoom": "Zoom da interface", + "settings.backgroundOpacity": "Opacidade do fundo", + "settings.animations": "Animações", + "settings.animationsSystem": "Seguir o sistema", + "settings.animationsOn": "Sempre ativas", + "settings.animationsOff": "Desativadas", + "settings.performance": "Desempenho", + "settings.suspendGraphicsWhenMinimized": "Suspender gráficos ao minimizar", + "settings.minimizeOnAccountSwitch": "Minimizar ao trocar de conta", + "settings.system": "Sistema", + "settings.startWithSystem": "Iniciar na inicialização", + "settings.startWithSystemHint": "Inicia o Accshift com o Windows, minimizado na área de notificação.", + "settings.closeToTray": "Fechar para a área de notificação", + "settings.closeToTrayHint": "O botão de fechar oculta o Accshift na área de notificação em vez de sair.", + "settings.autostartFailed": "Não foi possível alterar a inicialização automática.", + "tray.open": "Abrir o Accshift", + "tray.quit": "Sair", + "settings.platforms": "Plataformas", + "settings.platformSearchPlaceholder": "Buscar plataformas...", + "settings.platformSearchNoResults": "Nenhuma plataforma corresponde à sua busca.", + "settings.accountDisplay": "Exibição das contas", + "settings.startupAndExtras": "Inicialização", + "settings.defaultOnStartup": "Plataforma padrão ao iniciar", + "settings.platformDisabledSuffix": "(desativada)", + "settings.platformUnsupportedOs": "Sistema não suportado", + "settings.platformNotImplemented": "Planejada", + "settings.platformUserProvided": "Adicionada a partir da sua pasta de descritores", + "settings.customPlatforms": "Plataformas personalizadas", + "settings.customPlatformsHint": "Os arquivos de descritor desta pasta são lidos ao iniciar o aplicativo.", + "settings.customPlatformsNone": "Nenhum descritor carregado desta pasta.", + "settings.customPlatformsProblems": "Arquivos que não carregaram", + "descriptor.addFromFile": "Adicionar de um arquivo", + "descriptor.reload": "Recarregar", + "descriptor.openFolder": "Abrir pasta", + "descriptor.remove": "Remover", + "descriptor.removeConfirmTitle": "Remover {platform}?", + "descriptor.removeConfirmMessage": "O arquivo de descritor sai da sua pasta. As contas já capturadas continuam no disco.", + "descriptor.previewTitle": "Adicionar uma plataforma", + "descriptor.previewOs": "Descrita para {list}", + "descriptor.previewRoots": "Pastas em que pode mexer", + "descriptor.previewSteps": "O que uma troca de conta faria", + "descriptor.previewWarnings": "Avisos", + "descriptor.previewNoPlan": "Não foi possível montar uma simulação: {problem}", + "descriptor.previewReplaces": "Isso substitui o descritor que já está na sua pasta.", + "descriptor.previewNothingWritten": "Nada é gravado até você adicionar.", + "descriptor.replace": "Substituir", + "descriptor.installed": "{platform} adicionada", + "descriptor.removed": "{platform} removida", + "descriptor.actionRead": "ler", + "descriptor.actionCapture": "capturar", + "descriptor.actionRestore": "restaurar", + "descriptor.actionDelete": "remover", + "descriptor.actionClose": "fechar", + "descriptor.actionLaunch": "iniciar", + "settings.dataRefresh": "Atualização de dados", + "settings.avatarRefresh": "Atualizar avatares", + "settings.banCheckDelay": "Intervalo da checagem de banimentos", + "settings.refreshNow": "Atualizar agora", + "settings.zeroEachLaunch": "0 = a cada inicialização", + "settings.privacy": "Privacidade", + "settings.inactivityTimeout": "Tempo de inatividade", + "settings.zeroDisabled": "0 = desativado", + "settings.streamerMode": "Modo streamer", + "settings.streamerModeHint": "Desfoca os detalhes das contas automaticamente quando um software de transmissão está em execução.", + "settings.personasHint": "Mostrar o seletor de identidades personas", + "settings.replayOnboarding": "Rever a introdução", + "settings.replayOnboardingHint": "Mostra de novo o tour de boas-vindas e a escolha de estatísticas de uso.", + "streamer.title": "Modo streamer", + "streamer.disable": "Desativar", + "streamer.disablePermanently": "Desativar permanentemente", + "personas.title": "Personas", + "personas.create": "Nova persona", + "personas.activate": "Trocar para esta persona", + "personas.edit": "Editar", + "personas.delete": "Excluir", + "personas.finish": "Concluir", + "personas.switching": "Trocando...", + "personas.switched": "Trocado para {name}", + "personas.switchPartial": "{name}: falhou em {failed}", + "personas.switchFailed": "Não foi possível trocar para {name}", + "personas.namePlaceholder": "Nome da persona", + "personas.choosePlatforms": "Escolha uma conta para cada plataforma incluída", + "personas.chooseAccount": "Escolha a conta {platform}", + "personas.removeFromPersona": "Remover desta persona", + "personas.customImage": "Escolher uma imagem...", + "personas.removeImage": "Remover imagem", + "personas.imageError": "Não foi possível carregar essa imagem.", + "personas.loadingAccounts": "Carregando contas...", + "personas.noAccountsOnPlatform": "Ainda não há contas nesta plataforma", + "personas.emptyHint": "Um clique troca cada plataforma incluída para a conta dela.", + "settings.security": "Segurança", + "settings.pinLockOnAfk": "Bloqueio por PIN ao ficar ausente", + "settings.pinCode": "Código PIN", + "settings.pinPlaceholder": "4 dígitos", + "settings.pinCurrentCode": "PIN atual", + "settings.pinCurrentHint": "Digite o PIN atual para alterar ou desativar o bloqueio.", + "settings.telemetry": "Estatísticas de uso (anônimas)", + "settings.telemetryModeA": "Estatísticas básicas anônimas", + "settings.telemetryModeAHint": "Conta as inicializações e quais recursos são usados. Um ID local aleatório evita contar a mesma instalação duas vezes; nenhum dado pessoal é enviado.", + "settings.telemetryModeB": "Estatísticas avançadas com UUID local (opcional)", + "settings.telemetryModeBHint": "Adiciona um UUID aleatório guardado apenas nesta máquina. Permite medir a retenção e quais recursos importam. Continua sem enviar nenhum dado pessoal.", + "settings.telemetryExport": "Exportar meus dados", + "settings.telemetryExported": "Dados exportados para a área de transferência", + "settings.telemetryExportFailed": "Falha na exportação", + "settings.telemetryEnableFailed": "Não foi possível ativar as estatísticas avançadas", + "settings.telemetryDisableFailed": "Não foi possível desativar as estatísticas avançadas", + "settings.telemetryDeletePending": "As estatísticas avançadas estão desativadas; a exclusão no servidor vai precisar de outra tentativa", + "settings.telemetryDeletePendingHint": "A exclusão no servidor está pendente. O ID local antigo é mantido só para você tentar de novo ou exportar os dados associados.", + "settings.telemetryRetryDelete": "Tentar excluir os dados de novo", + "settings.telemetryDeleteCompleted": "Dados do servidor excluídos", + "settings.telemetryDeleteRetryFailed": "A exclusão no servidor continua pendente", + "settings.telemetryLearnMore": "Quais dados são enviados?", + "onboarding.welcome.title": "Bem-vindo ao Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "Seu sistema operacional é compatível com:", + "onboarding.welcome.detectedPlatforms": "Estas são as plataformas detectadas no seu computador:", + "onboarding.welcome.next": "Quero conhecer", + "onboarding.welcome.skip": "Pular", + "onboarding.features.hint": "Esta parte do app não está visível agora.", + "onboarding.features.addAccount.label": "Adicionar uma conta", + "onboarding.features.addAccount.body": "O botão + no canto superior direito adiciona uma conta à plataforma atual. Faça login uma vez e o Accshift guarda a conta.", + "onboarding.features.switch.label": "Trocar de conta", + "onboarding.features.switch.body": "Clique em um card e você já está nessa conta. Sinceramente, o app inteiro é isso.", + "onboarding.features.contextMenu.label": "Botão direito para mais ações", + "onboarding.features.contextMenu.body": "Clique com o botão direito em um card para renomear, mudar a cor, mover para uma pasta ou excluir.", + "onboarding.features.platformSwitch.label": "Trocar de plataforma", + "onboarding.features.platformSwitch.body": "Funciona igual para Riot, Battle.net, Epic e o resto. As abas de cima trocam de plataforma.", + "onboarding.features.next": "Avançar", + "onboarding.features.back": "Voltar", + "onboarding.features.step": "Etapa {step}", + "onboarding.features.mockRename": "Renomear", + "onboarding.features.mockSetColor": "Definir cor", + "onboarding.features.mockMoveToFolder": "Mover para pasta", + "onboarding.features.mockDelete": "Excluir", + "onboarding.features.mockAccount": "Conta {number}", + "onboarding.telemetry.title": "VENDA SUA ALMA PARA O ACCSHIFT!", + "onboarding.telemetry.intro": "O Accshift conta algumas estatísticas de uso anônimas para eu saber o que consertar primeiro. Nada pessoal sai da sua máquina, o app funciona igual de qualquer jeito, e você pode mudar de ideia quando quiser nas configurações.", + "onboarding.telemetry.question": "Fechado?", + "onboarding.telemetry.gifAlt": "Meme da proposta de troca", + "onboarding.telemetry.basic": "NÃO! Já chega", + "onboarding.telemetry.basicDefault": "(padrão)", + "onboarding.telemetry.basicHint": "Só contadores anônimos e nada mais: sem perfil, sem identificador que te siga.", + "onboarding.telemetry.deal": "FECHADO", + "onboarding.telemetry.dealHint": "Adiciona um UUID separado para as estatísticas avançadas, guardado só na sua máquina. Continuo sem ideia de quem você é, mas você ajuda muito. Obrigado!", + "onboarding.telemetry.optOutNote": "Escolha o que escolher, dá para mudar depois em Configurações -> Privacidade. :c", + "onboarding.telemetry.refuse": "Não, obrigado, desligue tudo", + "onboarding.telemetry.refuseHint": "Nada é medido nem enviado, nem os contadores anônimos.", + "onboarding.telemetry.back": "Voltar", + "onboarding.persistFailed": "Não foi possível salvar essa escolha. Tente de novo.", + "onboarding.persistLater": "Perguntar na próxima abertura", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Executar o Steam como admin", + "settings.showUsernames": "Mostrar nomes de usuário", + "settings.showSteamLastLogin": "Mostrar último login do Steam", + "settings.showRiotLastLogin": "Mostrar último login da Riot", + "settings.showBattleNetLastLogin": "Mostrar último login da Battle.net", + "settings.showUbisoftLastLogin": "Mostrar último login da Ubisoft", + "settings.showNotesUnderCards": "Mostrar notas sob os cards", + "settings.expandedFolders": "Expandir todas as pastas", + "list.rootSection": "Raiz", + "settings.steamLaunch": "Troca", + "settings.steamInstallation": "Instalação", + "settings.steamApiAndData": "API e dados", + "settings.launchOptions": "Opções de inicialização", + "settings.shutdownMode": "Modo de encerramento", + "settings.shutdownModeGraceful": "Suave", + "settings.shutdownModeForce": "Forçar encerramento", + "settings.autoDetected": "Detectado automaticamente", + "settings.steamFolder": "Pasta do Steam", + "settings.steamFolderRegistry": "Registro", + "settings.riotClientPath": "Executável do Riot Client", + "settings.battleNetPath": "Executável da Battle.net", + "settings.ubisoftPath": "Pasta do Ubisoft Connect", + "settings.steamWebApiKey": "Chave da Steam Web API", + "settings.getKey": "Obter chave", + "settings.pasteApiKey": "Cole sua chave da Steam Web API", + "settings.saved": "Configurações salvas", + "settings.openApiKeyFailed": "Não foi possível abrir a página da chave da API Steam", + "settings.integrations": "Integrações", + "settings.deepLinks": "Permitir links accshift://", + "settings.deepLinksHint": "Deixe outros apps trocarem de conta por você: favoritos, Stream Deck, scripts.", + "settings.cliEnabled": "Permitir a CLI do accshift", + "settings.cliEnabledHint": "Deixa a ferramenta de linha de comando listar e trocar de contas. Desativado, todo comando accshift se recusa a rodar, diag incluído; o aplicativo em si não é afetado.", + "settings.help": "Abrir a wiki do accshift", + "settings.helpShort": "Ajuda", + "settings.openHelpFailed": "Não foi possível abrir a wiki", + "settings.logs": "Logs", + "settings.openLogs": "Abrir a pasta de logs", + "settings.openLogsFailed": "Não foi possível abrir a pasta de logs", + "context.loading": "Carregando...", + "context.noGamesFound": "Nenhum jogo encontrado", + "context.field.cut": "Recortar", + "context.field.copy": "Copiar", + "context.field.paste": "Colar", + "context.field.selectAll": "Selecionar tudo", + "context.menu.newFolder": "Nova pasta", + "context.menu.refresh": "Atualizar", + "context.menu.rename": "Renomear", + "context.menu.copy": "Copiar...", + "context.menu.deleteFolder": "Excluir pasta", + "context.menu.folderColor": "Cor da pasta", + "context.menu.moveToFolder": "Mover para uma pasta", + "context.menu.moveToRoot": "Sem pasta", + "context.menu.editCardAndColor": "Editar card e cor", + "context.menu.cardColor": "Cor do card", + "context.menu.addNote": "Adicionar nota", + "context.menu.editNote": "Editar nota", + "platform.rename": "Renomear", + "platform.renameTitle": "Renomear conta", + "platform.renamePlaceholder": "Nome da conta", + "platform.newAccountPending": "Aguardando...", + "platform.setupBusy": "Outra operação do accshift está em andamento. Aguardando que ela termine.", + "platform.renameNewAccount": "Dê um nome a esta conta", + "platform.copyLabelAccountId": "ID da conta", + "platform.forget": "Esquecer", + "platform.forgetConfirmTitle": "Esquecer \"{display}\"?", + "platform.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "platform.forgotAccount": "{display} esquecida", + "dialog.newFolderTitle": "Nova pasta", + "dialog.renameFolderTitle": "Renomear pasta", + "dialog.folderNamePlaceholder": "Nome da pasta", + "dialog.cardNoteTitle": "Nota do card", + "dialog.cardNotePlaceholder": "Escreva uma nota curta", + "card.noteAttached": "Nota anexada", + "card.clickAgainToSwitch": "Clique de novo para trocar", + "card.extensionWarnings": "Avisos", + "card.extensionNote": "Nota", + "card.cs2Section": "CS2", + "card.cs2Level": "Nível {level}", + "card.cs2CaseEarned": "Caixa semanal obtida", + "card.cs2CaseNotEarned": "Caixa semanal pendente", + "list.selectAccountPreview": "Selecione uma conta para pré-visualizar", + "preview.noteLabel": "Nota", + "preview.currentlyActive": "Ativa no momento", + "preview.switchAgain": "Trocar de novo", + "preview.switchAccount": "Trocar de conta", + "ban.community": "Banimento da comunidade", + "ban.vac.single": "{count} banimento VAC", + "ban.vac.multiple": "{count} banimentos VAC", + "ban.game.single": "{count} banimento de jogo", + "ban.game.multiple": "{count} banimentos de jogo", + "time.unknown": "desconhecido", + "time.justNow": "agora mesmo", + "time.minuteAgo": "há {count} minuto", + "time.minutesAgo": "há {count} minutos", + "time.hourAgo": "há {count} hora", + "time.hoursAgo": "há {count} horas", + "time.dayAgo": "há {count} dia", + "time.daysAgo": "há {count} dias", + "time.neverConnected": "nunca conectado", + "time.compactWrapper": "({value})", + "toast.copied": "{label} copiado", + "toast.legacyConfigMigrated": "Configurações migradas para o novo formato", + "toast.legacyConfigMigrationFailed": "Falha ao migrar as configurações antigas, veja os logs", + "toast.failedLoadSettingsPanel": "Falha ao carregar o painel de configurações", + "toast.banChecking": "Checando banimentos...", + "toast.banCheckSummary.single": "Checagem de banimentos: {count} conta com banimentos", + "toast.banCheckSummary.multiple": "Checagem de banimentos: {count} contas com banimentos", + "toast.banCheckFailed": "Falha na checagem de banimentos: {error}", + "toast.avatarRefreshComplete": "Atualização de avatares concluída para {count} contas", + "toast.banRefreshComplete": "Atualização de banimentos concluída para {count} contas", + "toast.banRefreshNoApiKey": "Atualização de banimentos ignorada: adicione uma chave da Steam Web API nas configurações", + "toast.refreshPartial": "Atualização concluída para {ok} de {count} contas", + "toast.noSteamAccountsFound": "Nenhuma conta Steam encontrada. Entre no Steam pelo menos uma vez e atualize.", + "toast.accountsRefreshed.single": "{count} conta atualizada", + "toast.accountsRefreshed.multiple": "{count} contas atualizadas", + "toast.steamFolderNotFound": "A pasta do Steam não foi encontrada. Defina manualmente em Configurações > Pasta do Steam.", + "toast.steamElevated": "O Steam está rodando como admin. Ative 'Executar o Steam como admin' em Configurações > Steam.", + "toast.noRiotProfilesFound": "Nenhum perfil Riot ainda. Clique em + para adicionar um.", + "toast.noBattleNetAccountsFound": "Nenhuma conta Battle.net encontrada. Entre na Battle.net pelo menos uma vez e atualize.", + "toast.noUbisoftAccountsFound": "Nenhuma conta Ubisoft encontrada. Entre no Ubisoft Connect pelo menos uma vez e atualize.", + "toast.deepLinkInvalid": "Link accshift:// não reconhecido", + "toast.deepLinkDisabled": "Os links accshift:// estão desativados nas configurações", + "toast.deepLinkUnknownPlatform": "Plataforma desconhecida no link: {platform}", + "toast.deepLinkPlatformDisabled": "{platform} está desativada nas configurações", + "toast.deepLinkAccountNotFound": "Nenhuma conta {platform} corresponde a \"{account}\"", + "toast.deepLinkSwitched": "Trocado para {account}", + "dialog.deepLinkSwitchTitle": "Trocar de conta?", + "dialog.deepLinkSwitchMessage": "Um link accshift:// está pedindo para trocar sua conta {platform} para \"{account}\". Continuar?", + "dialog.deepLinkSwitchConfirm": "Trocar", + "steam.launchOnline": "Trocar online", + "steam.launchInvisible": "Trocar invisível", + "steam.switchAndRunGame": "Trocar + jogar {game}", + "steam.switchAndRunPick": "Trocar + jogar um jogo…", + "steam.runGame": "Jogar {game}", + "steam.runPick": "Jogar um jogo…", + "steam.switchAndRunNoGames": "Nenhum jogo encontrado", + "steam.clearDefaultGame": "Limpar jogo padrão", + "steam.copySteamId64": "Copiar SteamID64", + "steam.copyFriendCode": "Copiar código de amigo", + "steam.copyProfileUrl": "Copiar URL do perfil", + "steam.copyLabelUsername": "Nome de usuário", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Código de amigo", + "steam.copyLabelProfileUrl": "URL do perfil", + "steam.openUserdataFolder": "Abrir a pasta userdata", + "steam.clearIntegratedBrowserCache": "Redefinir sessão do navegador", + "steam.clearIntegratedBrowserCacheConfirmTitle": "Redefinir a sessão do navegador do Steam?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "Isso fecha o Steam e limpa o cache do navegador integrado usado pela Loja e pela Comunidade.", + "steam.clearedIntegratedBrowserCache": "Sessão do navegador do Steam redefinida", + "steam.copySettingsFrom": "Copiar configurações de", + "steam.copiedSettingsToCurrent": "Configurações de {game} copiadas para a conta atual", + "steam.forget": "Esquecer", + "steam.forgetConfirmTitle": "Esquecer \"{display}\"?", + "steam.forgetConfirmMessage": "Isso vai remover esta conta da lista de contas Steam deste PC.", + "steam.forgotAccount": "{username} esquecida", + "steam.setupPendingLabel": "Conta Steam", + "steam.setupWaitingForClient": "Aguardando o Steam...", + "steam.setupWaitingForLogin": "Entre na conta Steam que você quer adicionar.", + "steam.setupDetected": "Conta detectada", + "steam.setupConnected": "Conectado", + "steam.setupReady": "Conta Steam adicionada", + "steam.setupReadyWithProfile": "{profile} adicionada", + "steam.setupFailed": "Falha na configuração do Steam", + "steam.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Steam.", + "riot.captureSession": "Capturar a sessão atual", + "riot.recaptureSession": "Recapturar sessão", + "riot.capturedSession": "Sessão capturada para {profile}", + "riot.setupPendingLabel": "Conta Riot", + "riot.setupWaitingForClient": "Aguardando o Riot Client...", + "riot.setupWaitingForLogin": "Entre na conta Riot que você quer adicionar.", + "riot.setupCapturing": "Salvando a sessão Riot...", + "riot.setupReady": "Conta Riot adicionada", + "riot.setupReadyWithProfile": "{profile} adicionada", + "riot.setupFailed": "Falha na configuração da Riot", + "riot.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Riot.", + "riot.setupStaySignedIn": "Mantenha 'Continuar conectado' ativado.", + "riot.setupDetected": "Conta detectada", + "riot.setupConnected": "Conectado", + "riot.forget": "Esquecer conta", + "riot.forgetConfirmTitle": "Esquecer \"{display}\"?", + "riot.forgetConfirmMessage": "Isso remove o perfil Riot local e a sessão salva do Accshift.", + "riot.forgotProfile": "{profile} esquecido", + "battlenet.copyUsername": "Copiar nome de usuário", + "battlenet.copyLabelUsername": "Nome de usuário", + "battlenet.copyEmail": "Copiar e-mail", + "battlenet.copyLabelEmail": "E-mail", + "battlenet.forget": "Esquecer", + "battlenet.forgetConfirmTitle": "Esquecer \"{display}\"?", + "battlenet.forgetConfirmMessage": "Isso vai remover esta conta da lista de contas Battle.net deste PC.", + "battlenet.forgotAccount": "{email} esquecida", + "battlenet.setupPendingLabel": "Conta Battle.net", + "battlenet.setupWaitingForClient": "Aguardando a Battle.net...", + "battlenet.setupWaitingForLogin": "Entre na conta Battle.net que você quer adicionar.", + "battlenet.setupKeepMeLoggedIn": "Mantenha 'Continuar conectado' ativado quando possível.", + "battlenet.setupDetected": "Conta detectada", + "battlenet.setupConnected": "Conectado", + "battlenet.setupReady": "Conta Battle.net adicionada", + "battlenet.setupReadyWithProfile": "{profile} adicionada", + "battlenet.setupFailed": "Falha na configuração da Battle.net", + "battlenet.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Battle.net.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Esquecer", + "ubisoft.forgetConfirmTitle": "Esquecer \"{display}\"?", + "ubisoft.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "ubisoft.forgotAccount": "{display} esquecida", + "ubisoft.setupPendingLabel": "Conta Ubisoft", + "ubisoft.setupWaitingForClient": "Aguardando o Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Entre na conta Ubisoft que você quer adicionar.", + "ubisoft.setupKeepMeLoggedIn": "Mantenha 'Lembrar de mim' ativado.", + "ubisoft.setupDetected": "Conta detectada", + "ubisoft.setupConnected": "Conectado", + "ubisoft.setupReady": "Conta Ubisoft adicionada", + "ubisoft.setupReadyWithProfile": "{profile} adicionada", + "ubisoft.setupFailed": "Falha na configuração da Ubisoft", + "ubisoft.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Ubisoft.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "Nenhuma conta Roblox encontrada. Adicione uma conta pelo Quick Login ou cole um cookie.", + "roblox.noAccountsHint": "Pressione + para adicionar uma conta Roblox pelo Quick Login.", + "roblox.sessionExpired": "Sessão expirada", + "roblox.sessionExpiredTooltip": "Esta conta está desconectada. Adicione de novo antes de trocar.", + "roblox.sessionExpiredSwitchError": "Sessão expirada. Adicione esta conta de novo para trocar. As notas e configurações são mantidas.", + "roblox.cookiePasteTitle": "Adicionar conta por cookie", + "roblox.cookiePasteHint": "Cole um cookie .ROBLOSECURITY do seu navegador para adicionar uma conta direto.", + "settings.showRobloxLastLogin": "Mostrar último login", + "settings.robloxHealthCheck": "Checagem de sessão", + "settings.robloxHealthCheckHint": "Sinaliza as contas cuja sessão Roblox expirou; uma requisição de rede por inicialização.", + "roblox.copyLabelUsername": "Nome de usuário", + "roblox.copyLabelUserId": "ID do usuário", + "roblox.forget": "Esquecer", + "roblox.forgetConfirmTitle": "Esquecer \"{display}\"?", + "roblox.forgetConfirmMessage": "Isso vai remover esta conta e o cookie guardado deste PC.", + "roblox.forgotAccount": "{display} esquecida", + "roblox.setupPendingLabel": "Conta Roblox", + "roblox.setupWaitingForClient": "Conectando ao Roblox...", + "roblox.setupWaitingForLogin": "Abra o app do Roblox em outro dispositivo e digite o código abaixo.", + "roblox.setupQuickLoginCode": "Código do Quick Login", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Copiar código", + "roblox.setupDetected": "Conta detectada", + "roblox.setupConnected": "Conectado", + "roblox.setupReady": "Conta Roblox adicionada", + "roblox.setupReadyWithProfile": "{profile} adicionada", + "roblox.setupFailed": "Falha na configuração do Roblox", + "roblox.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Roblox.", + "settings.epic": "Epic Games", + "settings.epicPath": "Executável do Epic Games Launcher", + "settings.showEpicLastLogin": "Mostrar último login", + "toast.noEpicAccountsFound": "Nenhuma conta Epic Games encontrada. Entre no Epic Games Launcher pelo menos uma vez e atualize.", + "epic.noAccountsHint": "Pressione + para adicionar uma conta Epic Games.", + "epic.copyLabelAccountId": "ID da conta", + "epic.forget": "Esquecer", + "epic.forgetConfirmTitle": "Esquecer \"{display}\"?", + "epic.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "epic.forgotAccount": "{display} esquecida", + "epic.setupPendingLabel": "Conta Epic Games", + "epic.setupWaitingForClient": "Aguardando o Epic Games Launcher...", + "epic.setupWaitingForLogin": "Entre na conta Epic Games que você quer adicionar.", + "epic.setupKeepMeLoggedIn": "Mantenha 'Lembrar de mim' ativado.", + "epic.setupDetected": "Conta detectada", + "epic.setupConnected": "Conectado", + "epic.setupReady": "Conta Epic Games adicionada", + "epic.setupReadyWithProfile": "{profile} adicionada", + "epic.setupFailed": "Falha na configuração da Epic Games", + "epic.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Epic Games.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "Executável do GOG Galaxy", + "settings.showGogLastLogin": "Mostrar último login", + "toast.noGogAccountsFound": "Nenhuma conta GOG Galaxy encontrada. Entre no GOG Galaxy pelo menos uma vez e atualize.", + "gog.noAccountsHint": "Pressione + para adicionar uma conta GOG Galaxy.", + "gog.copyLabelAccountId": "ID da conta", + "gog.forget": "Esquecer", + "gog.forgetConfirmTitle": "Esquecer \"{display}\"?", + "gog.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "gog.forgotAccount": "{display} esquecida", + "gog.setupPendingLabel": "Conta GOG Galaxy", + "gog.setupWaitingForClient": "Aguardando o GOG Galaxy...", + "gog.setupWaitingForLogin": "Entre na conta GOG Galaxy que você quer adicionar.", + "gog.setupKeepMeLoggedIn": "Mantenha 'Continuar conectado' ativado.", + "gog.setupDetected": "Conta detectada", + "gog.setupConnected": "Conectado", + "gog.setupReady": "Conta GOG Galaxy adicionada", + "gog.setupReadyWithProfile": "{profile} adicionada", + "gog.setupFailed": "Falha na configuração do GOG Galaxy", + "gog.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta GOG Galaxy.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Executável do Jagex Launcher", + "settings.showJagexLastLogin": "Mostrar último login", + "toast.noJagexAccountsFound": "Nenhuma conta Jagex salva ainda. Pressione + para adicionar uma pelo Jagex Launcher.", + "jagex.noAccountsHint": "Pressione + para adicionar uma conta Jagex.", + "jagex.copyLabelAccountId": "ID da conta", + "jagex.forget": "Esquecer", + "jagex.forgetConfirmTitle": "Esquecer \"{display}\"?", + "jagex.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "jagex.forgotAccount": "{display} esquecida", + "jagex.setupPendingLabel": "Conta Jagex", + "jagex.setupWaitingForClient": "Aguardando o Jagex Launcher...", + "jagex.setupWaitingForLogin": "Entre na conta Jagex que você quer adicionar.", + "jagex.setupKeepMeLoggedIn": "Mantenha 'Lembrar de mim' ativado.", + "jagex.setupDetected": "Conta detectada", + "jagex.setupConnected": "Conectado", + "jagex.setupReady": "Conta Jagex adicionada", + "jagex.setupReadyWithProfile": "{profile} adicionada", + "jagex.setupFailed": "Falha na configuração da Jagex", + "jagex.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Jagex.", + "settings.discord": "Discord", + "settings.discordPath": "Executável do Discord", + "settings.showDiscordLastLogin": "Mostrar último login", + "toast.noDiscordAccountsFound": "Nenhuma conta Discord encontrada. Adicione uma conta Discord e atualize.", + "discord.noAccountsHint": "Pressione + para adicionar uma conta Discord.", + "discord.copyLabelAccountId": "ID da conta", + "discord.forget": "Esquecer", + "discord.forgetConfirmTitle": "Esquecer \"{display}\"?", + "discord.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "discord.forgotAccount": "{display} esquecida", + "discord.setupPendingLabel": "Conta Discord", + "discord.setupWaitingForClient": "Aguardando o Discord...", + "discord.setupWaitingForLogin": "Entre na conta Discord que você quer adicionar.", + "discord.setupKeepMeLoggedIn": "Continue conectado para manter esta conta.", + "discord.setupDetected": "Conta detectada", + "discord.setupConnected": "Conectado", + "discord.setupReady": "Conta Discord adicionada", + "discord.setupReadyWithProfile": "{profile} adicionada", + "discord.setupFailed": "Falha na configuração do Discord", + "discord.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Discord.", + "color.none": "Padrão", + "color.blue": "Azul", + "color.cyan": "Ciano", + "color.green": "Verde", + "color.lime": "Lima", + "color.yellow": "Amarelo", + "color.orange": "Laranja", + "color.red": "Vermelho", + "color.pink": "Rosa", + "color.violet": "Violeta", + "color.gray": "Cinza", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Escuro", + "theme.light": "Claro", + "theme.midnight": "Meia-noite", + "theme.glassDark": "Vidro escuro", + "theme.glassLight": "Vidro claro", + "theme.liquidGlass": "Vidro líquido", + "theme.custom": "Personalizado", + "settings.themeImport": "Importar", + "settings.themeExport": "Exportar", + "settings.themeDelete": "Excluir tema", + "settings.themeImported": "Tema importado", + "settings.themeDeleted": "Tema excluído", + "settings.themeExportCopied": "JSON do tema copiado para a área de transferência", + "settings.themeInvalidJson": "JSON de tema inválido", + "settings.themeCustomize": "Personalizar", + "settings.themeEdit": "Editar", + "settings.themeCopyName": "{name} cópia", + "settings.themeSaved": "Tema salvo", + "settings.themeMigrated": "Tema importado e atualizado para o formato atual", + "settings.themeInvalidId": "O identificador do tema está faltando ou é inválido", + "settings.themeInvalidName": "O tema não tem nome", + "settings.themeUnsupportedVersion": "Este tema precisa de uma versão mais recente do accshift", + "themeGroup.surface": "Superfícies", + "themeGroup.text": "Texto", + "themeGroup.semantic": "Cores de status", + "themeGroup.shape": "Forma e densidade", + "themeGroup.typography": "Tipografia", + "themeGroup.motion": "Movimento", + "themeKind.rgbTriplet": "trio RGB", + "themeKind.hexColor": "cor hexadecimal", + "themeKind.color": "cor", + "themeKind.length": "comprimento", + "themeKind.shadow": "sombra", + "themeKind.choice": "opção", + "themeKind.fontStack": "fonte", + "themeKind.signedLength": "espaçamento", + "themeKind.gradient": "gradiente", + "themeKind.number": "número", + "themeDensity.compact": "Compacta", + "themeDensity.cozy": "Normal", + "themeDensity.comfortable": "Ampla", + "themeAvatarShape.circle": "Redondo", + "themeAvatarShape.rounded": "Arredondado", + "themeAvatarShape.square": "Quadrado", + "themeSmoothing.auto": "Suavizado", + "themeSmoothing.none": "Pixel", + "themeToken.bgRgb": "Fundo da janela", + "themeToken.bgCard": "Fundo dos cartões", + "themeToken.bgCardHover": "Fundo dos cartões ao passar o cursor", + "themeToken.bgMuted": "Fundo atenuado", + "themeToken.bgElevated": "Fundo elevado", + "themeToken.border": "Bordas", + "themeToken.fg": "Texto principal", + "themeToken.fgMuted": "Texto secundário", + "themeToken.fgSubtle": "Texto discreto", + "themeToken.afkText": "Etiqueta de ausente", + "themeToken.accent": "Destaque", + "themeToken.accentFg": "Texto sobre o destaque", + "themeToken.success": "Sucesso", + "themeToken.warning": "Aviso", + "themeToken.danger": "Perigo", + "themeToken.radiusSm": "Raio pequeno", + "themeToken.radiusMd": "Raio médio", + "themeToken.radiusLg": "Raio grande", + "themeToken.elevationLow": "Sombra leve", + "themeToken.elevationMedium": "Sombra média", + "themeToken.elevationHigh": "Sombra forte", + "themeToken.density": "Densidade", + "themeToken.fontUi": "Fonte da interface", + "themeToken.bgImage": "Gradiente da janela", + "themeToken.cardBgImage": "Gradiente das superfícies", + "themeToken.borderWidth": "Espessura das bordas", + "themeToken.borderStyle": "Estilo das bordas", + "themeToken.avatarShape": "Forma dos avatares", + "themeToken.focusRing": "Contorno de foco", + "themeToken.overlayBlur": "Desfoque do fundo", + "themeToken.fontDisplay": "Fonte dos títulos", + "themeToken.lineHeight": "Altura da linha", + "themeToken.letterSpacing": "Espaçamento entre letras", + "themeToken.labelCase": "Maiúsculas dos rótulos", + "themeToken.fontSmoothing": "Suavização do texto", + "themeToken.motionScale": "Velocidade das animações", + "themeEditor.title": "Editor de temas", + "themeEditor.name": "Nome", + "themeEditor.author": "Autor", + "themeEditor.version": "Versão", + "themeEditor.base": "Herda de", + "themeEditor.colorScheme": "Modo de cor", + "themeEditor.glass": "Superfícies translúcidas", + "themeEditor.inherited": "Herdado", + "themeEditor.builtInBase": "Tema integrado", + "themeEditor.reset": "Voltar ao valor herdado", + "themeEditor.issues": "Verificações", + "themeEditor.noIssues": "Nada a relatar", + "themeEditor.fixErrorsFirst": "Corrija os valores recusados antes de salvar", + "themeEditor.save": "Salvar", + "themeEditor.saveFailed": "Não foi possível salvar o tema. Tente de novo.", + "themeEditor.export": "Copiar", + "themeEditor.issueInvalidValue": "{token}: {kind} inválido, por exemplo {example}", + "themeEditor.issueUnknownToken": "{token} não é um valor de tema, ele é ignorado", + "themeEditor.issueMissingToken": "{token} não está definido, o valor integrado é usado", + "themeEditor.issueUnknownBase": "O tema base {base} não existe, os valores integrados são usados", + "themeEditor.issueContrast": "{token} sobre {against}: {ratio} para 1, abaixo da meta de {target} para 1", + "themeEditor.beta": "Beta", + "themeEditor.betaNote": "O formato de tema ainda muda: um tema escrito hoje pode precisar de um ajuste depois de uma atualização.", + "themeEditor.customCss": "CSS personalizado", + "themeEditor.customCssHelp": "Aplicado sobre o tema, para o que os valores acima não alcançam. Sem @import nem url(): um tema nunca baixa nada.", + "themeEditor.issueUnsafeCss": "{construct} não é permitido em um tema, o CSS não é aplicado", + "themeEditor.issueCssTooLong": "CSS muito longo: {length} caracteres, o limite é {limit}", + "bulkEdit.title": "Edição em massa", + "bulkEdit.selectAccounts": "Selecionar contas", + "bulkEdit.selectAll": "Selecionar tudo", + "bulkEdit.deselectAll": "Desmarcar tudo", + "bulkEdit.next": "Avançar", + "bulkEdit.apply": "Aplicar", + "bulkEdit.settings": "Configurações", + "bulkEdit.newsPopup": "Pop-up de notícias do jogo", + "bulkEdit.doNotDisturb": "Não perturbe", + "bulkEdit.launchOptions": "Opções de inicialização por jogo", + "bulkEdit.unchanged": "Sem alteração", + "bulkEdit.addLaunchOption": "Adicionar", + "bulkEdit.selectGame": "Selecione um jogo...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "A conta ativa no momento está selecionada. As mudanças serão aplicadas no próximo reinício do Steam.", + "bulkEdit.toastSuccess": "Edição em massa: {count} com sucesso", + "bulkEdit.toastPartial": "Edição em massa: {succeeded} com sucesso, {failed} com falha", + "bulkEdit.toastFailed": "Falha na edição em massa", + "bulkEdit.noAccountsSelected": "Nenhuma conta selecionada", + "bulkEdit.selected": "{count} selecionadas", + "bulkEdit.loadingGames": "Carregando jogos...", + "bulkEdit.copyUrls": "Copiar URLs", + "bulkEdit.urlsCopied": "{count} URL(s) de perfil copiada(s)", + "bulkEdit.cardColor": "Cor do card", + "bulkEdit.colorApplied": "Cor aplicada a {count} card(s)", + "bulkEdit.colorCleared": "Cor removida de {count} card(s)", + "common.retry": "Tentar de novo", + "app.searchNoResults": "Nenhum resultado para \"{query}\"", + "app.folderEmpty": "Esta pasta está vazia", + "app.loadErrorGeneric": "Falha ao carregar as contas. Veja o console para mais detalhes.", + "toast.dismiss": "Dispensar", + "toast.switchFailed": "Falha ao trocar de conta. Veja o console para mais detalhes.", + "toast.addAccountFailed": "Não foi possível adicionar a conta. Veja os logs do app para mais detalhes.", + "toast.addAccountClientMissing": "O {platform} não parece estar instalado. Instale-o ou defina o local dele nas configurações.", + "toast.banCheckFailedGeneric": "Falha na checagem de banimentos. Veja o console para mais detalhes.", + "toast.renameAccountFailed": "Falha ao renomear: {error}", + "toast.copyFailed": "Não foi possível copiar para a área de transferência", + "toast.refreshFailed": "Falha ao atualizar. Veja o console para mais detalhes.", + "dialog.deleteFolderTitle": "Excluir \"{name}\"?", + "dialog.deleteFolderMessage": "As subpastas também são excluídas. As contas de dentro sobem um nível, não são removidas.", + "personas.deleteConfirm": "Clique de novo para excluir", + "personas.discardConfirm": "Clique de novo para descartar", + "personas.switchConfirmTitle": "Trocar de persona", + "personas.switchConfirmMessage": "Trocar {count} plataforma(s) para \"{name}\"? Cada cliente vai fechar e reabrir.", + "personas.switchConfirmAction": "Trocar", + "personas.deleteTitle": "Excluir persona", + "personas.deleteMessage": "Excluir \"{name}\"? As contas em si não são afetadas.", + "context.submenuError": "Não foi possível carregar este menu", + "settings.apiKeySaveFailed": "Não foi possível salvar a chave da API Steam", + "settings.apiKeyInvalidHint": "A chave não foi salva. Verifique e tente de novo.", + "settings.apiKeyCleared": "Chave da API Steam removida", + "settings.apiKeyClearFailed": "Não foi possível remover a chave da API Steam", + "settings.clearApiKey": "Limpar chave", + "settings.cs2Bridge": "Bridge de estatísticas do CS2", + "settings.cs2BridgeLink": "Link da bridge", + "settings.cs2BridgeLinkPlaceholder": "https://seu-servidor/api/bridge/accshift/", + "settings.cs2BridgeTest": "Testar", + "settings.cs2BridgeStatusOk": "Conectado · {count} contas · {ms} ms", + "settings.cs2BridgeStatusFail": "Falha na conexão", + "settings.cs2BridgeStatusTesting": "Testando...", + "settings.cs2BridgeStatusUnknown": "Não testado", + "settings.cs2BridgeToken": "Token da API", + "settings.cs2BridgeTokenOptional": "opcional, para servidores personalizados", + "settings.cs2BridgeTokenPlaceholder": "Token Bearer, se o seu servidor precisar", + "settings.cs2BridgeSaveFailed": "Não foi possível salvar as configurações da bridge do CS2", + "settings.cs2BridgeClearToken": "Limpar token", + "settings.pathSaveFailed": "Não foi possível salvar o caminho do {platform}", + "settings.pathInvalidHint": "Não foi possível salvar este caminho. Verifique e tente de novo.", + "settings.pathSteamNeverSignedIn": "Steam encontrado, entre uma vez para que ele crie o histórico de login", + "settings.pathNotSteamFolder": "Esta pasta não parece uma instalação do Steam", + "settings.pathNotADirectory": "O caminho precisa ser uma pasta existente", + "settings.pinSaved": "PIN salvo", + "settings.pinTakesEffectNextLaunch": "Tem efeito na próxima vez que o app iniciar", + "settings.pinRequiredAfterInactivity": "Exigido após inatividade e quando o app inicia", + "settings.pinScreenLockOnly": "O PIN bloqueia esta janela e as trocas de conta. Ele não protege nada no disco.", + "settings.unitSeconds": "segundos", + "settings.unitDays": "dias", + "settings.telemetryLoadFailed": "Não foi possível carregar as configurações de telemetria", + "palette.placeholder": "Digite um comando ou nome de conta...", + "palette.noResults": "Nenhum comando correspondente", + "palette.sectionAccounts": "Contas", + "palette.sectionActions": "Ações", + "palette.sectionNavigation": "Navegação", + "palette.openTab": "Ir para {platform}", + "palette.openFolder": "Abrir a pasta \"{name}\"", + "palette.backToParent": "Voltar para a pasta anterior", + "palette.zoomReset": "Redefinir zoom" +} diff --git a/crates/accshift-app/i18n/pt.json b/crates/accshift-app/i18n/pt.json new file mode 100644 index 00000000..a0eaf92c --- /dev/null +++ b/crates/accshift-app/i18n/pt.json @@ -0,0 +1,753 @@ +{ + "app.loadingSettings": "A carregar definições...", + "app.loading": "A carregar...", + "app.searchPlaceholder": "Procurar conta...", + "app.noAccountsFound": "Nenhuma conta {platform} encontrada", + "app.noAccountsHint": "Confirma que {platform} está instalado e que já iniciaste sessão pelo menos uma vez.", + "app.switchingAccount": "A mudar de conta...", + "app.platformRestarting": "{platform} está a reiniciar", + "app.comingSoon": "{platform} - Brevemente", + "app.platformUnsupportedOs": "{platform} ainda não está disponível neste SO", + "app.noCompatiblePlatforms": "Nenhuma plataforma compatível está disponível neste SO", + "app.noCompatiblePlatformsHint": "Vê as atualizações de suporte para Windows ou usa uma máquina compatível.", + "update.ctaAvailable": "Atualização disponível", + "update.ctaInstalling": "A instalar...", + "update.restartToApplyVersion": "Reiniciar para aplicar a atualização {version}", + "update.restartToApply": "Reiniciar para aplicar a atualização", + "update.readyToastVersion": "Atualização {version} pronta", + "update.readyToast": "Atualização pronta", + "update.restartFailed": "Não foi possível reiniciar para aplicar a atualização", + "pin.lockedTitle": "Aplicação bloqueada", + "pin.lockedPrompt": "Introduz o PIN para desbloquear", + "pin.placeholder": "PIN de 4 dígitos", + "pin.unlock": "Desbloquear", + "pin.invalid": "PIN inválido", + "pin.tooManyAttempts": "Demasiadas tentativas. Tente novamente dentro de {seconds} s.", + "common.ok": "OK", + "common.cancel": "Cancelar", + "common.confirm": "Confirmar", + "common.close": "Fechar", + "common.back": "Voltar", + "common.active": "Ativa", + "common.choose": "Escolher...", + "common.none": "Nenhuma", + "common.custom": "Personalizado", + "common.configured": "Configurado", + "common.missing": "Em falta", + "common.enabled": "Ativado", + "common.disabled": "Desativado", + "common.on": "Ligado", + "common.off": "Desligado", + "common.add": "Adicionar", + "common.visible": "Visível", + "common.hidden": "Oculto", + "common.inline": "No cartão", + "common.tooltip": "Dica", + "titlebar.refresh": "Atualizar", + "titlebar.addAccount": "Adicionar conta", + "titlebar.settings": "Definições", + "titlebar.personas": "Personas", + "titlebar.minimize": "Minimizar", + "titlebar.maximize": "Maximizar", + "titlebar.restore": "Restaurar", + "titlebar.close": "Fechar", + "view.grid": "Vista de grelha", + "view.list": "Vista de lista", + "settings.title": "Definições", + "settings.general": "Geral", + "settings.appearance": "Aspeto", + "settings.language": "Idioma", + "settings.theme": "Tema", + "settings.uiZoom": "Zoom da interface", + "settings.backgroundOpacity": "Opacidade do fundo", + "settings.animations": "Animações", + "settings.animationsSystem": "Seguir o sistema", + "settings.animationsOn": "Sempre ativas", + "settings.animationsOff": "Desligadas", + "settings.performance": "Desempenho", + "settings.suspendGraphicsWhenMinimized": "Suspender gráficos quando minimizado", + "settings.minimizeOnAccountSwitch": "Minimizar ao mudar de conta", + "settings.system": "Sistema", + "settings.startWithSystem": "Iniciar no arranque", + "settings.startWithSystemHint": "Inicia o Accshift com o Windows, minimizado na área de notificação.", + "settings.closeToTray": "Fechar para a área de notificação", + "settings.closeToTrayHint": "O botão de fechar oculta o Accshift na área de notificação em vez de sair.", + "settings.autostartFailed": "Não foi possível alterar o arranque automático.", + "tray.open": "Abrir o Accshift", + "tray.quit": "Sair", + "settings.platforms": "Plataformas", + "settings.platformSearchPlaceholder": "Procurar plataformas...", + "settings.platformSearchNoResults": "Nenhuma plataforma corresponde à pesquisa.", + "settings.accountDisplay": "Apresentação das contas", + "settings.startupAndExtras": "Arranque", + "settings.defaultOnStartup": "Plataforma predefinida no arranque", + "settings.platformDisabledSuffix": "(desativada)", + "settings.platformUnsupportedOs": "SO não suportado", + "settings.platformNotImplemented": "Planeado", + "settings.platformUserProvided": "Adicionada a partir da sua pasta de descritores", + "settings.customPlatforms": "Plataformas personalizadas", + "settings.customPlatformsHint": "Os ficheiros de descritor desta pasta são lidos ao iniciar a aplicação.", + "settings.customPlatformsNone": "Nenhum descritor carregado a partir desta pasta.", + "settings.customPlatformsProblems": "Ficheiros que não carregaram", + "descriptor.addFromFile": "Adicionar a partir de um ficheiro", + "descriptor.reload": "Recarregar", + "descriptor.openFolder": "Abrir pasta", + "descriptor.remove": "Remover", + "descriptor.removeConfirmTitle": "Remover {platform}?", + "descriptor.removeConfirmMessage": "O ficheiro de descritor sai da sua pasta. As contas já capturadas ficam no disco.", + "descriptor.previewTitle": "Adicionar uma plataforma", + "descriptor.previewOs": "Descrita para {list}", + "descriptor.previewRoots": "Pastas em que pode tocar", + "descriptor.previewSteps": "O que uma troca de conta faria", + "descriptor.previewWarnings": "Avisos", + "descriptor.previewNoPlan": "Não foi possível criar uma simulação: {problem}", + "descriptor.previewReplaces": "Isto substitui o descritor que já está na sua pasta.", + "descriptor.previewNothingWritten": "Nada é escrito até que o adicione.", + "descriptor.replace": "Substituir", + "descriptor.installed": "{platform} adicionada", + "descriptor.removed": "{platform} removida", + "descriptor.actionRead": "ler", + "descriptor.actionCapture": "capturar", + "descriptor.actionRestore": "restaurar", + "descriptor.actionDelete": "remover", + "descriptor.actionClose": "fechar", + "descriptor.actionLaunch": "iniciar", + "settings.dataRefresh": "Atualização de dados", + "settings.avatarRefresh": "Atualização de avatares", + "settings.banCheckDelay": "Intervalo da verificação de bans", + "settings.refreshNow": "Atualizar agora", + "settings.zeroEachLaunch": "0 = a cada arranque", + "settings.privacy": "Privacidade", + "settings.inactivityTimeout": "Tempo de inatividade", + "settings.zeroDisabled": "0 = desativado", + "settings.streamerMode": "Modo streamer", + "settings.streamerModeHint": "Desfoca automaticamente os dados das contas quando há software de streaming a correr.", + "settings.personasHint": "Mostrar o seletor de identidades personas", + "settings.replayOnboarding": "Repetir a introdução", + "settings.replayOnboardingHint": "Mostra outra vez a visita guiada e a escolha das estatísticas de utilização.", + "streamer.title": "Modo streamer", + "streamer.disable": "Desativar", + "streamer.disablePermanently": "Desativar permanentemente", + "personas.title": "Personas", + "personas.create": "Nova persona", + "personas.activate": "Mudar para esta persona", + "personas.edit": "Editar", + "personas.delete": "Eliminar", + "personas.finish": "Concluir", + "personas.switching": "A mudar...", + "personas.switched": "Mudou para {name}", + "personas.switchPartial": "{name}: falhou em {failed}", + "personas.switchFailed": "Não foi possível mudar para {name}", + "personas.namePlaceholder": "Nome da persona", + "personas.choosePlatforms": "Escolhe uma conta para cada plataforma a incluir", + "personas.chooseAccount": "Escolhe a conta {platform}", + "personas.removeFromPersona": "Remover desta persona", + "personas.customImage": "Escolher uma imagem...", + "personas.removeImage": "Remover imagem", + "personas.imageError": "Não foi possível carregar essa imagem.", + "personas.loadingAccounts": "A carregar contas...", + "personas.noAccountsOnPlatform": "Ainda não há contas nesta plataforma", + "personas.emptyHint": "Um clique muda todas as plataformas incluídas para a respetiva conta.", + "settings.security": "Segurança", + "settings.pinLockOnAfk": "Bloqueio por PIN em inatividade", + "settings.pinCode": "Código PIN", + "settings.pinPlaceholder": "4 dígitos", + "settings.pinCurrentCode": "PIN atual", + "settings.pinCurrentHint": "Introduza o PIN atual para alterar ou desativar o bloqueio.", + "settings.telemetry": "Estatísticas de utilização (anónimas)", + "settings.telemetryModeA": "Estatísticas anónimas básicas", + "settings.telemetryModeAHint": "Conta os arranques e as funcionalidades utilizadas. Um ID local aleatório evita contagens duplicadas de instalações; não são enviados dados pessoais.", + "settings.telemetryModeB": "Estatísticas avançadas com UUID local (opcional)", + "settings.telemetryModeBHint": "Adiciona um UUID aleatório guardado apenas nesta máquina. Permite medir a retenção e saber que funcionalidades importam. Continuam a não ser enviados dados pessoais.", + "settings.telemetryExport": "Exportar os meus dados", + "settings.telemetryExported": "Dados copiados para a área de transferência", + "settings.telemetryExportFailed": "A exportação falhou", + "settings.telemetryEnableFailed": "Não foi possível ativar as estatísticas avançadas", + "settings.telemetryDisableFailed": "Não foi possível desativar as estatísticas avançadas", + "settings.telemetryDeletePending": "As estatísticas avançadas estão desligadas; a eliminação no servidor precisa de outra tentativa", + "settings.telemetryDeletePendingHint": "A eliminação no servidor está pendente. O ID local antigo é mantido apenas para poderes repetir a eliminação ou exportar os dados associados.", + "settings.telemetryRetryDelete": "Repetir a eliminação dos dados", + "settings.telemetryDeleteCompleted": "Dados do servidor eliminados", + "settings.telemetryDeleteRetryFailed": "A eliminação no servidor continua pendente", + "settings.telemetryLearnMore": "Que dados são enviados?", + "onboarding.welcome.title": "Bem-vindo ao Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "O teu sistema operativo é compatível com:", + "onboarding.welcome.detectedPlatforms": "Estas são as plataformas detetadas no teu computador:", + "onboarding.welcome.next": "Mostra-me a aplicação", + "onboarding.welcome.skip": "Ignorar", + "onboarding.features.hint": "Esta parte da aplicação não está visível neste momento.", + "onboarding.features.addAccount.label": "Adicionar uma conta", + "onboarding.features.addAccount.body": "O botão + no canto superior direito adiciona uma conta à plataforma atual. Inicias sessão uma vez, o Accshift guarda-a.", + "onboarding.features.switch.label": "Mudar de conta", + "onboarding.features.switch.body": "Clica num cartão e ficas nessa conta. É basicamente toda a aplicação, sinceramente.", + "onboarding.features.contextMenu.label": "Clique direito para as ações", + "onboarding.features.contextMenu.body": "Clica com o botão direito num cartão para o renomear, mudar a cor, movê-lo para uma pasta ou eliminá-lo.", + "onboarding.features.platformSwitch.label": "Mudar de plataforma", + "onboarding.features.platformSwitch.body": "O mesmo para Riot, Battle.net, Epic e as restantes. Os separadores no topo mudam de plataforma.", + "onboarding.features.next": "Seguinte", + "onboarding.features.back": "Voltar", + "onboarding.features.step": "Passo {step}", + "onboarding.features.mockRename": "Renomear", + "onboarding.features.mockSetColor": "Definir cor", + "onboarding.features.mockMoveToFolder": "Mover para pasta", + "onboarding.features.mockDelete": "Eliminar", + "onboarding.features.mockAccount": "Conta {number}", + "onboarding.telemetry.title": "VENDE A TUA ALMA AO ACCSHIFT!", + "onboarding.telemetry.intro": "O Accshift conta algumas estatísticas de utilização anónimas para eu saber o que corrigir primeiro. Nada de pessoal sai da tua máquina, a aplicação funciona na mesma, e podes mudar de ideias quando quiseres nas definições.", + "onboarding.telemetry.question": "Negócio fechado?", + "onboarding.telemetry.gifAlt": "Meme Trade Offer", + "onboarding.telemetry.basic": "NÃO! Já chega", + "onboarding.telemetry.basicDefault": "(predefinido)", + "onboarding.telemetry.basicHint": "Apenas contadores anónimos e nada mais: sem perfil, sem identificador que te siga.", + "onboarding.telemetry.deal": "NEGÓCIO FECHADO", + "onboarding.telemetry.dealHint": "Adiciona um UUID separado para as estatísticas avançadas, guardado apenas na tua máquina. Continuo sem saber quem és, mas ajudas imenso. Obrigado!", + "onboarding.telemetry.optOutNote": "Escolhas o que escolheres, podes mudar depois em Definições -> Privacidade. :c", + "onboarding.telemetry.refuse": "Não obrigado, desliga tudo", + "onboarding.telemetry.refuseHint": "Nada é medido nem enviado, nem sequer os contadores anónimos.", + "onboarding.telemetry.back": "Voltar", + "onboarding.persistFailed": "Não foi possível guardar essa escolha. Tenta de novo.", + "onboarding.persistLater": "Perguntar no próximo arranque", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Executar o Steam como administrador", + "settings.showUsernames": "Mostrar nomes de utilizador", + "settings.showSteamLastLogin": "Mostrar última sessão Steam", + "settings.showRiotLastLogin": "Mostrar última sessão Riot", + "settings.showBattleNetLastLogin": "Mostrar última sessão Battle.net", + "settings.showUbisoftLastLogin": "Mostrar última sessão Ubisoft", + "settings.showNotesUnderCards": "Mostrar notas sob os cartões", + "settings.expandedFolders": "Expandir todas as pastas", + "list.rootSection": "Raiz", + "settings.steamLaunch": "Mudança", + "settings.steamInstallation": "Instalação", + "settings.steamApiAndData": "API e dados", + "settings.launchOptions": "Opções de arranque", + "settings.shutdownMode": "Modo de encerramento", + "settings.shutdownModeGraceful": "Suave", + "settings.shutdownModeForce": "Forçar fecho", + "settings.autoDetected": "Detetado automaticamente", + "settings.steamFolder": "Pasta do Steam", + "settings.steamFolderRegistry": "Registo", + "settings.riotClientPath": "Executável do Riot Client", + "settings.battleNetPath": "Executável do Battle.net", + "settings.ubisoftPath": "Pasta do Ubisoft Connect", + "settings.steamWebApiKey": "Chave da Steam Web API", + "settings.getKey": "Obter chave", + "settings.pasteApiKey": "Cola a tua chave da Steam Web API", + "settings.saved": "Definições guardadas", + "settings.openApiKeyFailed": "Não foi possível abrir a página da chave da API Steam", + "settings.integrations": "Integrações", + "settings.deepLinks": "Permitir ligações accshift://", + "settings.deepLinksHint": "Deixa outras aplicações mudarem de conta por ti: favoritos, Stream Deck, scripts.", + "settings.cliEnabled": "Permitir a CLI do accshift", + "settings.cliEnabledHint": "Deixa a ferramenta de linha de comandos listar e mudar de conta. Desativado, todos os comandos accshift recusam-se a correr, diag incluído; a aplicação em si não é afetada.", + "settings.help": "Abrir a wiki do accshift", + "settings.helpShort": "Ajuda", + "settings.openHelpFailed": "Não foi possível abrir a wiki", + "settings.logs": "Registos", + "settings.openLogs": "Abrir a pasta dos registos", + "settings.openLogsFailed": "Não foi possível abrir a pasta dos registos", + "context.loading": "A carregar...", + "context.noGamesFound": "Nenhum jogo encontrado", + "context.field.cut": "Cortar", + "context.field.copy": "Copiar", + "context.field.paste": "Colar", + "context.field.selectAll": "Selecionar tudo", + "context.menu.newFolder": "Nova pasta", + "context.menu.refresh": "Atualizar", + "context.menu.rename": "Renomear", + "context.menu.copy": "Copiar...", + "context.menu.deleteFolder": "Eliminar pasta", + "context.menu.folderColor": "Cor da pasta", + "context.menu.moveToFolder": "Mover para uma pasta", + "context.menu.moveToRoot": "Sem pasta", + "context.menu.editCardAndColor": "Editar cartão e cor", + "context.menu.cardColor": "Cor do cartão", + "context.menu.addNote": "Adicionar nota", + "context.menu.editNote": "Editar nota", + "platform.rename": "Renomear", + "platform.renameTitle": "Renomear conta", + "platform.renamePlaceholder": "Nome da conta", + "platform.newAccountPending": "A aguardar...", + "platform.setupBusy": "Está a decorrer outra operação do accshift. A aguardar que termine.", + "platform.renameNewAccount": "Dá um nome a esta conta", + "platform.copyLabelAccountId": "ID da conta", + "platform.forget": "Esquecer", + "platform.forgetConfirmTitle": "Esquecer «{display}»?", + "platform.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "platform.forgotAccount": "{display} esquecida", + "dialog.newFolderTitle": "Nova pasta", + "dialog.renameFolderTitle": "Renomear pasta", + "dialog.folderNamePlaceholder": "Nome da pasta", + "dialog.cardNoteTitle": "Nota do cartão", + "dialog.cardNotePlaceholder": "Escreve uma nota curta", + "card.noteAttached": "Nota anexada", + "card.clickAgainToSwitch": "Clique novamente para trocar", + "card.extensionWarnings": "Avisos", + "card.extensionNote": "Nota", + "card.cs2Section": "CS2", + "card.cs2Level": "Nível {level}", + "card.cs2CaseEarned": "Caixa semanal obtida", + "card.cs2CaseNotEarned": "Caixa semanal pendente", + "list.selectAccountPreview": "Seleciona uma conta para pré-visualizar", + "preview.noteLabel": "Nota", + "preview.currentlyActive": "Ativa neste momento", + "preview.switchAgain": "Mudar outra vez", + "preview.switchAccount": "Mudar de conta", + "ban.community": "Ban da comunidade", + "ban.vac.single": "{count} ban VAC", + "ban.vac.multiple": "{count} bans VAC", + "ban.game.single": "{count} ban de jogo", + "ban.game.multiple": "{count} bans de jogo", + "time.unknown": "desconhecido", + "time.justNow": "agora mesmo", + "time.minuteAgo": "há {count} minuto", + "time.minutesAgo": "há {count} minutos", + "time.hourAgo": "há {count} hora", + "time.hoursAgo": "há {count} horas", + "time.dayAgo": "há {count} dia", + "time.daysAgo": "há {count} dias", + "time.neverConnected": "nunca iniciou sessão", + "time.compactWrapper": "({value})", + "toast.copied": "{label} copiado", + "toast.legacyConfigMigrated": "Definições migradas para o novo formato", + "toast.legacyConfigMigrationFailed": "Falha ao migrar as definições antigas, consulta os registos", + "toast.failedLoadSettingsPanel": "Falha ao carregar o painel de definições", + "toast.banChecking": "A verificar bans...", + "toast.banCheckSummary.single": "Verificação de bans: {count} conta com bans", + "toast.banCheckSummary.multiple": "Verificação de bans: {count} contas com bans", + "toast.banCheckFailed": "A verificação de bans falhou: {error}", + "toast.avatarRefreshComplete": "Atualização de avatares concluída para {count} contas", + "toast.banRefreshComplete": "Atualização de bans concluída para {count} contas", + "toast.banRefreshNoApiKey": "Atualização de bans ignorada: adiciona uma chave da Steam Web API nas definições", + "toast.refreshPartial": "Atualização concluída para {ok} de {count} contas", + "toast.noSteamAccountsFound": "Nenhuma conta Steam encontrada. Inicia sessão no Steam pelo menos uma vez e atualiza.", + "toast.accountsRefreshed.single": "{count} conta atualizada", + "toast.accountsRefreshed.multiple": "{count} contas atualizadas", + "toast.steamFolderNotFound": "A pasta do Steam não foi encontrada. Define-a manualmente em Definições > Pasta do Steam.", + "toast.steamElevated": "O Steam está a correr como administrador. Ativa 'Executar o Steam como administrador' em Definições > Steam.", + "toast.noRiotProfilesFound": "Ainda não há perfis Riot. Clica em + para adicionar um.", + "toast.noBattleNetAccountsFound": "Nenhuma conta Battle.net encontrada. Inicia sessão no Battle.net pelo menos uma vez e atualiza.", + "toast.noUbisoftAccountsFound": "Nenhuma conta Ubisoft encontrada. Inicia sessão no Ubisoft Connect pelo menos uma vez e atualiza.", + "toast.deepLinkInvalid": "Ligação accshift:// não reconhecida", + "toast.deepLinkDisabled": "As ligações accshift:// estão desativadas nas definições", + "toast.deepLinkUnknownPlatform": "Plataforma desconhecida na ligação: {platform}", + "toast.deepLinkPlatformDisabled": "{platform} está desativada nas definições", + "toast.deepLinkAccountNotFound": "Nenhuma conta {platform} corresponde a «{account}»", + "toast.deepLinkSwitched": "Mudou para {account}", + "dialog.deepLinkSwitchTitle": "Mudar de conta?", + "dialog.deepLinkSwitchMessage": "Uma ligação accshift:// está a pedir para mudar a tua conta {platform} para «{account}». Continuar?", + "dialog.deepLinkSwitchConfirm": "Mudar", + "steam.launchOnline": "Mudar como online", + "steam.launchInvisible": "Mudar como invisível", + "steam.switchAndRunGame": "Mudar + iniciar {game}", + "steam.switchAndRunPick": "Mudar + iniciar um jogo...", + "steam.runGame": "Iniciar {game}", + "steam.runPick": "Iniciar um jogo...", + "steam.switchAndRunNoGames": "Nenhum jogo encontrado", + "steam.clearDefaultGame": "Limpar jogo predefinido", + "steam.copySteamId64": "Copiar SteamID64", + "steam.copyFriendCode": "Copiar código de amigo", + "steam.copyProfileUrl": "Copiar URL do perfil", + "steam.copyLabelUsername": "Nome de utilizador", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Código de amigo", + "steam.copyLabelProfileUrl": "URL do perfil", + "steam.openUserdataFolder": "Abrir a pasta userdata", + "steam.clearIntegratedBrowserCache": "Repor a sessão do navegador", + "steam.clearIntegratedBrowserCacheConfirmTitle": "Repor a sessão do navegador do Steam?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "Isto fecha o Steam e limpa a cache do navegador integrado usado pela Loja e pela Comunidade.", + "steam.clearedIntegratedBrowserCache": "Sessão do navegador do Steam reposta", + "steam.copySettingsFrom": "Copiar definições de", + "steam.copiedSettingsToCurrent": "Definições de {game} copiadas para a conta atual", + "steam.forget": "Esquecer", + "steam.forgetConfirmTitle": "Esquecer «{display}»?", + "steam.forgetConfirmMessage": "Isto remove esta conta da tua lista de contas Steam neste PC.", + "steam.forgotAccount": "{username} esquecida", + "steam.setupPendingLabel": "Conta Steam", + "steam.setupWaitingForClient": "À espera do Steam...", + "steam.setupWaitingForLogin": "Inicia sessão na conta Steam que queres adicionar.", + "steam.setupDetected": "Conta detetada", + "steam.setupConnected": "Ligado", + "steam.setupReady": "Conta Steam adicionada", + "steam.setupReadyWithProfile": "{profile} adicionada", + "steam.setupFailed": "A configuração do Steam falhou", + "steam.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Steam.", + "riot.captureSession": "Capturar a sessão atual", + "riot.recaptureSession": "Voltar a capturar a sessão", + "riot.capturedSession": "Sessão capturada para {profile}", + "riot.setupPendingLabel": "Conta Riot", + "riot.setupWaitingForClient": "À espera do Riot Client...", + "riot.setupWaitingForLogin": "Inicia sessão na conta Riot que queres adicionar.", + "riot.setupCapturing": "A guardar a sessão Riot...", + "riot.setupReady": "Conta Riot adicionada", + "riot.setupReadyWithProfile": "{profile} adicionada", + "riot.setupFailed": "A configuração do Riot falhou", + "riot.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Riot.", + "riot.setupStaySignedIn": "Mantém 'Manter sessão iniciada' ativado.", + "riot.setupDetected": "Conta detetada", + "riot.setupConnected": "Ligado", + "riot.forget": "Esquecer conta", + "riot.forgetConfirmTitle": "Esquecer «{display}»?", + "riot.forgetConfirmMessage": "Isto remove o perfil Riot local e a sua sessão guardada do Accshift.", + "riot.forgotProfile": "{profile} esquecido", + "battlenet.copyUsername": "Copiar nome de utilizador", + "battlenet.copyLabelUsername": "Nome de utilizador", + "battlenet.copyEmail": "Copiar email", + "battlenet.copyLabelEmail": "Email", + "battlenet.forget": "Esquecer", + "battlenet.forgetConfirmTitle": "Esquecer «{display}»?", + "battlenet.forgetConfirmMessage": "Isto remove esta conta da lista de contas Battle.net neste PC.", + "battlenet.forgotAccount": "{email} esquecida", + "battlenet.setupPendingLabel": "Conta Battle.net", + "battlenet.setupWaitingForClient": "À espera do Battle.net...", + "battlenet.setupWaitingForLogin": "Inicia sessão na conta Battle.net que queres adicionar.", + "battlenet.setupKeepMeLoggedIn": "Mantém 'Manter sessão iniciada' ativado, se possível.", + "battlenet.setupDetected": "Conta detetada", + "battlenet.setupConnected": "Ligado", + "battlenet.setupReady": "Conta Battle.net adicionada", + "battlenet.setupReadyWithProfile": "{profile} adicionada", + "battlenet.setupFailed": "A configuração do Battle.net falhou", + "battlenet.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Battle.net.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Esquecer", + "ubisoft.forgetConfirmTitle": "Esquecer «{display}»?", + "ubisoft.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "ubisoft.forgotAccount": "{display} esquecida", + "ubisoft.setupPendingLabel": "Conta Ubisoft", + "ubisoft.setupWaitingForClient": "À espera do Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Inicia sessão na conta Ubisoft que queres adicionar.", + "ubisoft.setupKeepMeLoggedIn": "Mantém 'Lembrar-me' ativado.", + "ubisoft.setupDetected": "Conta detetada", + "ubisoft.setupConnected": "Ligado", + "ubisoft.setupReady": "Conta Ubisoft adicionada", + "ubisoft.setupReadyWithProfile": "{profile} adicionada", + "ubisoft.setupFailed": "A configuração do Ubisoft falhou", + "ubisoft.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Ubisoft.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "Nenhuma conta Roblox encontrada. Adiciona uma conta com o Quick Login ou cola um cookie.", + "roblox.noAccountsHint": "Carrega em + para adicionar uma conta Roblox via Quick Login.", + "roblox.sessionExpired": "Sessão expirada", + "roblox.sessionExpiredTooltip": "Esta conta tem a sessão terminada. Volta a adicioná-la antes de mudar.", + "roblox.sessionExpiredSwitchError": "Sessão expirada. Volta a adicionar esta conta para mudar. As notas e definições são mantidas.", + "roblox.cookiePasteTitle": "Adicionar conta por cookie", + "roblox.cookiePasteHint": "Cola um cookie .ROBLOSECURITY do teu navegador para adicionar uma conta diretamente.", + "settings.showRobloxLastLogin": "Mostrar última sessão", + "settings.robloxHealthCheck": "Verificação de sessão", + "settings.robloxHealthCheckHint": "Assinala as contas cuja sessão Roblox expirou; um pedido de rede por arranque.", + "roblox.copyLabelUsername": "Nome de utilizador", + "roblox.copyLabelUserId": "ID de utilizador", + "roblox.forget": "Esquecer", + "roblox.forgetConfirmTitle": "Esquecer «{display}»?", + "roblox.forgetConfirmMessage": "Isto remove esta conta e o cookie guardado deste PC.", + "roblox.forgotAccount": "{display} esquecida", + "roblox.setupPendingLabel": "Conta Roblox", + "roblox.setupWaitingForClient": "A ligar ao Roblox...", + "roblox.setupWaitingForLogin": "Abre a aplicação Roblox noutro dispositivo e introduz o código abaixo.", + "roblox.setupQuickLoginCode": "Código Quick Login", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Copiar código", + "roblox.setupDetected": "Conta detetada", + "roblox.setupConnected": "Ligado", + "roblox.setupReady": "Conta Roblox adicionada", + "roblox.setupReadyWithProfile": "{profile} adicionada", + "roblox.setupFailed": "A configuração do Roblox falhou", + "roblox.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Roblox.", + "settings.epic": "Epic Games", + "settings.epicPath": "Executável do Epic Games Launcher", + "settings.showEpicLastLogin": "Mostrar última sessão", + "toast.noEpicAccountsFound": "Nenhuma conta Epic Games encontrada. Inicia sessão no Epic Games Launcher pelo menos uma vez e atualiza.", + "epic.noAccountsHint": "Carrega em + para adicionar uma conta Epic Games.", + "epic.copyLabelAccountId": "ID da conta", + "epic.forget": "Esquecer", + "epic.forgetConfirmTitle": "Esquecer «{display}»?", + "epic.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "epic.forgotAccount": "{display} esquecida", + "epic.setupPendingLabel": "Conta Epic Games", + "epic.setupWaitingForClient": "À espera do Epic Games Launcher...", + "epic.setupWaitingForLogin": "Inicia sessão na conta Epic Games que queres adicionar.", + "epic.setupKeepMeLoggedIn": "Mantém 'Lembrar-me' ativado.", + "epic.setupDetected": "Conta detetada", + "epic.setupConnected": "Ligado", + "epic.setupReady": "Conta Epic Games adicionada", + "epic.setupReadyWithProfile": "{profile} adicionada", + "epic.setupFailed": "A configuração do Epic Games falhou", + "epic.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Epic Games.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "Executável do GOG Galaxy", + "settings.showGogLastLogin": "Mostrar última sessão", + "toast.noGogAccountsFound": "Nenhuma conta GOG Galaxy encontrada. Inicia sessão no GOG Galaxy pelo menos uma vez e atualiza.", + "gog.noAccountsHint": "Carrega em + para adicionar uma conta GOG Galaxy.", + "gog.copyLabelAccountId": "ID da conta", + "gog.forget": "Esquecer", + "gog.forgetConfirmTitle": "Esquecer «{display}»?", + "gog.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "gog.forgotAccount": "{display} esquecida", + "gog.setupPendingLabel": "Conta GOG Galaxy", + "gog.setupWaitingForClient": "À espera do GOG Galaxy...", + "gog.setupWaitingForLogin": "Inicia sessão na conta GOG Galaxy que queres adicionar.", + "gog.setupKeepMeLoggedIn": "Mantém 'Manter sessão iniciada' ativado.", + "gog.setupDetected": "Conta detetada", + "gog.setupConnected": "Ligado", + "gog.setupReady": "Conta GOG Galaxy adicionada", + "gog.setupReadyWithProfile": "{profile} adicionada", + "gog.setupFailed": "A configuração do GOG Galaxy falhou", + "gog.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta GOG Galaxy.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Executável do Jagex Launcher", + "settings.showJagexLastLogin": "Mostrar última sessão", + "toast.noJagexAccountsFound": "Ainda não há contas Jagex guardadas. Carrega em + para adicionar uma através do Jagex Launcher.", + "jagex.noAccountsHint": "Carrega em + para adicionar uma conta Jagex.", + "jagex.copyLabelAccountId": "ID da conta", + "jagex.forget": "Esquecer", + "jagex.forgetConfirmTitle": "Esquecer «{display}»?", + "jagex.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "jagex.forgotAccount": "{display} esquecida", + "jagex.setupPendingLabel": "Conta Jagex", + "jagex.setupWaitingForClient": "À espera do Jagex Launcher...", + "jagex.setupWaitingForLogin": "Inicia sessão na conta Jagex que queres adicionar.", + "jagex.setupKeepMeLoggedIn": "Mantém 'Lembrar-me' ativado.", + "jagex.setupDetected": "Conta detetada", + "jagex.setupConnected": "Ligado", + "jagex.setupReady": "Conta Jagex adicionada", + "jagex.setupReadyWithProfile": "{profile} adicionada", + "jagex.setupFailed": "A configuração do Jagex falhou", + "jagex.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Jagex.", + "settings.discord": "Discord", + "settings.discordPath": "Executável do Discord", + "settings.showDiscordLastLogin": "Mostrar última sessão", + "toast.noDiscordAccountsFound": "Nenhuma conta Discord encontrada. Adiciona uma conta Discord e atualiza.", + "discord.noAccountsHint": "Carrega em + para adicionar uma conta Discord.", + "discord.copyLabelAccountId": "ID da conta", + "discord.forget": "Esquecer", + "discord.forgetConfirmTitle": "Esquecer «{display}»?", + "discord.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "discord.forgotAccount": "{display} esquecida", + "discord.setupPendingLabel": "Conta Discord", + "discord.setupWaitingForClient": "À espera do Discord...", + "discord.setupWaitingForLogin": "Inicia sessão na conta Discord que queres adicionar.", + "discord.setupKeepMeLoggedIn": "Mantém a sessão iniciada para conservar esta conta.", + "discord.setupDetected": "Conta detetada", + "discord.setupConnected": "Ligado", + "discord.setupReady": "Conta Discord adicionada", + "discord.setupReadyWithProfile": "{profile} adicionada", + "discord.setupFailed": "A configuração do Discord falhou", + "discord.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Discord.", + "color.none": "Predefinida", + "color.blue": "Azul", + "color.cyan": "Ciano", + "color.green": "Verde", + "color.lime": "Verde-lima", + "color.yellow": "Amarelo", + "color.orange": "Laranja", + "color.red": "Vermelho", + "color.pink": "Rosa", + "color.violet": "Violeta", + "color.gray": "Cinzento", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Escuro", + "theme.light": "Claro", + "theme.midnight": "Meia-noite", + "theme.glassDark": "Vidro escuro", + "theme.glassLight": "Vidro claro", + "theme.liquidGlass": "Vidro líquido", + "theme.custom": "Personalizado", + "settings.themeImport": "Importar", + "settings.themeExport": "Exportar", + "settings.themeDelete": "Eliminar tema", + "settings.themeImported": "Tema importado", + "settings.themeDeleted": "Tema eliminado", + "settings.themeExportCopied": "JSON do tema copiado para a área de transferência", + "settings.themeInvalidJson": "JSON de tema inválido", + "settings.themeCustomize": "Personalizar", + "settings.themeEdit": "Editar", + "settings.themeCopyName": "{name} cópia", + "settings.themeSaved": "Tema guardado", + "settings.themeMigrated": "Tema importado e atualizado para o formato atual", + "settings.themeInvalidId": "O identificador do tema está em falta ou é inválido", + "settings.themeInvalidName": "O tema não tem nome", + "settings.themeUnsupportedVersion": "Este tema precisa de uma versão mais recente do accshift", + "themeGroup.surface": "Superfícies", + "themeGroup.text": "Texto", + "themeGroup.semantic": "Cores de estado", + "themeGroup.shape": "Forma e densidade", + "themeGroup.typography": "Tipografia", + "themeGroup.motion": "Movimento", + "themeKind.rgbTriplet": "trio RGB", + "themeKind.hexColor": "cor hexadecimal", + "themeKind.color": "cor", + "themeKind.length": "comprimento", + "themeKind.shadow": "sombra", + "themeKind.choice": "opção", + "themeKind.fontStack": "tipo de letra", + "themeKind.signedLength": "espaçamento", + "themeKind.gradient": "gradiente", + "themeKind.number": "número", + "themeDensity.compact": "Compacta", + "themeDensity.cozy": "Normal", + "themeDensity.comfortable": "Ampla", + "themeAvatarShape.circle": "Redondo", + "themeAvatarShape.rounded": "Arredondado", + "themeAvatarShape.square": "Quadrado", + "themeSmoothing.auto": "Suavizado", + "themeSmoothing.none": "Pixel", + "themeToken.bgRgb": "Fundo da janela", + "themeToken.bgCard": "Fundo dos cartões", + "themeToken.bgCardHover": "Fundo dos cartões ao passar o cursor", + "themeToken.bgMuted": "Fundo atenuado", + "themeToken.bgElevated": "Fundo elevado", + "themeToken.border": "Contornos", + "themeToken.fg": "Texto principal", + "themeToken.fgMuted": "Texto secundário", + "themeToken.fgSubtle": "Texto discreto", + "themeToken.afkText": "Etiqueta de ausente", + "themeToken.accent": "Destaque", + "themeToken.accentFg": "Texto sobre o destaque", + "themeToken.success": "Sucesso", + "themeToken.warning": "Aviso", + "themeToken.danger": "Perigo", + "themeToken.radiusSm": "Raio pequeno", + "themeToken.radiusMd": "Raio médio", + "themeToken.radiusLg": "Raio grande", + "themeToken.elevationLow": "Sombra ligeira", + "themeToken.elevationMedium": "Sombra média", + "themeToken.elevationHigh": "Sombra forte", + "themeToken.density": "Densidade", + "themeToken.fontUi": "Tipo de letra da interface", + "themeToken.bgImage": "Gradiente da janela", + "themeToken.cardBgImage": "Gradiente das superfícies", + "themeToken.borderWidth": "Espessura dos contornos", + "themeToken.borderStyle": "Estilo dos contornos", + "themeToken.avatarShape": "Forma dos avatares", + "themeToken.focusRing": "Contorno de foco", + "themeToken.overlayBlur": "Desfoque do fundo", + "themeToken.fontDisplay": "Tipo de letra dos títulos", + "themeToken.lineHeight": "Altura da linha", + "themeToken.letterSpacing": "Espaçamento entre letras", + "themeToken.labelCase": "Maiúsculas das etiquetas", + "themeToken.fontSmoothing": "Suavização do texto", + "themeToken.motionScale": "Velocidade das animações", + "themeEditor.title": "Editor de temas", + "themeEditor.name": "Nome", + "themeEditor.author": "Autor", + "themeEditor.version": "Versão", + "themeEditor.base": "Herda de", + "themeEditor.colorScheme": "Modo de cor", + "themeEditor.glass": "Superfícies translúcidas", + "themeEditor.inherited": "Herdado", + "themeEditor.builtInBase": "Tema incorporado", + "themeEditor.reset": "Voltar ao valor herdado", + "themeEditor.issues": "Verificações", + "themeEditor.noIssues": "Nada a assinalar", + "themeEditor.fixErrorsFirst": "Corrija os valores recusados antes de guardar", + "themeEditor.save": "Guardar", + "themeEditor.saveFailed": "Não foi possível guardar o tema. Tenta de novo.", + "themeEditor.export": "Copiar", + "themeEditor.issueInvalidValue": "{token}: {kind} inválido, por exemplo {example}", + "themeEditor.issueUnknownToken": "{token} não é um valor de tema, é ignorado", + "themeEditor.issueMissingToken": "{token} não está definido, é usado o valor incorporado", + "themeEditor.issueUnknownBase": "O tema base {base} não existe, são usados os valores incorporados", + "themeEditor.issueContrast": "{token} sobre {against}: {ratio} para 1, abaixo do objetivo de {target} para 1", + "themeEditor.beta": "Beta", + "themeEditor.betaNote": "O formato de tema ainda muda: um tema escrito hoje pode precisar de um ajuste depois de uma atualização.", + "themeEditor.customCss": "CSS personalizado", + "themeEditor.customCssHelp": "Aplicado por cima do tema, para o que os valores acima não alcançam. Sem @import nem url(): um tema nunca vai buscar nada.", + "themeEditor.issueUnsafeCss": "{construct} não é permitido num tema, o CSS não é aplicado", + "themeEditor.issueCssTooLong": "CSS demasiado longo: {length} caracteres, o limite é {limit}", + "bulkEdit.title": "Edição em massa", + "bulkEdit.selectAccounts": "Selecionar contas", + "bulkEdit.selectAll": "Selecionar tudo", + "bulkEdit.deselectAll": "Desmarcar tudo", + "bulkEdit.next": "Seguinte", + "bulkEdit.apply": "Aplicar", + "bulkEdit.settings": "Definições", + "bulkEdit.newsPopup": "Pop-up de notícias dos jogos", + "bulkEdit.doNotDisturb": "Não incomodar", + "bulkEdit.launchOptions": "Opções de arranque por jogo", + "bulkEdit.unchanged": "Inalterado", + "bulkEdit.addLaunchOption": "Adicionar", + "bulkEdit.selectGame": "Seleciona um jogo...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "A conta atualmente ativa está selecionada. As alterações serão aplicadas no próximo reinício do Steam.", + "bulkEdit.toastSuccess": "Edição em massa: {count} com sucesso", + "bulkEdit.toastPartial": "Edição em massa: {succeeded} com sucesso, {failed} falhadas", + "bulkEdit.toastFailed": "A edição em massa falhou", + "bulkEdit.noAccountsSelected": "Nenhuma conta selecionada", + "bulkEdit.selected": "{count} selecionada(s)", + "bulkEdit.loadingGames": "A carregar jogos...", + "bulkEdit.copyUrls": "Copiar URLs", + "bulkEdit.urlsCopied": "{count} URL(s) de perfil copiada(s)", + "bulkEdit.cardColor": "Cor do cartão", + "bulkEdit.colorApplied": "Cor aplicada a {count} cartão(ões)", + "bulkEdit.colorCleared": "Cor removida de {count} cartão(ões)", + "common.retry": "Repetir", + "app.searchNoResults": "Nenhum resultado para «{query}»", + "app.folderEmpty": "Esta pasta está vazia", + "app.loadErrorGeneric": "Falha ao carregar as contas. Consulta a consola para mais detalhes.", + "toast.dismiss": "Fechar", + "toast.switchFailed": "A mudança de conta falhou. Consulta a consola para mais detalhes.", + "toast.addAccountFailed": "Não foi possível adicionar a conta. Consulta os registos da aplicação para mais detalhes.", + "toast.addAccountClientMissing": "{platform} não parece estar instalado. Instala-o, ou define a sua localização nas Definições.", + "toast.banCheckFailedGeneric": "A verificação de bans falhou. Consulta a consola para mais detalhes.", + "toast.renameAccountFailed": "Falha ao renomear: {error}", + "toast.copyFailed": "Não foi possível copiar para a área de transferência", + "toast.refreshFailed": "A atualização falhou. Consulta a consola para mais detalhes.", + "dialog.deleteFolderTitle": "Eliminar «{name}»?", + "dialog.deleteFolderMessage": "As subpastas também são eliminadas. As contas lá dentro sobem um nível, não são removidas.", + "personas.deleteConfirm": "Clica outra vez para eliminar", + "personas.discardConfirm": "Clica outra vez para descartar", + "personas.switchConfirmTitle": "Mudar de persona", + "personas.switchConfirmMessage": "Mudar {count} plataforma(s) para «{name}»? Cada cliente vai fechar e reabrir.", + "personas.switchConfirmAction": "Mudar", + "personas.deleteTitle": "Eliminar persona", + "personas.deleteMessage": "Eliminar «{name}»? As contas em si não são afetadas.", + "context.submenuError": "Não foi possível carregar este menu", + "settings.apiKeySaveFailed": "Não foi possível guardar a chave da API Steam", + "settings.apiKeyInvalidHint": "A chave não foi guardada. Verifica-a e tenta de novo.", + "settings.apiKeyCleared": "Chave da API Steam limpa", + "settings.apiKeyClearFailed": "Não foi possível limpar a chave da API Steam", + "settings.clearApiKey": "Limpar chave", + "settings.cs2Bridge": "Ponte de estatísticas CS2", + "settings.cs2BridgeLink": "Ligação da ponte", + "settings.cs2BridgeLinkPlaceholder": "https://o-teu-servidor/api/bridge/accshift/", + "settings.cs2BridgeTest": "Testar", + "settings.cs2BridgeStatusOk": "Ligado · {count} contas · {ms} ms", + "settings.cs2BridgeStatusFail": "A ligação falhou", + "settings.cs2BridgeStatusTesting": "A testar...", + "settings.cs2BridgeStatusUnknown": "Não testado", + "settings.cs2BridgeToken": "Token da API", + "settings.cs2BridgeTokenOptional": "opcional, para servidores próprios", + "settings.cs2BridgeTokenPlaceholder": "Token Bearer se o teu servidor precisar de um", + "settings.cs2BridgeSaveFailed": "Não foi possível guardar as definições da ponte CS2", + "settings.cs2BridgeClearToken": "Limpar token", + "settings.pathSaveFailed": "Não foi possível guardar o caminho de {platform}", + "settings.pathInvalidHint": "Não foi possível guardar este caminho. Verifica-o e tenta de novo.", + "settings.pathSteamNeverSignedIn": "Steam encontrado, inicia sessão uma vez para que crie o histórico de sessões", + "settings.pathNotSteamFolder": "Esta pasta não parece uma instalação do Steam", + "settings.pathNotADirectory": "O caminho tem de ser uma pasta existente", + "settings.pinSaved": "PIN guardado", + "settings.pinTakesEffectNextLaunch": "Aplica-se no próximo arranque da aplicação", + "settings.pinRequiredAfterInactivity": "Pedido após inatividade e no arranque da aplicação", + "settings.pinScreenLockOnly": "O PIN bloqueia esta janela e as trocas de conta. Não protege nada no disco.", + "settings.unitSeconds": "segundos", + "settings.unitDays": "dias", + "settings.telemetryLoadFailed": "Não foi possível carregar as definições de telemetria", + "palette.placeholder": "Escreve um comando ou o nome de uma conta...", + "palette.noResults": "Nenhum comando corresponde", + "palette.sectionAccounts": "Contas", + "palette.sectionActions": "Ações", + "palette.sectionNavigation": "Navegação", + "palette.openTab": "Ir para {platform}", + "palette.openFolder": "Abrir a pasta «{name}»", + "palette.backToParent": "Voltar à pasta acima", + "palette.zoomReset": "Repor o zoom" +} diff --git a/crates/accshift-app/i18n/ru.json b/crates/accshift-app/i18n/ru.json new file mode 100644 index 00000000..e165573d --- /dev/null +++ b/crates/accshift-app/i18n/ru.json @@ -0,0 +1,753 @@ +{ + "app.loadingSettings": "Загрузка настроек...", + "app.loading": "Загрузка...", + "app.searchPlaceholder": "Поиск аккаунта...", + "app.noAccountsFound": "Аккаунты {platform} не найдены", + "app.noAccountsHint": "Убедитесь, что {platform} установлен и вы вошли хотя бы один раз.", + "app.switchingAccount": "Смена аккаунта...", + "app.platformRestarting": "{platform} перезапускается", + "app.comingSoon": "{platform} - скоро", + "app.platformUnsupportedOs": "{platform} пока недоступен в этой ОС", + "app.noCompatiblePlatforms": "В этой ОС нет ни одной совместимой платформы", + "app.noCompatiblePlatformsHint": "Следите за обновлениями поддержки Windows или используйте совместимую машину.", + "update.ctaAvailable": "Есть обновление", + "update.ctaInstalling": "Установка...", + "update.restartToApplyVersion": "Перезапустить и применить обновление {version}", + "update.restartToApply": "Перезапустить и применить обновление", + "update.readyToastVersion": "Обновление {version} готово", + "update.readyToast": "Обновление готово", + "update.restartFailed": "Не удалось перезапустить для установки обновления", + "pin.lockedTitle": "Приложение заблокировано", + "pin.lockedPrompt": "Введите PIN для разблокировки", + "pin.placeholder": "PIN из 4 цифр", + "pin.unlock": "Разблокировать", + "pin.invalid": "Неверный PIN", + "pin.tooManyAttempts": "Слишком много попыток. Повторите через {seconds} с.", + "common.ok": "OK", + "common.cancel": "Отмена", + "common.confirm": "Подтвердить", + "common.close": "Закрыть", + "common.back": "Назад", + "common.active": "Активен", + "common.choose": "Выбрать...", + "common.none": "Нет", + "common.custom": "Свой", + "common.configured": "Настроено", + "common.missing": "Не задано", + "common.enabled": "Включено", + "common.disabled": "Отключено", + "common.on": "Вкл", + "common.off": "Выкл", + "common.add": "Добавить", + "common.visible": "Показано", + "common.hidden": "Скрыто", + "common.inline": "В строке", + "common.tooltip": "Подсказка", + "titlebar.refresh": "Обновить", + "titlebar.addAccount": "Добавить аккаунт", + "titlebar.settings": "Настройки", + "titlebar.personas": "Персоны", + "titlebar.minimize": "Свернуть", + "titlebar.maximize": "Развернуть", + "titlebar.restore": "Восстановить", + "titlebar.close": "Закрыть", + "view.grid": "Вид сеткой", + "view.list": "Вид списком", + "settings.title": "Настройки", + "settings.general": "Общие", + "settings.appearance": "Оформление", + "settings.language": "Язык", + "settings.theme": "Тема", + "settings.uiZoom": "Масштаб интерфейса", + "settings.backgroundOpacity": "Прозрачность фона", + "settings.animations": "Анимации", + "settings.animationsSystem": "Как в системе", + "settings.animationsOn": "Всегда включены", + "settings.animationsOff": "Выключены", + "settings.performance": "Производительность", + "settings.suspendGraphicsWhenMinimized": "Отключать графику при сворачивании", + "settings.minimizeOnAccountSwitch": "Сворачивать при смене аккаунта", + "settings.system": "Система", + "settings.startWithSystem": "Запуск при старте", + "settings.startWithSystemHint": "Запускать Accshift вместе с Windows, свёрнутым в область уведомлений.", + "settings.closeToTray": "Сворачивать в область уведомлений", + "settings.closeToTrayHint": "Кнопка закрытия сворачивает Accshift в область уведомлений вместо выхода.", + "settings.autostartFailed": "Не удалось изменить автозапуск.", + "tray.open": "Открыть Accshift", + "tray.quit": "Выход", + "settings.platforms": "Платформы", + "settings.platformSearchPlaceholder": "Поиск платформ...", + "settings.platformSearchNoResults": "По запросу ничего не найдено.", + "settings.accountDisplay": "Отображение аккаунтов", + "settings.startupAndExtras": "Запуск", + "settings.defaultOnStartup": "Платформа при запуске", + "settings.platformDisabledSuffix": "(отключена)", + "settings.platformUnsupportedOs": "ОС не поддерживается", + "settings.platformNotImplemented": "В планах", + "settings.platformUserProvided": "Добавлена из вашей папки дескрипторов", + "settings.customPlatforms": "Пользовательские платформы", + "settings.customPlatformsHint": "Файлы дескрипторов в этой папке читаются при запуске приложения.", + "settings.customPlatformsNone": "Из этой папки не загружен ни один дескриптор.", + "settings.customPlatformsProblems": "Файлы, которые не загрузились", + "descriptor.addFromFile": "Добавить из файла", + "descriptor.reload": "Перезагрузить", + "descriptor.openFolder": "Открыть папку", + "descriptor.remove": "Удалить", + "descriptor.removeConfirmTitle": "Удалить {platform}?", + "descriptor.removeConfirmMessage": "Файл дескриптора покинет вашу папку. Уже сохранённые аккаунты останутся на диске.", + "descriptor.previewTitle": "Добавить платформу", + "descriptor.previewOs": "Описана для {list}", + "descriptor.previewRoots": "Папки, которых она может касаться", + "descriptor.previewSteps": "Что сделало бы переключение аккаунта", + "descriptor.previewWarnings": "Предупреждения", + "descriptor.previewNoPlan": "Не удалось построить пробный запуск: {problem}", + "descriptor.previewReplaces": "Это заменит дескриптор, который уже лежит в вашей папке.", + "descriptor.previewNothingWritten": "Ничего не записывается, пока вы не добавите.", + "descriptor.replace": "Заменить", + "descriptor.installed": "{platform} добавлена", + "descriptor.removed": "{platform} удалена", + "descriptor.actionRead": "чтение", + "descriptor.actionCapture": "снимок", + "descriptor.actionRestore": "восстановление", + "descriptor.actionDelete": "удаление", + "descriptor.actionClose": "закрытие", + "descriptor.actionLaunch": "запуск", + "settings.dataRefresh": "Обновление данных", + "settings.avatarRefresh": "Обновление аватаров", + "settings.banCheckDelay": "Задержка проверки банов", + "settings.refreshNow": "Обновить сейчас", + "settings.zeroEachLaunch": "0 = при каждом запуске", + "settings.privacy": "Приватность", + "settings.inactivityTimeout": "Тайм-аут бездействия", + "settings.zeroDisabled": "0 = отключено", + "settings.streamerMode": "Режим стримера", + "settings.streamerModeHint": "Автоматически размывать данные аккаунтов, когда запущен софт для стриминга.", + "settings.personasHint": "Показывать переключатель персон", + "settings.replayOnboarding": "Пройти знакомство заново", + "settings.replayOnboardingHint": "Снова показать приветственный тур и выбор по статистике использования.", + "streamer.title": "Режим стримера", + "streamer.disable": "Отключить", + "streamer.disablePermanently": "Отключить навсегда", + "personas.title": "Персоны", + "personas.create": "Новая персона", + "personas.activate": "Переключиться на эту персону", + "personas.edit": "Изменить", + "personas.delete": "Удалить", + "personas.finish": "Готово", + "personas.switching": "Переключение...", + "personas.switched": "Переключено на {name}", + "personas.switchPartial": "{name}: сбой на {failed}", + "personas.switchFailed": "Не удалось переключиться на {name}", + "personas.namePlaceholder": "Название персоны", + "personas.choosePlatforms": "Выберите аккаунт для каждой нужной платформы", + "personas.chooseAccount": "Выберите аккаунт {platform}", + "personas.removeFromPersona": "Убрать из этой персоны", + "personas.customImage": "Выбрать изображение...", + "personas.removeImage": "Удалить изображение", + "personas.imageError": "Не удалось загрузить это изображение.", + "personas.loadingAccounts": "Загрузка аккаунтов...", + "personas.noAccountsOnPlatform": "На этой платформе пока нет аккаунтов", + "personas.emptyHint": "Один клик переключает каждую платформу на её аккаунт.", + "settings.security": "Безопасность", + "settings.pinLockOnAfk": "Блокировка PIN при бездействии", + "settings.pinCode": "PIN-код", + "settings.pinPlaceholder": "4 цифры", + "settings.pinCurrentCode": "Текущий PIN", + "settings.pinCurrentHint": "Введите текущий PIN, чтобы изменить или отключить блокировку.", + "settings.telemetry": "Статистика использования (анонимно)", + "settings.telemetryModeA": "Базовая анонимная статистика", + "settings.telemetryModeAHint": "Считает запуски и то, какие функции используются. Случайный локальный ID не даёт посчитать одну установку дважды; личные данные не отправляются.", + "settings.telemetryModeB": "Расширенная статистика с локальным UUID (по желанию)", + "settings.telemetryModeBHint": "Добавляет случайный UUID, который хранится только на этой машине. Позволяет оценить удержание и важность функций. Личные данные по-прежнему не отправляются.", + "settings.telemetryExport": "Экспортировать мои данные", + "settings.telemetryExported": "Данные скопированы в буфер обмена", + "settings.telemetryExportFailed": "Не удалось экспортировать", + "settings.telemetryEnableFailed": "Не удалось включить расширенную статистику", + "settings.telemetryDisableFailed": "Не удалось отключить расширенную статистику", + "settings.telemetryDeletePending": "Расширенная статистика отключена; удаление на сервере потребует ещё одной попытки", + "settings.telemetryDeletePendingHint": "Удаление на сервере не завершено. Старый локальный ID сохранён только для повторной попытки или экспорта связанных данных.", + "settings.telemetryRetryDelete": "Повторить удаление данных", + "settings.telemetryDeleteCompleted": "Данные на сервере удалены", + "settings.telemetryDeleteRetryFailed": "Удаление на сервере всё ещё не завершено", + "settings.telemetryLearnMore": "Какие данные отправляются?", + "onboarding.welcome.title": "Добро пожаловать в Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "Ваша операционная система совместима с:", + "onboarding.welcome.detectedPlatforms": "На вашем компьютере обнаружены эти платформы:", + "onboarding.welcome.next": "Показать обзор", + "onboarding.welcome.skip": "Пропустить", + "onboarding.features.hint": "Эта часть приложения сейчас не видна.", + "onboarding.features.addAccount.label": "Добавить аккаунт", + "onboarding.features.addAccount.body": "Кнопка + справа сверху добавляет аккаунт на текущую платформу. Войдите один раз, Accshift его запомнит.", + "onboarding.features.switch.label": "Смена аккаунта", + "onboarding.features.switch.body": "Нажмите на карточку, и вы уже в этом аккаунте. Честно говоря, это всё приложение целиком.", + "onboarding.features.contextMenu.label": "Правый клик для действий", + "onboarding.features.contextMenu.body": "Правый клик по карточке позволяет переименовать её, сменить цвет, переместить в папку или удалить.", + "onboarding.features.platformSwitch.label": "Смена платформы", + "onboarding.features.platformSwitch.body": "То же самое для Riot, Battle.net, Epic Games и остальных. Вкладки сверху переключают платформы.", + "onboarding.features.next": "Далее", + "onboarding.features.back": "Назад", + "onboarding.features.step": "Шаг {step}", + "onboarding.features.mockRename": "Переименовать", + "onboarding.features.mockSetColor": "Задать цвет", + "onboarding.features.mockMoveToFolder": "Переместить в папку", + "onboarding.features.mockDelete": "Удалить", + "onboarding.features.mockAccount": "Аккаунт {number}", + "onboarding.telemetry.title": "ПРОДАЙТЕ ДУШУ ACCSHIFT!", + "onboarding.telemetry.intro": "Accshift считает немного анонимной статистики, чтобы я знал, что чинить первым. Ничего личного вашу машину не покидает, приложение работает одинаково в любом случае, и вы всегда можете передумать в настройках.", + "onboarding.telemetry.question": "По рукам?", + "onboarding.telemetry.gifAlt": "Мем с предложением обмена", + "onboarding.telemetry.basic": "НЕТ! Хватит уже", + "onboarding.telemetry.basicDefault": "(по умолчанию)", + "onboarding.telemetry.basicHint": "Только анонимные счётчики и ничего больше: ни профиля, ни идентификатора, который следует за вами.", + "onboarding.telemetry.deal": "ПО РУКАМ", + "onboarding.telemetry.dealHint": "Добавляет отдельный UUID для расширенной статистики, он хранится только на вашей машине. Кто вы, по-прежнему неизвестно, но помощь огромная. Спасибо!", + "onboarding.telemetry.optOutNote": "Что бы вы ни выбрали, это можно изменить позже в разделе Настройки -> Приватность. :c", + "onboarding.telemetry.refuse": "Нет, спасибо, отключить всё", + "onboarding.telemetry.refuseHint": "Ничего не измеряется и не отправляется, даже анонимные счётчики.", + "onboarding.telemetry.back": "Назад", + "onboarding.persistFailed": "Не удалось сохранить этот выбор. Попробуй ещё раз.", + "onboarding.persistLater": "Спросить при следующем запуске", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Запускать Steam от админа", + "settings.showUsernames": "Показывать логины", + "settings.showSteamLastLogin": "Показывать последний вход в Steam", + "settings.showRiotLastLogin": "Показывать последний вход в Riot", + "settings.showBattleNetLastLogin": "Показывать последний вход в Battle.net", + "settings.showUbisoftLastLogin": "Показывать последний вход в Ubisoft", + "settings.showNotesUnderCards": "Показывать заметки под карточками", + "settings.expandedFolders": "Разворачивать все папки", + "list.rootSection": "Корень", + "settings.steamLaunch": "Смена", + "settings.steamInstallation": "Установка", + "settings.steamApiAndData": "API и данные", + "settings.launchOptions": "Параметры запуска", + "settings.shutdownMode": "Режим завершения", + "settings.shutdownModeGraceful": "Штатный", + "settings.shutdownModeForce": "Принудительный", + "settings.autoDetected": "Определено автоматически", + "settings.steamFolder": "Папка Steam", + "settings.steamFolderRegistry": "Реестр", + "settings.riotClientPath": "Исполняемый файл Riot Client", + "settings.battleNetPath": "Исполняемый файл Battle.net", + "settings.ubisoftPath": "Папка Ubisoft Connect", + "settings.steamWebApiKey": "Ключ Steam Web API", + "settings.getKey": "Получить ключ", + "settings.pasteApiKey": "Вставьте ваш ключ Steam Web API", + "settings.saved": "Настройки сохранены", + "settings.openApiKeyFailed": "Не удалось открыть страницу ключа Steam API", + "settings.integrations": "Интеграции", + "settings.deepLinks": "Разрешить ссылки accshift://", + "settings.deepLinksHint": "Позволяет другим приложениям менять аккаунт за вас: закладки, Stream Deck, скрипты.", + "settings.cliEnabled": "Разрешить CLI accshift", + "settings.cliEnabledHint": "Позволяет утилите командной строки показывать список и менять аккаунты. Если выключено, любая команда accshift отказывается выполняться, включая diag; само приложение не затронуто.", + "settings.help": "Открыть вики accshift", + "settings.helpShort": "Справка", + "settings.openHelpFailed": "Не удалось открыть вики", + "settings.logs": "Логи", + "settings.openLogs": "Открыть папку логов", + "settings.openLogsFailed": "Не удалось открыть папку логов", + "context.loading": "Загрузка...", + "context.noGamesFound": "Игры не найдены", + "context.field.cut": "Вырезать", + "context.field.copy": "Копировать", + "context.field.paste": "Вставить", + "context.field.selectAll": "Выделить всё", + "context.menu.newFolder": "Новая папка", + "context.menu.refresh": "Обновить", + "context.menu.rename": "Переименовать", + "context.menu.copy": "Копировать...", + "context.menu.deleteFolder": "Удалить папку", + "context.menu.folderColor": "Цвет папки", + "context.menu.moveToFolder": "Переместить в папку", + "context.menu.moveToRoot": "Без папки", + "context.menu.editCardAndColor": "Изменить карточку и цвет", + "context.menu.cardColor": "Цвет карточки", + "context.menu.addNote": "Добавить заметку", + "context.menu.editNote": "Изменить заметку", + "platform.rename": "Переименовать", + "platform.renameTitle": "Переименовать аккаунт", + "platform.renamePlaceholder": "Имя аккаунта", + "platform.newAccountPending": "Ожидание...", + "platform.setupBusy": "Выполняется другая операция accshift. Ожидание её завершения.", + "platform.renameNewAccount": "Назовите этот аккаунт", + "platform.copyLabelAccountId": "ID аккаунта", + "platform.forget": "Забыть", + "platform.forgetConfirmTitle": "Забыть «{display}»?", + "platform.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "platform.forgotAccount": "Забыт {display}", + "dialog.newFolderTitle": "Новая папка", + "dialog.renameFolderTitle": "Переименовать папку", + "dialog.folderNamePlaceholder": "Имя папки", + "dialog.cardNoteTitle": "Заметка карточки", + "dialog.cardNotePlaceholder": "Напишите короткую заметку", + "card.noteAttached": "Есть заметка", + "card.clickAgainToSwitch": "Нажмите ещё раз для смены", + "card.extensionWarnings": "Предупреждения", + "card.extensionNote": "Заметка", + "card.cs2Section": "CS2", + "card.cs2Level": "Уровень {level}", + "card.cs2CaseEarned": "Недельный кейс получен", + "card.cs2CaseNotEarned": "Недельный кейс ожидается", + "list.selectAccountPreview": "Выберите аккаунт для просмотра", + "preview.noteLabel": "Заметка", + "preview.currentlyActive": "Сейчас активен", + "preview.switchAgain": "Сменить снова", + "preview.switchAccount": "Сменить аккаунт", + "ban.community": "Бан сообщества", + "ban.vac.single": "{count} бан VAC", + "ban.vac.multiple": "{count} банов VAC", + "ban.game.single": "{count} игровой бан", + "ban.game.multiple": "{count} игровых банов", + "time.unknown": "неизвестно", + "time.justNow": "только что", + "time.minuteAgo": "{count} мин. назад", + "time.minutesAgo": "{count} мин. назад", + "time.hourAgo": "{count} ч. назад", + "time.hoursAgo": "{count} ч. назад", + "time.dayAgo": "{count} дн. назад", + "time.daysAgo": "{count} дн. назад", + "time.neverConnected": "входов не было", + "time.compactWrapper": "({value})", + "toast.copied": "{label} скопировано", + "toast.legacyConfigMigrated": "Настройки перенесены в новый формат", + "toast.legacyConfigMigrationFailed": "Не удалось перенести старые настройки, смотрите логи", + "toast.failedLoadSettingsPanel": "Не удалось загрузить панель настроек", + "toast.banChecking": "Проверка банов...", + "toast.banCheckSummary.single": "Проверка банов: {count} аккаунт с банами", + "toast.banCheckSummary.multiple": "Проверка банов: {count} аккаунтов с банами", + "toast.banCheckFailed": "Проверка банов не удалась: {error}", + "toast.avatarRefreshComplete": "Аватары обновлены для {count} аккаунтов", + "toast.banRefreshComplete": "Баны обновлены для {count} аккаунтов", + "toast.banRefreshNoApiKey": "Обновление банов пропущено: добавьте ключ Steam Web API в настройках", + "toast.refreshPartial": "Обновление завершено для {ok} из {count} аккаунтов", + "toast.noSteamAccountsFound": "Аккаунты Steam не найдены. Войдите в Steam хотя бы один раз, затем обновите.", + "toast.accountsRefreshed.single": "Обновлён {count} аккаунт", + "toast.accountsRefreshed.multiple": "Обновлено аккаунтов: {count}", + "toast.steamFolderNotFound": "Папка Steam не найдена. Укажите её вручную в «Настройки > Папка Steam».", + "toast.steamElevated": "Steam запущен от админа. Включите «Запускать Steam от админа» в «Настройки > Steam».", + "toast.noRiotProfilesFound": "Профилей Riot пока нет. Нажмите +, чтобы добавить.", + "toast.noBattleNetAccountsFound": "Аккаунты Battle.net не найдены. Войдите в Battle.net хотя бы один раз, затем обновите.", + "toast.noUbisoftAccountsFound": "Аккаунты Ubisoft не найдены. Войдите в Ubisoft Connect хотя бы один раз, затем обновите.", + "toast.deepLinkInvalid": "Нераспознанная ссылка accshift://", + "toast.deepLinkDisabled": "Ссылки accshift:// отключены в настройках", + "toast.deepLinkUnknownPlatform": "Неизвестная платформа в ссылке: {platform}", + "toast.deepLinkPlatformDisabled": "{platform} отключён в настройках", + "toast.deepLinkAccountNotFound": "Нет аккаунта {platform}, подходящего под «{account}»", + "toast.deepLinkSwitched": "Переключено на {account}", + "dialog.deepLinkSwitchTitle": "Сменить аккаунт?", + "dialog.deepLinkSwitchMessage": "Ссылка accshift:// просит сменить ваш аккаунт {platform} на «{account}». Продолжить?", + "dialog.deepLinkSwitchConfirm": "Сменить", + "steam.launchOnline": "Сменить в сети", + "steam.launchInvisible": "Сменить в невидимке", + "steam.switchAndRunGame": "Сменить + запустить {game}", + "steam.switchAndRunPick": "Сменить + запустить игру…", + "steam.runGame": "Запустить {game}", + "steam.runPick": "Запустить игру…", + "steam.switchAndRunNoGames": "Игры не найдены", + "steam.clearDefaultGame": "Сбросить игру по умолчанию", + "steam.copySteamId64": "Копировать SteamID64", + "steam.copyFriendCode": "Копировать Friend Code", + "steam.copyProfileUrl": "Копировать ссылку на профиль", + "steam.copyLabelUsername": "Логин", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Friend Code", + "steam.copyLabelProfileUrl": "Ссылка на профиль", + "steam.openUserdataFolder": "Открыть папку userdata", + "steam.clearIntegratedBrowserCache": "Сбросить сессию браузера", + "steam.clearIntegratedBrowserCacheConfirmTitle": "Сбросить сессию браузера Steam?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "Steam будет закрыт, а кэш встроенного браузера для Магазина и Сообщества очищен.", + "steam.clearedIntegratedBrowserCache": "Сессия браузера Steam сброшена", + "steam.copySettingsFrom": "Копировать настройки из", + "steam.copiedSettingsToCurrent": "Настройки {game} скопированы в текущий аккаунт", + "steam.forget": "Забыть", + "steam.forgetConfirmTitle": "Забыть «{display}»?", + "steam.forgetConfirmMessage": "Аккаунт будет удалён из списка аккаунтов Steam на этом ПК.", + "steam.forgotAccount": "Забыт {username}", + "steam.setupPendingLabel": "Аккаунт Steam", + "steam.setupWaitingForClient": "Ожидание Steam...", + "steam.setupWaitingForLogin": "Войдите в аккаунт Steam, который хотите добавить.", + "steam.setupDetected": "Аккаунт определён", + "steam.setupConnected": "Подключено", + "steam.setupReady": "Аккаунт Steam добавлен", + "steam.setupReadyWithProfile": "Добавлен {profile}", + "steam.setupFailed": "Не удалось настроить Steam", + "steam.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Steam.", + "riot.captureSession": "Захватить текущую сессию", + "riot.recaptureSession": "Захватить сессию заново", + "riot.capturedSession": "Сессия для {profile} захвачена", + "riot.setupPendingLabel": "Аккаунт Riot", + "riot.setupWaitingForClient": "Ожидание Riot Client...", + "riot.setupWaitingForLogin": "Войдите в аккаунт Riot, который хотите добавить.", + "riot.setupCapturing": "Сохранение сессии Riot...", + "riot.setupReady": "Аккаунт Riot добавлен", + "riot.setupReadyWithProfile": "Добавлен {profile}", + "riot.setupFailed": "Не удалось настроить Riot", + "riot.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Riot.", + "riot.setupStaySignedIn": "Оставьте включённой опцию «Stay signed in».", + "riot.setupDetected": "Аккаунт определён", + "riot.setupConnected": "Подключено", + "riot.forget": "Забыть аккаунт", + "riot.forgetConfirmTitle": "Забыть «{display}»?", + "riot.forgetConfirmMessage": "Локальный профиль Riot и его сохранённая сессия будут удалены из Accshift.", + "riot.forgotProfile": "Забыт {profile}", + "battlenet.copyUsername": "Копировать логин", + "battlenet.copyLabelUsername": "Логин", + "battlenet.copyEmail": "Копировать e-mail", + "battlenet.copyLabelEmail": "E-mail", + "battlenet.forget": "Забыть", + "battlenet.forgetConfirmTitle": "Забыть «{display}»?", + "battlenet.forgetConfirmMessage": "Аккаунт будет удалён из списка аккаунтов Battle.net на этом ПК.", + "battlenet.forgotAccount": "Забыт {email}", + "battlenet.setupPendingLabel": "Аккаунт Battle.net", + "battlenet.setupWaitingForClient": "Ожидание Battle.net...", + "battlenet.setupWaitingForLogin": "Войдите в аккаунт Battle.net, который хотите добавить.", + "battlenet.setupKeepMeLoggedIn": "По возможности оставьте включённой опцию «Keep me logged in».", + "battlenet.setupDetected": "Аккаунт определён", + "battlenet.setupConnected": "Подключено", + "battlenet.setupReady": "Аккаунт Battle.net добавлен", + "battlenet.setupReadyWithProfile": "Добавлен {profile}", + "battlenet.setupFailed": "Не удалось настроить Battle.net", + "battlenet.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Battle.net.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Забыть", + "ubisoft.forgetConfirmTitle": "Забыть «{display}»?", + "ubisoft.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "ubisoft.forgotAccount": "Забыт {display}", + "ubisoft.setupPendingLabel": "Аккаунт Ubisoft", + "ubisoft.setupWaitingForClient": "Ожидание Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Войдите в аккаунт Ubisoft, который хотите добавить.", + "ubisoft.setupKeepMeLoggedIn": "Оставьте включённой опцию «Remember me».", + "ubisoft.setupDetected": "Аккаунт определён", + "ubisoft.setupConnected": "Подключено", + "ubisoft.setupReady": "Аккаунт Ubisoft добавлен", + "ubisoft.setupReadyWithProfile": "Добавлен {profile}", + "ubisoft.setupFailed": "Не удалось настроить Ubisoft", + "ubisoft.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Ubisoft.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "Аккаунты Roblox не найдены. Добавьте аккаунт через Quick Login или вставьте cookie.", + "roblox.noAccountsHint": "Нажмите +, чтобы добавить аккаунт Roblox через Quick Login.", + "roblox.sessionExpired": "Сессия истекла", + "roblox.sessionExpiredTooltip": "Из этого аккаунта выполнен выход. Добавьте его заново перед сменой.", + "roblox.sessionExpiredSwitchError": "Сессия истекла. Добавьте этот аккаунт заново, чтобы переключиться. Заметки и настройки сохранятся.", + "roblox.cookiePasteTitle": "Добавить аккаунт по cookie", + "roblox.cookiePasteHint": "Вставьте cookie .ROBLOSECURITY из браузера, чтобы добавить аккаунт напрямую.", + "settings.showRobloxLastLogin": "Показывать последний вход", + "settings.robloxHealthCheck": "Проверка сессии", + "settings.robloxHealthCheckHint": "Помечает аккаунты с истёкшей сессией Roblox; один сетевой запрос за запуск.", + "roblox.copyLabelUsername": "Логин", + "roblox.copyLabelUserId": "ID пользователя", + "roblox.forget": "Забыть", + "roblox.forgetConfirmTitle": "Забыть «{display}»?", + "roblox.forgetConfirmMessage": "Аккаунт и сохранённый cookie будут удалены с этого ПК.", + "roblox.forgotAccount": "Забыт {display}", + "roblox.setupPendingLabel": "Аккаунт Roblox", + "roblox.setupWaitingForClient": "Подключение к Roblox...", + "roblox.setupWaitingForLogin": "Откройте приложение Roblox на другом устройстве и введите код ниже.", + "roblox.setupQuickLoginCode": "Код Quick Login", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Копировать код", + "roblox.setupDetected": "Аккаунт определён", + "roblox.setupConnected": "Подключено", + "roblox.setupReady": "Аккаунт Roblox добавлен", + "roblox.setupReadyWithProfile": "Добавлен {profile}", + "roblox.setupFailed": "Не удалось настроить Roblox", + "roblox.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Roblox.", + "settings.epic": "Epic Games", + "settings.epicPath": "Исполняемый файл Epic Games Launcher", + "settings.showEpicLastLogin": "Показывать последний вход", + "toast.noEpicAccountsFound": "Аккаунты Epic Games не найдены. Войдите в Epic Games Launcher хотя бы один раз, затем обновите.", + "epic.noAccountsHint": "Нажмите +, чтобы добавить аккаунт Epic Games.", + "epic.copyLabelAccountId": "ID аккаунта", + "epic.forget": "Забыть", + "epic.forgetConfirmTitle": "Забыть «{display}»?", + "epic.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "epic.forgotAccount": "Забыт {display}", + "epic.setupPendingLabel": "Аккаунт Epic Games", + "epic.setupWaitingForClient": "Ожидание Epic Games Launcher...", + "epic.setupWaitingForLogin": "Войдите в аккаунт Epic Games, который хотите добавить.", + "epic.setupKeepMeLoggedIn": "Оставьте включённой опцию «Remember me».", + "epic.setupDetected": "Аккаунт определён", + "epic.setupConnected": "Подключено", + "epic.setupReady": "Аккаунт Epic Games добавлен", + "epic.setupReadyWithProfile": "Добавлен {profile}", + "epic.setupFailed": "Не удалось настроить Epic Games", + "epic.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Epic Games.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "Исполняемый файл GOG Galaxy", + "settings.showGogLastLogin": "Показывать последний вход", + "toast.noGogAccountsFound": "Аккаунты GOG Galaxy не найдены. Войдите в GOG Galaxy хотя бы один раз, затем обновите.", + "gog.noAccountsHint": "Нажмите +, чтобы добавить аккаунт GOG Galaxy.", + "gog.copyLabelAccountId": "ID аккаунта", + "gog.forget": "Забыть", + "gog.forgetConfirmTitle": "Забыть «{display}»?", + "gog.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "gog.forgotAccount": "Забыт {display}", + "gog.setupPendingLabel": "Аккаунт GOG Galaxy", + "gog.setupWaitingForClient": "Ожидание GOG Galaxy...", + "gog.setupWaitingForLogin": "Войдите в аккаунт GOG Galaxy, который хотите добавить.", + "gog.setupKeepMeLoggedIn": "Оставьте включённой опцию «Stay signed in».", + "gog.setupDetected": "Аккаунт определён", + "gog.setupConnected": "Подключено", + "gog.setupReady": "Аккаунт GOG Galaxy добавлен", + "gog.setupReadyWithProfile": "Добавлен {profile}", + "gog.setupFailed": "Не удалось настроить GOG Galaxy", + "gog.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта GOG Galaxy.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Исполняемый файл Jagex Launcher", + "settings.showJagexLastLogin": "Показывать последний вход", + "toast.noJagexAccountsFound": "Аккаунты Jagex ещё не сохранены. Нажмите +, чтобы добавить через Jagex Launcher.", + "jagex.noAccountsHint": "Нажмите +, чтобы добавить аккаунт Jagex.", + "jagex.copyLabelAccountId": "ID аккаунта", + "jagex.forget": "Забыть", + "jagex.forgetConfirmTitle": "Забыть «{display}»?", + "jagex.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "jagex.forgotAccount": "Забыт {display}", + "jagex.setupPendingLabel": "Аккаунт Jagex", + "jagex.setupWaitingForClient": "Ожидание Jagex Launcher...", + "jagex.setupWaitingForLogin": "Войдите в аккаунт Jagex, который хотите добавить.", + "jagex.setupKeepMeLoggedIn": "Оставьте включённой опцию «Remember me».", + "jagex.setupDetected": "Аккаунт определён", + "jagex.setupConnected": "Подключено", + "jagex.setupReady": "Аккаунт Jagex добавлен", + "jagex.setupReadyWithProfile": "Добавлен {profile}", + "jagex.setupFailed": "Не удалось настроить Jagex", + "jagex.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Jagex.", + "settings.discord": "Discord", + "settings.discordPath": "Исполняемый файл Discord", + "settings.showDiscordLastLogin": "Показывать последний вход", + "toast.noDiscordAccountsFound": "Аккаунты Discord не найдены. Добавьте аккаунт Discord, затем обновите.", + "discord.noAccountsHint": "Нажмите +, чтобы добавить аккаунт Discord.", + "discord.copyLabelAccountId": "ID аккаунта", + "discord.forget": "Забыть", + "discord.forgetConfirmTitle": "Забыть «{display}»?", + "discord.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "discord.forgotAccount": "Забыт {display}", + "discord.setupPendingLabel": "Аккаунт Discord", + "discord.setupWaitingForClient": "Ожидание Discord...", + "discord.setupWaitingForLogin": "Войдите в аккаунт Discord, который хотите добавить.", + "discord.setupKeepMeLoggedIn": "Не выходите из аккаунта, чтобы он сохранился.", + "discord.setupDetected": "Аккаунт определён", + "discord.setupConnected": "Подключено", + "discord.setupReady": "Аккаунт Discord добавлен", + "discord.setupReadyWithProfile": "Добавлен {profile}", + "discord.setupFailed": "Не удалось настроить Discord", + "discord.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Discord.", + "color.none": "По умолчанию", + "color.blue": "Синий", + "color.cyan": "Голубой", + "color.green": "Зелёный", + "color.lime": "Лаймовый", + "color.yellow": "Жёлтый", + "color.orange": "Оранжевый", + "color.red": "Красный", + "color.pink": "Розовый", + "color.violet": "Фиолетовый", + "color.gray": "Серый", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Тёмная", + "theme.light": "Светлая", + "theme.midnight": "Полночь", + "theme.glassDark": "Стекло тёмное", + "theme.glassLight": "Стекло светлое", + "theme.liquidGlass": "Жидкое стекло", + "theme.custom": "Своя", + "settings.themeImport": "Импорт", + "settings.themeExport": "Экспорт", + "settings.themeDelete": "Удалить тему", + "settings.themeImported": "Тема импортирована", + "settings.themeDeleted": "Тема удалена", + "settings.themeExportCopied": "JSON темы скопирован в буфер обмена", + "settings.themeInvalidJson": "Некорректный JSON темы", + "settings.themeCustomize": "Настроить", + "settings.themeEdit": "Изменить", + "settings.themeCopyName": "{name} копия", + "settings.themeSaved": "Тема сохранена", + "settings.themeMigrated": "Тема импортирована и переведена в текущий формат", + "settings.themeInvalidId": "Идентификатор темы отсутствует или некорректен", + "settings.themeInvalidName": "У темы нет названия", + "settings.themeUnsupportedVersion": "Этой теме нужна более новая версия accshift", + "themeGroup.surface": "Поверхности", + "themeGroup.text": "Текст", + "themeGroup.semantic": "Цвета состояний", + "themeGroup.shape": "Форма и плотность", + "themeGroup.typography": "Типографика", + "themeGroup.motion": "Движение", + "themeKind.rgbTriplet": "триплет RGB", + "themeKind.hexColor": "шестнадцатеричный цвет", + "themeKind.color": "цвет", + "themeKind.length": "длина", + "themeKind.shadow": "тень", + "themeKind.choice": "вариант", + "themeKind.fontStack": "шрифт", + "themeKind.signedLength": "интервал", + "themeKind.gradient": "градиент", + "themeKind.number": "число", + "themeDensity.compact": "Плотная", + "themeDensity.cozy": "Обычная", + "themeDensity.comfortable": "Свободная", + "themeAvatarShape.circle": "Круг", + "themeAvatarShape.rounded": "Скруглённый", + "themeAvatarShape.square": "Квадрат", + "themeSmoothing.auto": "Сглаженный", + "themeSmoothing.none": "Пиксельный", + "themeToken.bgRgb": "Фон окна", + "themeToken.bgCard": "Фон карточек", + "themeToken.bgCardHover": "Фон карточек при наведении", + "themeToken.bgMuted": "Приглушённый фон", + "themeToken.bgElevated": "Приподнятый фон", + "themeToken.border": "Границы", + "themeToken.fg": "Основной текст", + "themeToken.fgMuted": "Второстепенный текст", + "themeToken.fgSubtle": "Малозаметный текст", + "themeToken.afkText": "Отметка отсутствия", + "themeToken.accent": "Акцент", + "themeToken.accentFg": "Текст на акценте", + "themeToken.success": "Успех", + "themeToken.warning": "Предупреждение", + "themeToken.danger": "Опасность", + "themeToken.radiusSm": "Малое скругление", + "themeToken.radiusMd": "Среднее скругление", + "themeToken.radiusLg": "Большое скругление", + "themeToken.elevationLow": "Лёгкая тень", + "themeToken.elevationMedium": "Средняя тень", + "themeToken.elevationHigh": "Сильная тень", + "themeToken.density": "Плотность", + "themeToken.fontUi": "Шрифт интерфейса", + "themeToken.bgImage": "Градиент окна", + "themeToken.cardBgImage": "Градиент поверхностей", + "themeToken.borderWidth": "Толщина границ", + "themeToken.borderStyle": "Стиль границ", + "themeToken.avatarShape": "Форма аватаров", + "themeToken.focusRing": "Рамка фокуса", + "themeToken.overlayBlur": "Размытие фона", + "themeToken.fontDisplay": "Шрифт заголовков", + "themeToken.lineHeight": "Высота строки", + "themeToken.letterSpacing": "Межбуквенный интервал", + "themeToken.labelCase": "Регистр подписей", + "themeToken.fontSmoothing": "Сглаживание текста", + "themeToken.motionScale": "Скорость анимаций", + "themeEditor.title": "Редактор тем", + "themeEditor.name": "Название", + "themeEditor.author": "Автор", + "themeEditor.version": "Версия", + "themeEditor.base": "Наследует от", + "themeEditor.colorScheme": "Цветовая схема", + "themeEditor.glass": "Полупрозрачные поверхности", + "themeEditor.inherited": "Унаследовано", + "themeEditor.builtInBase": "Встроенная тема", + "themeEditor.reset": "Вернуть унаследованное значение", + "themeEditor.issues": "Проверки", + "themeEditor.noIssues": "Замечаний нет", + "themeEditor.fixErrorsFirst": "Исправьте отклонённые значения перед сохранением", + "themeEditor.save": "Сохранить", + "themeEditor.saveFailed": "Не удалось сохранить тему. Попробуй ещё раз.", + "themeEditor.export": "Копировать", + "themeEditor.issueInvalidValue": "{token}: недопустимое значение ({kind}), например {example}", + "themeEditor.issueUnknownToken": "{token} не является значением темы и игнорируется", + "themeEditor.issueMissingToken": "{token} не задан, используется встроенное значение", + "themeEditor.issueUnknownBase": "Базовая тема {base} не найдена, используются встроенные значения", + "themeEditor.issueContrast": "{token} на {against}: {ratio} к 1, ниже цели {target} к 1", + "themeEditor.beta": "Бета", + "themeEditor.betaNote": "Формат темы ещё меняется: тему, написанную сегодня, возможно, придётся поправить после обновления.", + "themeEditor.customCss": "Свой CSS", + "themeEditor.customCssHelp": "Применяется поверх темы, для того, до чего значения выше не достают. Без @import и url(): тема ничего не загружает извне.", + "themeEditor.issueUnsafeCss": "{construct} в теме запрещён, CSS не применяется", + "themeEditor.issueCssTooLong": "CSS слишком длинный: {length} символов, предел {limit}", + "bulkEdit.title": "Массовое изменение", + "bulkEdit.selectAccounts": "Выберите аккаунты", + "bulkEdit.selectAll": "Выбрать все", + "bulkEdit.deselectAll": "Снять выбор", + "bulkEdit.next": "Далее", + "bulkEdit.apply": "Применить", + "bulkEdit.settings": "Настройки", + "bulkEdit.newsPopup": "Всплывающие новости игры", + "bulkEdit.doNotDisturb": "Не беспокоить", + "bulkEdit.launchOptions": "Параметры запуска по играм", + "bulkEdit.unchanged": "Без изменений", + "bulkEdit.addLaunchOption": "Добавить", + "bulkEdit.selectGame": "Выберите игру...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "Выбран активный сейчас аккаунт. Изменения применятся при следующем запуске Steam.", + "bulkEdit.toastSuccess": "Массовое изменение: успешно {count}", + "bulkEdit.toastPartial": "Массовое изменение: успешно {succeeded}, с ошибкой {failed}", + "bulkEdit.toastFailed": "Массовое изменение не удалось", + "bulkEdit.noAccountsSelected": "Аккаунты не выбраны", + "bulkEdit.selected": "Выбрано: {count}", + "bulkEdit.loadingGames": "Загрузка игр...", + "bulkEdit.copyUrls": "Копировать ссылки", + "bulkEdit.urlsCopied": "Скопировано ссылок на профиль: {count}", + "bulkEdit.cardColor": "Цвет карточки", + "bulkEdit.colorApplied": "Цвет применён к карточкам: {count}", + "bulkEdit.colorCleared": "Цвет снят с карточек: {count}", + "common.retry": "Повторить", + "app.searchNoResults": "Нет результатов по запросу «{query}»", + "app.folderEmpty": "Эта папка пуста", + "app.loadErrorGeneric": "Не удалось загрузить аккаунты. Подробности в консоли.", + "toast.dismiss": "Закрыть", + "toast.switchFailed": "Не удалось сменить аккаунт. Подробности в консоли.", + "toast.addAccountFailed": "Не удалось добавить аккаунт. Подробности в логах приложения.", + "toast.addAccountClientMissing": "Похоже, {platform} не установлен. Установите его или укажите путь в настройках.", + "toast.banCheckFailedGeneric": "Проверка банов не удалась. Подробности в консоли.", + "toast.renameAccountFailed": "Не удалось переименовать: {error}", + "toast.copyFailed": "Не удалось скопировать в буфер обмена", + "toast.refreshFailed": "Не удалось обновить. Подробности в консоли.", + "dialog.deleteFolderTitle": "Удалить «{name}»?", + "dialog.deleteFolderMessage": "Вложенные папки тоже удаляются. Аккаунты внутри поднимаются на уровень выше, а не удаляются.", + "personas.deleteConfirm": "Нажмите ещё раз для удаления", + "personas.discardConfirm": "Нажмите ещё раз, чтобы отменить", + "personas.switchConfirmTitle": "Смена персоны", + "personas.switchConfirmMessage": "Переключить платформы ({count}) на «{name}»? Каждый клиент закроется и запустится заново.", + "personas.switchConfirmAction": "Переключить", + "personas.deleteTitle": "Удалить персону", + "personas.deleteMessage": "Удалить «{name}»? Сами аккаунты не затрагиваются.", + "context.submenuError": "Не удалось загрузить это меню", + "settings.apiKeySaveFailed": "Не удалось сохранить ключ Steam API", + "settings.apiKeyInvalidHint": "Ключ не сохранён. Проверьте его и попробуйте снова.", + "settings.apiKeyCleared": "Ключ Steam API удалён", + "settings.apiKeyClearFailed": "Не удалось удалить ключ Steam API", + "settings.clearApiKey": "Удалить ключ", + "settings.cs2Bridge": "Мост статистики CS2", + "settings.cs2BridgeLink": "Ссылка на мост", + "settings.cs2BridgeLinkPlaceholder": "https://your-server/api/bridge/accshift/", + "settings.cs2BridgeTest": "Проверить", + "settings.cs2BridgeStatusOk": "Подключено · {count} аккаунтов · {ms} мс", + "settings.cs2BridgeStatusFail": "Не удалось подключиться", + "settings.cs2BridgeStatusTesting": "Проверка...", + "settings.cs2BridgeStatusUnknown": "Не проверялось", + "settings.cs2BridgeToken": "Токен API", + "settings.cs2BridgeTokenOptional": "необязательно, для своих серверов", + "settings.cs2BridgeTokenPlaceholder": "Bearer-токен, если он нужен серверу", + "settings.cs2BridgeSaveFailed": "Не удалось сохранить настройки моста CS2", + "settings.cs2BridgeClearToken": "Удалить токен", + "settings.pathSaveFailed": "Не удалось сохранить путь {platform}", + "settings.pathInvalidHint": "Этот путь не удалось сохранить. Проверьте его и попробуйте снова.", + "settings.pathSteamNeverSignedIn": "Steam найден, войдите один раз, чтобы он создал историю входов", + "settings.pathNotSteamFolder": "Эта папка не похожа на установку Steam", + "settings.pathNotADirectory": "Путь должен указывать на существующую папку", + "settings.pinSaved": "PIN сохранён", + "settings.pinTakesEffectNextLaunch": "Вступит в силу при следующем запуске приложения", + "settings.pinRequiredAfterInactivity": "Требуется после бездействия и при запуске приложения", + "settings.pinScreenLockOnly": "PIN блокирует это окно и переключение аккаунтов. Он ничего не защищает на диске.", + "settings.unitSeconds": "секунд", + "settings.unitDays": "дней", + "settings.telemetryLoadFailed": "Не удалось загрузить настройки телеметрии", + "palette.placeholder": "Введите команду или имя аккаунта...", + "palette.noResults": "Подходящих команд нет", + "palette.sectionAccounts": "Аккаунты", + "palette.sectionActions": "Действия", + "palette.sectionNavigation": "Навигация", + "palette.openTab": "Перейти к {platform}", + "palette.openFolder": "Открыть папку «{name}»", + "palette.backToParent": "Вернуться в родительскую папку", + "palette.zoomReset": "Сбросить масштаб" +} diff --git a/crates/accshift-app/i18n/zh.json b/crates/accshift-app/i18n/zh.json new file mode 100644 index 00000000..4d3d96fc --- /dev/null +++ b/crates/accshift-app/i18n/zh.json @@ -0,0 +1,753 @@ +{ + "app.loadingSettings": "正在加载设置...", + "app.loading": "加载中...", + "app.searchPlaceholder": "搜索账号...", + "app.noAccountsFound": "未找到 {platform} 账号", + "app.noAccountsHint": "请确认已安装 {platform} 并且至少登录过一次。", + "app.switchingAccount": "正在切换账号...", + "app.platformRestarting": "{platform} 正在重启", + "app.comingSoon": "{platform} - 即将推出", + "app.platformUnsupportedOs": "{platform} 尚未支持当前系统", + "app.noCompatiblePlatforms": "当前系统没有可用的兼容平台", + "app.noCompatiblePlatformsHint": "请关注 Windows 支持更新,或改用受支持的设备。", + "update.ctaAvailable": "有可用更新", + "update.ctaInstalling": "正在安装...", + "update.restartToApplyVersion": "重启以应用更新 {version}", + "update.restartToApply": "重启以应用更新", + "update.readyToastVersion": "更新 {version} 已就绪", + "update.readyToast": "更新已就绪", + "update.restartFailed": "无法重启以应用更新", + "pin.lockedTitle": "应用已锁定", + "pin.lockedPrompt": "输入 PIN 解锁", + "pin.placeholder": "4 位 PIN", + "pin.unlock": "解锁", + "pin.invalid": "PIN 错误", + "pin.tooManyAttempts": "尝试次数过多,请在 {seconds} 秒后重试。", + "common.ok": "确定", + "common.cancel": "取消", + "common.confirm": "确认", + "common.close": "关闭", + "common.back": "返回", + "common.active": "使用中", + "common.choose": "选择...", + "common.none": "无", + "common.custom": "自定义", + "common.configured": "已配置", + "common.missing": "缺失", + "common.enabled": "已启用", + "common.disabled": "已禁用", + "common.on": "开", + "common.off": "关", + "common.add": "添加", + "common.visible": "显示", + "common.hidden": "隐藏", + "common.inline": "内嵌", + "common.tooltip": "悬浮提示", + "titlebar.refresh": "刷新", + "titlebar.addAccount": "添加账号", + "titlebar.settings": "设置", + "titlebar.personas": "身份", + "titlebar.minimize": "最小化", + "titlebar.maximize": "最大化", + "titlebar.restore": "还原", + "titlebar.close": "关闭", + "view.grid": "网格视图", + "view.list": "列表视图", + "settings.title": "设置", + "settings.general": "通用", + "settings.appearance": "外观", + "settings.language": "语言", + "settings.theme": "主题", + "settings.uiZoom": "界面缩放", + "settings.backgroundOpacity": "背景不透明度", + "settings.animations": "动画", + "settings.animationsSystem": "跟随系统", + "settings.animationsOn": "始终开启", + "settings.animationsOff": "关闭", + "settings.performance": "性能", + "settings.suspendGraphicsWhenMinimized": "最小化时暂停渲染", + "settings.minimizeOnAccountSwitch": "切换账号时最小化", + "settings.system": "系统", + "settings.startWithSystem": "开机启动", + "settings.startWithSystemHint": "随 Windows 启动 Accshift,并最小化到通知区域。", + "settings.closeToTray": "关闭到通知区域", + "settings.closeToTrayHint": "关闭按钮会将 Accshift 隐藏到通知区域,而不是退出。", + "settings.autostartFailed": "无法更改开机启动设置。", + "tray.open": "打开 Accshift", + "tray.quit": "退出", + "settings.platforms": "平台", + "settings.platformSearchPlaceholder": "搜索平台...", + "settings.platformSearchNoResults": "没有匹配的平台。", + "settings.accountDisplay": "账号显示", + "settings.startupAndExtras": "启动", + "settings.defaultOnStartup": "启动时的默认平台", + "settings.platformDisabledSuffix": "(已禁用)", + "settings.platformUnsupportedOs": "系统不支持", + "settings.platformNotImplemented": "计划中", + "settings.platformUserProvided": "来自你的描述符文件夹", + "settings.customPlatforms": "自定义平台", + "settings.customPlatformsHint": "此文件夹中的描述符文件会在应用启动时读取。", + "settings.customPlatformsNone": "此文件夹中没有加载任何描述符。", + "settings.customPlatformsProblems": "未能加载的文件", + "descriptor.addFromFile": "从文件添加", + "descriptor.reload": "重新加载", + "descriptor.openFolder": "打开文件夹", + "descriptor.remove": "移除", + "descriptor.removeConfirmTitle": "移除 {platform}?", + "descriptor.removeConfirmMessage": "它的描述符文件会离开你的文件夹。已保存的账号仍留在磁盘上。", + "descriptor.previewTitle": "添加平台", + "descriptor.previewOs": "适用于 {list}", + "descriptor.previewRoots": "它可以访问的文件夹", + "descriptor.previewSteps": "切换账号会做什么", + "descriptor.previewWarnings": "警告", + "descriptor.previewNoPlan": "无法生成试运行:{problem}", + "descriptor.previewReplaces": "这会替换你文件夹中已有的描述符。", + "descriptor.previewNothingWritten": "在你添加之前不会写入任何内容。", + "descriptor.replace": "替换", + "descriptor.installed": "已添加 {platform}", + "descriptor.removed": "已移除 {platform}", + "descriptor.actionRead": "读取", + "descriptor.actionCapture": "保存", + "descriptor.actionRestore": "恢复", + "descriptor.actionDelete": "删除", + "descriptor.actionClose": "关闭", + "descriptor.actionLaunch": "启动", + "settings.dataRefresh": "数据刷新", + "settings.avatarRefresh": "头像刷新", + "settings.banCheckDelay": "封禁检查间隔", + "settings.refreshNow": "立即刷新", + "settings.zeroEachLaunch": "0 = 每次启动", + "settings.privacy": "隐私", + "settings.inactivityTimeout": "无操作超时", + "settings.zeroDisabled": "0 = 禁用", + "settings.streamerMode": "直播模式", + "settings.streamerModeHint": "检测到直播软件运行时自动模糊账号信息。", + "settings.personasHint": "显示身份切换器", + "settings.replayOnboarding": "重看引导", + "settings.replayOnboardingHint": "再次显示欢迎导览和使用统计选项。", + "streamer.title": "直播模式", + "streamer.disable": "关闭", + "streamer.disablePermanently": "永久关闭", + "personas.title": "身份", + "personas.create": "新建身份", + "personas.activate": "切换到该身份", + "personas.edit": "编辑", + "personas.delete": "删除", + "personas.finish": "完成", + "personas.switching": "切换中...", + "personas.switched": "已切换到 {name}", + "personas.switchPartial": "{name}:{failed} 切换失败", + "personas.switchFailed": "无法切换到 {name}", + "personas.namePlaceholder": "身份名称", + "personas.choosePlatforms": "为每个要包含的平台选择一个账号", + "personas.chooseAccount": "选择 {platform} 账号", + "personas.removeFromPersona": "从该身份中移除", + "personas.customImage": "选择图片...", + "personas.removeImage": "移除图片", + "personas.imageError": "无法加载该图片。", + "personas.loadingAccounts": "正在加载账号...", + "personas.noAccountsOnPlatform": "该平台还没有账号", + "personas.emptyHint": "点击一次即可把所有已包含的平台切换到对应账号。", + "settings.security": "安全", + "settings.pinLockOnAfk": "挂机时 PIN 锁定", + "settings.pinCode": "PIN 码", + "settings.pinPlaceholder": "4 位数字", + "settings.pinCurrentCode": "当前 PIN", + "settings.pinCurrentHint": "输入当前 PIN 以更改或关闭锁定。", + "settings.telemetry": "使用统计(匿名)", + "settings.telemetryModeA": "匿名基础统计", + "settings.telemetryModeAHint": "统计启动次数和功能使用情况。随机本地 ID 用于避免重复计入安装量,不会发送任何个人数据。", + "settings.telemetryModeB": "增强统计,附带本地 UUID(可选)", + "settings.telemetryModeBHint": "额外生成一个仅保存在本机的随机 UUID,用于衡量留存率和功能价值。同样不会发送个人数据。", + "settings.telemetryExport": "导出我的数据", + "settings.telemetryExported": "数据已复制到剪贴板", + "settings.telemetryExportFailed": "导出失败", + "settings.telemetryEnableFailed": "无法启用增强统计", + "settings.telemetryDisableFailed": "无法关闭增强统计", + "settings.telemetryDeletePending": "增强统计已关闭,服务器数据删除需要再试一次", + "settings.telemetryDeletePendingHint": "服务器删除仍在等待中。保留旧的本地 ID 只是为了让你重试删除或导出相关数据。", + "settings.telemetryRetryDelete": "重试删除数据", + "settings.telemetryDeleteCompleted": "服务器数据已删除", + "settings.telemetryDeleteRetryFailed": "服务器删除仍在等待中", + "settings.telemetryLearnMore": "会发送哪些数据?", + "onboarding.welcome.title": "欢迎使用 Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "你的操作系统兼容以下平台:", + "onboarding.welcome.detectedPlatforms": "在你的电脑上检测到以下平台:", + "onboarding.welcome.next": "带我看看", + "onboarding.welcome.skip": "跳过", + "onboarding.features.hint": "应用的这一部分目前不可见。", + "onboarding.features.addAccount.label": "添加账号", + "onboarding.features.addAccount.body": "右上角的 + 按钮可以为当前平台添加账号。登录一次,Accshift 就帮你记住。", + "onboarding.features.switch.label": "切换账号", + "onboarding.features.switch.body": "点一下卡片就切到那个账号。说实话,整个应用就这么简单。", + "onboarding.features.contextMenu.label": "右键查看操作", + "onboarding.features.contextMenu.body": "右键点击卡片可以重命名、改颜色、移动到文件夹或删除。", + "onboarding.features.platformSwitch.label": "切换平台", + "onboarding.features.platformSwitch.body": "Riot、Battle.net、Epic 等平台也一样。顶部的标签页用来切换平台。", + "onboarding.features.next": "下一步", + "onboarding.features.back": "返回", + "onboarding.features.step": "第 {step} 步", + "onboarding.features.mockRename": "重命名", + "onboarding.features.mockSetColor": "设置颜色", + "onboarding.features.mockMoveToFolder": "移动到文件夹", + "onboarding.features.mockDelete": "删除", + "onboarding.features.mockAccount": "账号 {number}", + "onboarding.telemetry.title": "把灵魂卖给 ACCSHIFT!", + "onboarding.telemetry.intro": "Accshift 会统计少量匿名使用数据,这样我才知道该先修哪里。没有任何个人信息离开你的电脑,开不开都不影响使用,你随时可以在设置里改主意。", + "onboarding.telemetry.question": "成交吗?", + "onboarding.telemetry.gifAlt": "交易提议表情包", + "onboarding.telemetry.basic": "不!够了", + "onboarding.telemetry.basicDefault": "(默认)", + "onboarding.telemetry.basicHint": "只有匿名计数,仅此而已:没有画像,没有跟着你的标识符。", + "onboarding.telemetry.deal": "成交", + "onboarding.telemetry.dealHint": "额外增加一个仅保存在本机的 UUID 用于增强统计。依然不知道你是谁,但你帮了大忙。谢谢!", + "onboarding.telemetry.optOutNote": "无论你选哪个,之后都可以在设置 -> 隐私里改。:c", + "onboarding.telemetry.refuse": "不用了,全部关闭", + "onboarding.telemetry.refuseHint": "什么都不统计、不发送,连匿名计数也没有。", + "onboarding.telemetry.back": "返回", + "onboarding.persistFailed": "未能保存这个选择。请再试一次。", + "onboarding.persistLater": "下次启动时再问", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "以管理员身份运行 Steam", + "settings.showUsernames": "显示用户名", + "settings.showSteamLastLogin": "显示 Steam 上次登录", + "settings.showRiotLastLogin": "显示 Riot 上次登录", + "settings.showBattleNetLastLogin": "显示 Battle.net 上次登录", + "settings.showUbisoftLastLogin": "显示 Ubisoft 上次登录", + "settings.showNotesUnderCards": "在卡片下方显示备注", + "settings.expandedFolders": "展开所有文件夹", + "list.rootSection": "根目录", + "settings.steamLaunch": "切换", + "settings.steamInstallation": "安装", + "settings.steamApiAndData": "API 与数据", + "settings.launchOptions": "启动选项", + "settings.shutdownMode": "关闭方式", + "settings.shutdownModeGraceful": "正常退出", + "settings.shutdownModeForce": "强制结束", + "settings.autoDetected": "自动检测", + "settings.steamFolder": "Steam 文件夹", + "settings.steamFolderRegistry": "注册表", + "settings.riotClientPath": "Riot 客户端可执行文件", + "settings.battleNetPath": "Battle.net 可执行文件", + "settings.ubisoftPath": "Ubisoft Connect 文件夹", + "settings.steamWebApiKey": "Steam Web API 密钥", + "settings.getKey": "获取密钥", + "settings.pasteApiKey": "粘贴你的 Steam Web API 密钥", + "settings.saved": "设置已保存", + "settings.openApiKeyFailed": "无法打开 Steam API 密钥页面", + "settings.integrations": "集成", + "settings.deepLinks": "允许 accshift:// 链接", + "settings.deepLinksHint": "让其他应用替你切换账号:书签、Stream Deck、脚本。", + "settings.cliEnabled": "允许 accshift 命令行工具", + "settings.cliEnabledHint": "让命令行工具列出并切换账号。关闭后,所有 accshift 命令都会拒绝运行,包括 diag;应用本身不受影响。", + "settings.help": "打开 accshift 维基", + "settings.helpShort": "帮助", + "settings.openHelpFailed": "无法打开维基", + "settings.logs": "日志", + "settings.openLogs": "打开日志文件夹", + "settings.openLogsFailed": "无法打开日志文件夹", + "context.loading": "加载中...", + "context.noGamesFound": "未找到游戏", + "context.field.cut": "剪切", + "context.field.copy": "复制", + "context.field.paste": "粘贴", + "context.field.selectAll": "全选", + "context.menu.newFolder": "新建文件夹", + "context.menu.refresh": "刷新", + "context.menu.rename": "重命名", + "context.menu.copy": "复制...", + "context.menu.deleteFolder": "删除文件夹", + "context.menu.folderColor": "文件夹颜色", + "context.menu.moveToFolder": "移动到文件夹", + "context.menu.moveToRoot": "不放入文件夹", + "context.menu.editCardAndColor": "编辑卡片和颜色", + "context.menu.cardColor": "卡片颜色", + "context.menu.addNote": "添加备注", + "context.menu.editNote": "编辑备注", + "platform.rename": "重命名", + "platform.renameTitle": "重命名账号", + "platform.renamePlaceholder": "账号名称", + "platform.newAccountPending": "等待中...", + "platform.setupBusy": "另一个 accshift 操作正在运行。正在等待其完成。", + "platform.renameNewAccount": "为该账号命名", + "platform.copyLabelAccountId": "账号 ID", + "platform.forget": "移除", + "platform.forgetConfirmTitle": "移除「{display}」?", + "platform.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "platform.forgotAccount": "已移除 {display}", + "dialog.newFolderTitle": "新建文件夹", + "dialog.renameFolderTitle": "重命名文件夹", + "dialog.folderNamePlaceholder": "文件夹名称", + "dialog.cardNoteTitle": "卡片备注", + "dialog.cardNotePlaceholder": "写一条简短备注", + "card.noteAttached": "已附备注", + "card.clickAgainToSwitch": "再次点击以切换", + "card.extensionWarnings": "警告", + "card.extensionNote": "备注", + "card.cs2Section": "CS2", + "card.cs2Level": "等级 {level}", + "card.cs2CaseEarned": "本周箱子已获得", + "card.cs2CaseNotEarned": "本周箱子待获得", + "list.selectAccountPreview": "选择一个账号以预览", + "preview.noteLabel": "备注", + "preview.currentlyActive": "当前使用中", + "preview.switchAgain": "重新切换", + "preview.switchAccount": "切换账号", + "ban.community": "社区封禁", + "ban.vac.single": "{count} 次 VAC 封禁", + "ban.vac.multiple": "{count} 次 VAC 封禁", + "ban.game.single": "{count} 次游戏封禁", + "ban.game.multiple": "{count} 次游戏封禁", + "time.unknown": "未知", + "time.justNow": "刚刚", + "time.minuteAgo": "{count} 分钟前", + "time.minutesAgo": "{count} 分钟前", + "time.hourAgo": "{count} 小时前", + "time.hoursAgo": "{count} 小时前", + "time.dayAgo": "{count} 天前", + "time.daysAgo": "{count} 天前", + "time.neverConnected": "从未登录", + "time.compactWrapper": "({value})", + "toast.copied": "已复制{label}", + "toast.legacyConfigMigrated": "设置已迁移到新格式", + "toast.legacyConfigMigrationFailed": "旧设置迁移失败,请查看日志", + "toast.failedLoadSettingsPanel": "设置面板加载失败", + "toast.banChecking": "正在检查封禁...", + "toast.banCheckSummary.single": "封禁检查:{count} 个账号有封禁记录", + "toast.banCheckSummary.multiple": "封禁检查:{count} 个账号有封禁记录", + "toast.banCheckFailed": "封禁检查失败:{error}", + "toast.avatarRefreshComplete": "已完成 {count} 个账号的头像刷新", + "toast.banRefreshComplete": "已完成 {count} 个账号的封禁刷新", + "toast.banRefreshNoApiKey": "已跳过封禁刷新:请在设置中添加 Steam Web API 密钥", + "toast.refreshPartial": "已完成 {ok}/{count} 个账号的刷新", + "toast.noSteamAccountsFound": "未找到 Steam 账号。请先登录一次 Steam,然后刷新。", + "toast.accountsRefreshed.single": "已刷新 {count} 个账号", + "toast.accountsRefreshed.multiple": "已刷新 {count} 个账号", + "toast.steamFolderNotFound": "未找到 Steam 文件夹。请在设置 > Steam 文件夹中手动指定。", + "toast.steamElevated": "Steam 正在以管理员身份运行。请在设置 > Steam 中启用「以管理员身份运行 Steam」。", + "toast.noRiotProfilesFound": "还没有 Riot 档案。点击 + 添加一个。", + "toast.noBattleNetAccountsFound": "未找到 Battle.net 账号。请先登录一次 Battle.net,然后刷新。", + "toast.noUbisoftAccountsFound": "未找到 Ubisoft 账号。请先登录一次 Ubisoft Connect,然后刷新。", + "toast.deepLinkInvalid": "无法识别的 accshift:// 链接", + "toast.deepLinkDisabled": "accshift:// 链接已在设置中禁用", + "toast.deepLinkUnknownPlatform": "链接中的平台未知:{platform}", + "toast.deepLinkPlatformDisabled": "{platform} 已在设置中禁用", + "toast.deepLinkAccountNotFound": "没有与「{account}」匹配的 {platform} 账号", + "toast.deepLinkSwitched": "已切换到 {account}", + "dialog.deepLinkSwitchTitle": "切换账号?", + "dialog.deepLinkSwitchMessage": "有个 accshift:// 链接请求把你的 {platform} 账号切换为「{account}」。要继续吗?", + "dialog.deepLinkSwitchConfirm": "切换", + "steam.launchOnline": "在线切换", + "steam.launchInvisible": "隐身切换", + "steam.switchAndRunGame": "切换并运行 {game}", + "steam.switchAndRunPick": "切换并运行游戏...", + "steam.runGame": "运行 {game}", + "steam.runPick": "运行游戏...", + "steam.switchAndRunNoGames": "未找到游戏", + "steam.clearDefaultGame": "清除默认游戏", + "steam.copySteamId64": "复制 SteamID64", + "steam.copyFriendCode": "复制好友代码", + "steam.copyProfileUrl": "复制个人资料链接", + "steam.copyLabelUsername": "用户名", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "好友代码", + "steam.copyLabelProfileUrl": "个人资料链接", + "steam.openUserdataFolder": "打开 userdata 文件夹", + "steam.clearIntegratedBrowserCache": "重置浏览器会话", + "steam.clearIntegratedBrowserCacheConfirmTitle": "重置 Steam 浏览器会话?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "这会关闭 Steam 并清除商店和社区使用的内置浏览器缓存。", + "steam.clearedIntegratedBrowserCache": "Steam 浏览器会话已重置", + "steam.copySettingsFrom": "从以下账号复制设置", + "steam.copiedSettingsToCurrent": "已把 {game} 的设置复制到当前账号", + "steam.forget": "移除", + "steam.forgetConfirmTitle": "移除「{display}」?", + "steam.forgetConfirmMessage": "这会把该账号从本机的 Steam 账号列表中移除。", + "steam.forgotAccount": "已移除 {username}", + "steam.setupPendingLabel": "Steam 账号", + "steam.setupWaitingForClient": "等待 Steam...", + "steam.setupWaitingForLogin": "请登录你要添加的 Steam 账号。", + "steam.setupDetected": "已检测到账号", + "steam.setupConnected": "已连接", + "steam.setupReady": "Steam 账号已添加", + "steam.setupReadyWithProfile": "已添加 {profile}", + "steam.setupFailed": "Steam 设置失败", + "steam.setupFailedMessage": "Accshift 无法完成该 Steam 账号的添加。", + "riot.captureSession": "捕获当前会话", + "riot.recaptureSession": "重新捕获会话", + "riot.capturedSession": "已捕获 {profile} 的会话", + "riot.setupPendingLabel": "Riot 账号", + "riot.setupWaitingForClient": "等待 Riot 客户端...", + "riot.setupWaitingForLogin": "请登录你要添加的 Riot 账号。", + "riot.setupCapturing": "正在保存 Riot 会话...", + "riot.setupReady": "Riot 账号已添加", + "riot.setupReadyWithProfile": "已添加 {profile}", + "riot.setupFailed": "Riot 设置失败", + "riot.setupFailedMessage": "Accshift 无法完成该 Riot 账号的添加。", + "riot.setupStaySignedIn": "请保持「保持登录」为开启状态。", + "riot.setupDetected": "已检测到账号", + "riot.setupConnected": "已连接", + "riot.forget": "移除账号", + "riot.forgetConfirmTitle": "移除「{display}」?", + "riot.forgetConfirmMessage": "这会从 Accshift 中删除本地 Riot 档案及其保存的会话。", + "riot.forgotProfile": "已移除 {profile}", + "battlenet.copyUsername": "复制用户名", + "battlenet.copyLabelUsername": "用户名", + "battlenet.copyEmail": "复制邮箱", + "battlenet.copyLabelEmail": "邮箱", + "battlenet.forget": "移除", + "battlenet.forgetConfirmTitle": "移除「{display}」?", + "battlenet.forgetConfirmMessage": "这会把该账号从本机的 Battle.net 账号列表中移除。", + "battlenet.forgotAccount": "已移除 {email}", + "battlenet.setupPendingLabel": "Battle.net 账号", + "battlenet.setupWaitingForClient": "等待 Battle.net...", + "battlenet.setupWaitingForLogin": "请登录你要添加的 Battle.net 账号。", + "battlenet.setupKeepMeLoggedIn": "尽量保持「保持登录状态」为开启。", + "battlenet.setupDetected": "已检测到账号", + "battlenet.setupConnected": "已连接", + "battlenet.setupReady": "Battle.net 账号已添加", + "battlenet.setupReadyWithProfile": "已添加 {profile}", + "battlenet.setupFailed": "Battle.net 设置失败", + "battlenet.setupFailedMessage": "Accshift 无法完成该 Battle.net 账号的添加。", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "移除", + "ubisoft.forgetConfirmTitle": "移除「{display}」?", + "ubisoft.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "ubisoft.forgotAccount": "已移除 {display}", + "ubisoft.setupPendingLabel": "Ubisoft 账号", + "ubisoft.setupWaitingForClient": "等待 Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "请登录你要添加的 Ubisoft 账号。", + "ubisoft.setupKeepMeLoggedIn": "请保持「记住我」为开启状态。", + "ubisoft.setupDetected": "已检测到账号", + "ubisoft.setupConnected": "已连接", + "ubisoft.setupReady": "Ubisoft 账号已添加", + "ubisoft.setupReadyWithProfile": "已添加 {profile}", + "ubisoft.setupFailed": "Ubisoft 设置失败", + "ubisoft.setupFailedMessage": "Accshift 无法完成该 Ubisoft 账号的添加。", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "未找到 Roblox 账号。请使用快速登录添加账号,或粘贴一个 cookie。", + "roblox.noAccountsHint": "点击 + 通过快速登录添加 Roblox 账号。", + "roblox.sessionExpired": "会话已过期", + "roblox.sessionExpiredTooltip": "该账号已登出。切换前请重新添加。", + "roblox.sessionExpiredSwitchError": "会话已过期。请重新添加该账号后再切换。备注和设置会保留。", + "roblox.cookiePasteTitle": "通过 cookie 添加账号", + "roblox.cookiePasteHint": "从浏览器粘贴 .ROBLOSECURITY cookie 即可直接添加账号。", + "settings.showRobloxLastLogin": "显示上次登录", + "settings.robloxHealthCheck": "会话检查", + "settings.robloxHealthCheckHint": "标记 Roblox 会话已过期的账号,每次启动发送一次网络请求。", + "roblox.copyLabelUsername": "用户名", + "roblox.copyLabelUserId": "用户 ID", + "roblox.forget": "移除", + "roblox.forgetConfirmTitle": "移除「{display}」?", + "roblox.forgetConfirmMessage": "这会从本机删除该账号及其保存的 cookie。", + "roblox.forgotAccount": "已移除 {display}", + "roblox.setupPendingLabel": "Roblox 账号", + "roblox.setupWaitingForClient": "正在连接 Roblox...", + "roblox.setupWaitingForLogin": "在另一台设备上打开 Roblox 应用并输入下面的代码。", + "roblox.setupQuickLoginCode": "快速登录代码", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "复制代码", + "roblox.setupDetected": "已检测到账号", + "roblox.setupConnected": "已连接", + "roblox.setupReady": "Roblox 账号已添加", + "roblox.setupReadyWithProfile": "已添加 {profile}", + "roblox.setupFailed": "Roblox 设置失败", + "roblox.setupFailedMessage": "Accshift 无法完成该 Roblox 账号的添加。", + "settings.epic": "Epic Games", + "settings.epicPath": "Epic Games Launcher 可执行文件", + "settings.showEpicLastLogin": "显示上次登录", + "toast.noEpicAccountsFound": "未找到 Epic Games 账号。请先登录一次 Epic Games Launcher,然后刷新。", + "epic.noAccountsHint": "点击 + 添加 Epic Games 账号。", + "epic.copyLabelAccountId": "账号 ID", + "epic.forget": "移除", + "epic.forgetConfirmTitle": "移除「{display}」?", + "epic.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "epic.forgotAccount": "已移除 {display}", + "epic.setupPendingLabel": "Epic Games 账号", + "epic.setupWaitingForClient": "等待 Epic Games Launcher...", + "epic.setupWaitingForLogin": "请登录你要添加的 Epic Games 账号。", + "epic.setupKeepMeLoggedIn": "请保持「记住我」为开启状态。", + "epic.setupDetected": "已检测到账号", + "epic.setupConnected": "已连接", + "epic.setupReady": "Epic Games 账号已添加", + "epic.setupReadyWithProfile": "已添加 {profile}", + "epic.setupFailed": "Epic Games 设置失败", + "epic.setupFailedMessage": "Accshift 无法完成该 Epic Games 账号的添加。", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "GOG Galaxy 可执行文件", + "settings.showGogLastLogin": "显示上次登录", + "toast.noGogAccountsFound": "未找到 GOG Galaxy 账号。请先登录一次 GOG Galaxy,然后刷新。", + "gog.noAccountsHint": "点击 + 添加 GOG Galaxy 账号。", + "gog.copyLabelAccountId": "账号 ID", + "gog.forget": "移除", + "gog.forgetConfirmTitle": "移除「{display}」?", + "gog.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "gog.forgotAccount": "已移除 {display}", + "gog.setupPendingLabel": "GOG Galaxy 账号", + "gog.setupWaitingForClient": "等待 GOG Galaxy...", + "gog.setupWaitingForLogin": "请登录你要添加的 GOG Galaxy 账号。", + "gog.setupKeepMeLoggedIn": "请保持「保持登录」为开启状态。", + "gog.setupDetected": "已检测到账号", + "gog.setupConnected": "已连接", + "gog.setupReady": "GOG Galaxy 账号已添加", + "gog.setupReadyWithProfile": "已添加 {profile}", + "gog.setupFailed": "GOG Galaxy 设置失败", + "gog.setupFailedMessage": "Accshift 无法完成该 GOG Galaxy 账号的添加。", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Jagex Launcher 可执行文件", + "settings.showJagexLastLogin": "显示上次登录", + "toast.noJagexAccountsFound": "还没有保存的 Jagex 账号。点击 + 通过 Jagex Launcher 添加一个。", + "jagex.noAccountsHint": "点击 + 添加 Jagex 账号。", + "jagex.copyLabelAccountId": "账号 ID", + "jagex.forget": "移除", + "jagex.forgetConfirmTitle": "移除「{display}」?", + "jagex.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "jagex.forgotAccount": "已移除 {display}", + "jagex.setupPendingLabel": "Jagex 账号", + "jagex.setupWaitingForClient": "等待 Jagex Launcher...", + "jagex.setupWaitingForLogin": "请登录你要添加的 Jagex 账号。", + "jagex.setupKeepMeLoggedIn": "请保持「记住我」为开启状态。", + "jagex.setupDetected": "已检测到账号", + "jagex.setupConnected": "已连接", + "jagex.setupReady": "Jagex 账号已添加", + "jagex.setupReadyWithProfile": "已添加 {profile}", + "jagex.setupFailed": "Jagex 设置失败", + "jagex.setupFailedMessage": "Accshift 无法完成该 Jagex 账号的添加。", + "settings.discord": "Discord", + "settings.discordPath": "Discord 可执行文件", + "settings.showDiscordLastLogin": "显示上次登录", + "toast.noDiscordAccountsFound": "未找到 Discord 账号。请添加一个 Discord 账号,然后刷新。", + "discord.noAccountsHint": "点击 + 添加 Discord 账号。", + "discord.copyLabelAccountId": "账号 ID", + "discord.forget": "移除", + "discord.forgetConfirmTitle": "移除「{display}」?", + "discord.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "discord.forgotAccount": "已移除 {display}", + "discord.setupPendingLabel": "Discord 账号", + "discord.setupWaitingForClient": "等待 Discord...", + "discord.setupWaitingForLogin": "请登录你要添加的 Discord 账号。", + "discord.setupKeepMeLoggedIn": "保持登录状态才能保留该账号。", + "discord.setupDetected": "已检测到账号", + "discord.setupConnected": "已连接", + "discord.setupReady": "Discord 账号已添加", + "discord.setupReadyWithProfile": "已添加 {profile}", + "discord.setupFailed": "Discord 设置失败", + "discord.setupFailedMessage": "Accshift 无法完成该 Discord 账号的添加。", + "color.none": "默认", + "color.blue": "蓝色", + "color.cyan": "青色", + "color.green": "绿色", + "color.lime": "青柠色", + "color.yellow": "黄色", + "color.orange": "橙色", + "color.red": "红色", + "color.pink": "粉色", + "color.violet": "紫色", + "color.gray": "灰色", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "深色", + "theme.light": "浅色", + "theme.midnight": "午夜", + "theme.glassDark": "深色玻璃", + "theme.glassLight": "浅色玻璃", + "theme.liquidGlass": "液态玻璃", + "theme.custom": "自定义", + "settings.themeImport": "导入", + "settings.themeExport": "导出", + "settings.themeDelete": "删除主题", + "settings.themeImported": "主题已导入", + "settings.themeDeleted": "主题已删除", + "settings.themeExportCopied": "主题 JSON 已复制到剪贴板", + "settings.themeInvalidJson": "主题 JSON 无效", + "settings.themeCustomize": "自定义", + "settings.themeEdit": "编辑", + "settings.themeCopyName": "{name} 副本", + "settings.themeSaved": "主题已保存", + "settings.themeMigrated": "主题已导入并升级到当前格式", + "settings.themeInvalidId": "主题标识符缺失或无效", + "settings.themeInvalidName": "主题没有名称", + "settings.themeUnsupportedVersion": "该主题需要更新版本的 accshift", + "themeGroup.surface": "表面", + "themeGroup.text": "文字", + "themeGroup.semantic": "状态颜色", + "themeGroup.shape": "形状与密度", + "themeGroup.typography": "字体排印", + "themeGroup.motion": "动效", + "themeKind.rgbTriplet": "RGB 三元组", + "themeKind.hexColor": "十六进制颜色", + "themeKind.color": "颜色", + "themeKind.length": "长度", + "themeKind.shadow": "阴影", + "themeKind.choice": "选项", + "themeKind.fontStack": "字体", + "themeKind.signedLength": "字距", + "themeKind.gradient": "渐变", + "themeKind.number": "数值", + "themeDensity.compact": "紧凑", + "themeDensity.cozy": "适中", + "themeDensity.comfortable": "宽松", + "themeAvatarShape.circle": "圆形", + "themeAvatarShape.rounded": "圆角", + "themeAvatarShape.square": "方形", + "themeSmoothing.auto": "平滑", + "themeSmoothing.none": "像素", + "themeToken.bgRgb": "窗口背景", + "themeToken.bgCard": "卡片背景", + "themeToken.bgCardHover": "卡片悬停背景", + "themeToken.bgMuted": "弱化背景", + "themeToken.bgElevated": "浮起背景", + "themeToken.border": "边框", + "themeToken.fg": "主要文字", + "themeToken.fgMuted": "次要文字", + "themeToken.fgSubtle": "浅色文字", + "themeToken.afkText": "离开标记", + "themeToken.accent": "强调色", + "themeToken.accentFg": "强调色上的文字", + "themeToken.success": "成功", + "themeToken.warning": "警告", + "themeToken.danger": "危险", + "themeToken.radiusSm": "小圆角", + "themeToken.radiusMd": "中圆角", + "themeToken.radiusLg": "大圆角", + "themeToken.elevationLow": "浅阴影", + "themeToken.elevationMedium": "中阴影", + "themeToken.elevationHigh": "深阴影", + "themeToken.density": "密度", + "themeToken.fontUi": "界面字体", + "themeToken.bgImage": "窗口渐变", + "themeToken.cardBgImage": "表面渐变", + "themeToken.borderWidth": "边框粗细", + "themeToken.borderStyle": "边框样式", + "themeToken.avatarShape": "头像形状", + "themeToken.focusRing": "焦点边框", + "themeToken.overlayBlur": "背景模糊", + "themeToken.fontDisplay": "标题字体", + "themeToken.lineHeight": "行高", + "themeToken.letterSpacing": "字间距", + "themeToken.labelCase": "标签大小写", + "themeToken.fontSmoothing": "文字平滑", + "themeToken.motionScale": "动画速度", + "themeEditor.title": "主题编辑器", + "themeEditor.name": "名称", + "themeEditor.author": "作者", + "themeEditor.version": "版本", + "themeEditor.base": "继承自", + "themeEditor.colorScheme": "配色模式", + "themeEditor.glass": "半透明表面", + "themeEditor.inherited": "继承", + "themeEditor.builtInBase": "内置主题", + "themeEditor.reset": "恢复为继承的值", + "themeEditor.issues": "检查", + "themeEditor.noIssues": "没有发现问题", + "themeEditor.fixErrorsFirst": "请先修正被拒绝的值再保存", + "themeEditor.save": "保存", + "themeEditor.saveFailed": "未能保存主题。请再试一次。", + "themeEditor.export": "复制", + "themeEditor.issueInvalidValue": "{token}:不是有效的{kind},例如 {example}", + "themeEditor.issueUnknownToken": "{token} 不是主题值,将被忽略", + "themeEditor.issueMissingToken": "{token} 未设置,使用内置值", + "themeEditor.issueUnknownBase": "基础主题 {base} 不存在,使用内置值", + "themeEditor.issueContrast": "{token} 在 {against} 上的对比度为 {ratio}:1,低于 {target}:1 的目标", + "themeEditor.beta": "测试版", + "themeEditor.betaNote": "主题格式仍在变动:今天写的主题在更新后可能需要调整。", + "themeEditor.customCss": "自定义 CSS", + "themeEditor.customCssHelp": "叠加在主题之上,用于上面的取值无法覆盖的部分。不允许 @import 和 url():主题不会向外请求任何内容。", + "themeEditor.issueUnsafeCss": "主题中不允许 {construct},该 CSS 不会生效", + "themeEditor.issueCssTooLong": "CSS 过长:{length} 个字符,上限为 {limit}", + "bulkEdit.title": "批量编辑", + "bulkEdit.selectAccounts": "选择账号", + "bulkEdit.selectAll": "全选", + "bulkEdit.deselectAll": "取消全选", + "bulkEdit.next": "下一步", + "bulkEdit.apply": "应用", + "bulkEdit.settings": "设置", + "bulkEdit.newsPopup": "游戏新闻弹窗", + "bulkEdit.doNotDisturb": "请勿打扰", + "bulkEdit.launchOptions": "各游戏启动选项", + "bulkEdit.unchanged": "保持不变", + "bulkEdit.addLaunchOption": "添加", + "bulkEdit.selectGame": "选择游戏...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "当前使用中的账号也被选中。更改将在下次 Steam 重启后生效。", + "bulkEdit.toastSuccess": "批量编辑:{count} 个成功", + "bulkEdit.toastPartial": "批量编辑:{succeeded} 个成功,{failed} 个失败", + "bulkEdit.toastFailed": "批量编辑失败", + "bulkEdit.noAccountsSelected": "未选择账号", + "bulkEdit.selected": "已选 {count} 个", + "bulkEdit.loadingGames": "正在加载游戏...", + "bulkEdit.copyUrls": "复制链接", + "bulkEdit.urlsCopied": "已复制 {count} 个个人资料链接", + "bulkEdit.cardColor": "卡片颜色", + "bulkEdit.colorApplied": "已为 {count} 张卡片应用颜色", + "bulkEdit.colorCleared": "已清除 {count} 张卡片的颜色", + "common.retry": "重试", + "app.searchNoResults": "没有「{query}」的搜索结果", + "app.folderEmpty": "该文件夹是空的", + "app.loadErrorGeneric": "账号加载失败。详情见控制台。", + "toast.dismiss": "关闭", + "toast.switchFailed": "账号切换失败。详情见控制台。", + "toast.addAccountFailed": "无法添加该账号。详情见应用日志。", + "toast.addAccountClientMissing": "{platform} 似乎没有安装。请先安装,或在设置中指定其位置。", + "toast.banCheckFailedGeneric": "封禁检查失败。详情见控制台。", + "toast.renameAccountFailed": "重命名失败:{error}", + "toast.copyFailed": "无法复制到剪贴板", + "toast.refreshFailed": "刷新失败。详情见控制台。", + "dialog.deleteFolderTitle": "删除「{name}」?", + "dialog.deleteFolderMessage": "子文件夹会一并删除。里面的账号会移回上一级,不会被删除。", + "personas.deleteConfirm": "再点一次即可删除", + "personas.discardConfirm": "再点一次即可放弃", + "personas.switchConfirmTitle": "切换身份", + "personas.switchConfirmMessage": "把 {count} 个平台切换到「{name}」?每个客户端都会关闭并重新启动。", + "personas.switchConfirmAction": "切换", + "personas.deleteTitle": "删除身份", + "personas.deleteMessage": "删除「{name}」?账号本身不会受影响。", + "context.submenuError": "无法加载该菜单", + "settings.apiKeySaveFailed": "无法保存 Steam API 密钥", + "settings.apiKeyInvalidHint": "密钥未保存。请检查后重试。", + "settings.apiKeyCleared": "Steam API 密钥已清除", + "settings.apiKeyClearFailed": "无法清除 Steam API 密钥", + "settings.clearApiKey": "清除密钥", + "settings.cs2Bridge": "CS2 数据桥接", + "settings.cs2BridgeLink": "桥接链接", + "settings.cs2BridgeLinkPlaceholder": "https://your-server/api/bridge/accshift/", + "settings.cs2BridgeTest": "测试", + "settings.cs2BridgeStatusOk": "已连接 · {count} 个账号 · {ms} 毫秒", + "settings.cs2BridgeStatusFail": "连接失败", + "settings.cs2BridgeStatusTesting": "测试中...", + "settings.cs2BridgeStatusUnknown": "未测试", + "settings.cs2BridgeToken": "API 令牌", + "settings.cs2BridgeTokenOptional": "可选,用于自建服务器", + "settings.cs2BridgeTokenPlaceholder": "如果服务器需要,请填写 Bearer 令牌", + "settings.cs2BridgeSaveFailed": "无法保存 CS2 桥接设置", + "settings.cs2BridgeClearToken": "清除令牌", + "settings.pathSaveFailed": "无法保存 {platform} 路径", + "settings.pathInvalidHint": "该路径无法保存。请检查后重试。", + "settings.pathSteamNeverSignedIn": "已找到 Steam,请先登录一次,让它生成登录记录", + "settings.pathNotSteamFolder": "该文件夹看起来不是 Steam 安装目录", + "settings.pathNotADirectory": "该路径必须是已存在的文件夹", + "settings.pinSaved": "PIN 已保存", + "settings.pinTakesEffectNextLaunch": "下次启动应用时生效", + "settings.pinRequiredAfterInactivity": "无操作后以及应用启动时需要输入", + "settings.pinScreenLockOnly": "PIN 会锁定此窗口和账户切换,不会保护磁盘上的任何数据。", + "settings.unitSeconds": "秒", + "settings.unitDays": "天", + "settings.telemetryLoadFailed": "无法加载遥测设置", + "palette.placeholder": "输入命令或账号名称...", + "palette.noResults": "没有匹配的命令", + "palette.sectionAccounts": "账号", + "palette.sectionActions": "操作", + "palette.sectionNavigation": "导航", + "palette.openTab": "前往 {platform}", + "palette.openFolder": "打开文件夹「{name}」", + "palette.backToParent": "返回上级文件夹", + "palette.zoomReset": "重置缩放" +} diff --git a/crates/accshift-app/src/accounts/avatars.rs b/crates/accshift-app/src/accounts/avatars.rs new file mode 100644 index 00000000..a60b3e34 --- /dev/null +++ b/crates/accshift-app/src/accounts/avatars.rs @@ -0,0 +1,676 @@ +//! Per account avatar state, ported from `src/lib/shared/useAvatarLoader.svelte.ts`, +//! and the avatar images behind it. +//! +//! [`AvatarStates`] keeps the TS coalescing: updates queue in a pending map +//! that [`AvatarStates::flush`] commits once per frame (the +//! `requestAnimationFrame` flush), and readers inside this module see the +//! pending value first, like `readState`. +//! +//! [`AvatarImages`] is the part the browser did for free: turning an avatar +//! URL into something to draw. It yields an [`AvatarImage`] (a file path or +//! downloaded bytes, plus a stable key); decoding and texture upload belong +//! to the egui side. HTTP runs on the job pool through [`AvatarHttp`]; the +//! mock's avatars are local files and need no fetch. + +use super::profile_cache::UrlPolicy; +use crate::platforms::{CachedPlatformProfile, PlatformAccount, PlatformProfileInfo}; +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +/// `BATCH_SIZE`: per account profile fetches run this many at a time. +pub const BATCH_SIZE: usize = 15; + +/// `AvatarState`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AvatarState { + pub url: Option, + pub loading: bool, + pub refreshing: bool, +} + +/// `Partial` for [`AvatarStates::update_state`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AvatarPatch { + pub url: Option>, + pub loading: Option, + pub refreshing: Option, +} + +/// What seeding reads from the adapter. +pub trait ProfileSource { + /// `adapter.getCachedProfile?.(id)`: `None` when the platform has no + /// cache or no entry. + fn cached_profile(&self, account_id: &str) -> Option; + /// `adapter.getProfileInfo` exists. + fn has_profile_info(&self) -> bool; +} + +/// `createAvatarLoader`: the state half. +#[derive(Debug, Clone, Default)] +pub struct AvatarStates { + states: BTreeMap, + pending: Option>, + /// Bumped by each commit that changed something. + version: u64, +} + +impl AvatarStates { + pub fn new() -> Self { + Self::default() + } + + /// `states`: the committed map, as the UI reads it. + pub fn states(&self) -> &BTreeMap { + &self.states + } + + pub fn get(&self, account_id: &str) -> Option<&AvatarState> { + self.states.get(account_id) + } + + pub fn version(&self) -> u64 { + self.version + } + + /// Updates are waiting for [`AvatarStates::flush`]. + pub fn has_pending(&self) -> bool { + self.pending.is_some() + } + + /// `readState`: pending first, then committed. + pub fn read(&self, account_id: &str) -> Option<&AvatarState> { + self.pending + .as_ref() + .and_then(|pending| pending.get(account_id)) + .or_else(|| self.states.get(account_id)) + } + + /// `flushPending`: commits the queued updates. True when it changed the + /// committed map. + pub fn flush(&mut self) -> bool { + let Some(updates) = self.pending.take() else { + return false; + }; + if updates.is_empty() { + return false; + } + self.states.extend(updates); + self.version += 1; + true + } + + fn queue(&mut self, account_id: &str, state: AvatarState) { + self.pending + .get_or_insert_with(BTreeMap::new) + .insert(account_id.to_string(), state); + } + + /// `updateState`. + pub fn update_state(&mut self, account_id: &str, patch: AvatarPatch) { + let previous = self.read(account_id).cloned(); + let base = previous.clone().unwrap_or_default(); + let next = AvatarState { + url: patch.url.unwrap_or(base.url), + loading: patch.loading.unwrap_or(base.loading), + refreshing: patch.refreshing.unwrap_or(base.refreshing), + }; + if previous.as_ref() == Some(&next) { + return; + } + self.queue(account_id, next); + } + + /// `mergeStates`. + pub fn merge(&mut self, updates: BTreeMap) { + for (account_id, next) in updates { + if self.read(&account_id) == Some(&next) { + continue; + } + self.queue(&account_id, next); + } + } + + /// `seedForAccounts`: the cached avatar of each account, or a loading + /// placeholder. Returns the accounts whose profile needs a fetch. + pub fn seed_for_accounts( + &mut self, + accounts: &[PlatformAccount], + force_refresh: bool, + source: &S, + ) -> Vec { + let mut needs_refresh = Vec::new(); + let mut updates = BTreeMap::new(); + for account in accounts { + let existing = self.read(&account.id).is_some(); + if let Some(cached) = source.cached_profile(&account.id) { + let should_refresh = cached.expired || force_refresh; + updates.insert( + account.id.clone(), + AvatarState { + url: cached.url, + loading: false, + refreshing: should_refresh, + }, + ); + if should_refresh { + needs_refresh.push(account.clone()); + } + } else if source.has_profile_info() { + if !existing { + updates.insert( + account.id.clone(), + AvatarState { + url: None, + loading: true, + refreshing: false, + }, + ); + } + needs_refresh.push(account.clone()); + } + } + self.merge(updates); + needs_refresh + } + + fn resolved_state( + &self, + account_id: &str, + profile: Option<&PlatformProfileInfo>, + ) -> AvatarState { + match profile { + Some(profile) => AvatarState { + url: profile.avatar_url.clone(), + loading: profile.avatar_loading, + refreshing: false, + }, + // No data: keep whatever avatar is on screen, stop the spinners. + None => AvatarState { + url: self + .read(account_id) + .and_then(|state| state.url.clone()) + .filter(|url| !url.is_empty()), + loading: false, + refreshing: false, + }, + } + } + + /// `applyProfileUpdate`: one resolved profile, and its display name + /// written into `accounts`. Returns whether `accounts` changed. + pub fn apply_profile_update( + &mut self, + account: &PlatformAccount, + profile: Option<&PlatformProfileInfo>, + accounts: &mut [PlatformAccount], + ) -> bool { + let state = self.resolved_state(&account.id, profile); + self.update_state( + &account.id, + AvatarPatch { + url: Some(state.url), + loading: Some(state.loading), + refreshing: Some(false), + }, + ); + match profile.and_then(|profile| profile.display_name.as_deref()) { + Some(name) if !name.is_empty() && name != account.display_name => { + update_account_display_name(accounts, &account.id, name) + } + _ => false, + } + } + + /// `applyBatchProfileUpdates`: every resolved profile in one merge. + /// Returns whether `accounts` changed. + pub fn apply_batch_profile_updates( + &mut self, + targets: &[PlatformAccount], + profiles: &BTreeMap>, + accounts: &mut [PlatformAccount], + ) -> bool { + let mut updates = BTreeMap::new(); + let mut names = Vec::new(); + for account in targets { + let profile = profiles.get(&account.id).and_then(Option::as_ref); + updates.insert( + account.id.clone(), + self.resolved_state(&account.id, profile), + ); + if let Some(name) = profile.and_then(|profile| profile.display_name.as_deref()) { + if !name.is_empty() && name != account.display_name { + names.push((account.id.clone(), name.to_string())); + } + } + } + self.merge(updates); + let mut changed = false; + for (account_id, name) in names { + changed |= update_account_display_name(accounts, &account_id, &name); + } + changed + } + + /// `removeAvatar`. + pub fn remove(&mut self, account_id: &str) { + if let Some(pending) = &mut self.pending { + pending.remove(account_id); + } + if self.states.remove(account_id).is_some() { + self.version += 1; + } + } + + /// `clear`. + pub fn clear(&mut self) { + self.pending = None; + if !self.states.is_empty() { + self.states.clear(); + self.version += 1; + } + } + + /// Every account id with a committed or pending state. + pub fn ids(&self) -> Vec { + let mut ids: Vec = self.states.keys().cloned().collect(); + if let Some(pending) = &self.pending { + ids.extend( + pending + .keys() + .filter(|id| !self.states.contains_key(*id)) + .cloned(), + ); + } + ids + } +} + +/// `updateAccountDisplayName`. +pub fn update_account_display_name( + accounts: &mut [PlatformAccount], + account_id: &str, + display_name: &str, +) -> bool { + match accounts.iter_mut().find(|account| account.id == account_id) { + Some(account) if account.display_name != display_name => { + account.display_name = display_name.to_string(); + true + } + _ => false, + } +} + +// ------------------------------------------------------------ images + +/// An avatar ready for the UI to decode. `key` is stable for the same +/// source, so the UI can key its texture cache on it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AvatarImage { + /// A local file (the mock's SVGs). + File { key: String, path: PathBuf }, + /// Downloaded bytes (PNG or JPEG from Steam and Roblox CDNs). + Bytes { key: String, bytes: Arc<[u8]> }, +} + +impl AvatarImage { + pub fn key(&self) -> &str { + match self { + AvatarImage::File { key, .. } | AvatarImage::Bytes { key, .. } => key, + } + } +} + +/// Where an avatar URL points. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AvatarSource { + Http(String), + File(PathBuf), + /// A `data:image/...;base64,` URL (a persona's cover): its bytes. + Data(Vec), + /// None of these: the card shows its initials. + Unsupported, +} + +/// Classifies an avatar URL under `policy`. +pub fn avatar_source(url: &str, policy: UrlPolicy) -> AvatarSource { + let trimmed = crate::js::trim(url); + if crate::platforms::steam::is_safe_http_url(trimmed) { + return AvatarSource::Http(trimmed.to_string()); + } + if policy == UrlPolicy::HttpOrFile && UrlPolicy::is_local_file(trimmed) { + let path = trimmed.strip_prefix("file://").unwrap_or(trimmed); + return AvatarSource::File(PathBuf::from(path)); + } + if let Some(bytes) = data_url_bytes(trimmed) { + return AvatarSource::Data(bytes); + } + AvatarSource::Unsupported +} + +/// The payload of a base64 `data:image/` URL. +fn data_url_bytes(url: &str) -> Option> { + use base64::Engine as _; + let rest = url.strip_prefix("data:image/")?; + let (meta, payload) = rest.split_once(',')?; + if !meta.ends_with(";base64") { + return None; + } + base64::engine::general_purpose::STANDARD + .decode(payload.trim()) + .ok() + .filter(|bytes| !bytes.is_empty()) +} + +/// A short cache key for a data URL: its length and a hash, so a 300 kB +/// cover is not copied into every texture key. +fn data_url_key(url: &str) -> String { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + url.hash(&mut hasher); + format!("data:{}:{:016x}", url.len(), hasher.finish()) +} + +/// What [`AvatarImages::request`] answers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AvatarFetch { + Ready(AvatarImage), + /// A download is running. + Waiting, + /// The download failed or the URL is not drawable: show the initials. + Failed, + /// Start [`fetch_avatar_image`] for this URL on the job pool and hand the + /// result to [`AvatarImages::finish`]. + Start(String), +} + +#[derive(Debug, Clone)] +enum ImageEntry { + Loading, + Ready(AvatarImage), + Failed, +} + +/// The avatar images of the session, by URL. +#[derive(Debug, Clone)] +pub struct AvatarImages { + policy: UrlPolicy, + entries: HashMap, +} + +impl AvatarImages { + pub fn new(policy: UrlPolicy) -> Self { + Self { + policy, + entries: HashMap::new(), + } + } + + /// The image for `url`, or what to do to get it. A local file is ready at + /// once; an HTTP URL is fetched once, then served from memory. + pub fn request(&mut self, url: &str) -> AvatarFetch { + if let Some(entry) = self.entries.get(url) { + return match entry { + ImageEntry::Loading => AvatarFetch::Waiting, + ImageEntry::Ready(image) => AvatarFetch::Ready(image.clone()), + ImageEntry::Failed => AvatarFetch::Failed, + }; + } + match avatar_source(url, self.policy) { + AvatarSource::File(path) => { + let image = AvatarImage::File { + key: url.to_string(), + path, + }; + self.entries + .insert(url.to_string(), ImageEntry::Ready(image.clone())); + AvatarFetch::Ready(image) + } + AvatarSource::Http(_) => { + self.entries.insert(url.to_string(), ImageEntry::Loading); + AvatarFetch::Start(url.to_string()) + } + AvatarSource::Data(bytes) => { + let image = AvatarImage::Bytes { + key: data_url_key(url), + bytes: bytes.into(), + }; + self.entries + .insert(url.to_string(), ImageEntry::Ready(image.clone())); + AvatarFetch::Ready(image) + } + AvatarSource::Unsupported => { + self.entries.insert(url.to_string(), ImageEntry::Failed); + AvatarFetch::Failed + } + } + } + + /// Stores a download's result. + pub fn finish(&mut self, url: &str, result: Result) { + let entry = match result { + Ok(image) => ImageEntry::Ready(image), + Err(_) => ImageEntry::Failed, + }; + self.entries.insert(url.to_string(), entry); + } + + /// Drops `url`, so the next request fetches it again. + pub fn forget(&mut self, url: &str) { + self.entries.remove(url); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +/// The HTTP GET an avatar download needs; a trait so tests run offline. +pub trait AvatarHttp: Send + Sync { + fn get(&self, url: &str) -> Result, String>; +} + +/// The largest avatar accepted. Steam's full avatars are 184 px JPEGs. +pub const MAX_AVATAR_BYTES: usize = 4 * 1024 * 1024; + +/// [`AvatarHttp`] over a blocking reqwest client. +#[derive(Debug, Clone)] +pub struct HttpAvatarClient { + client: reqwest::blocking::Client, +} + +impl HttpAvatarClient { + pub fn new() -> Result { + accshift_core::http::blocking_client_builder() + .timeout(Duration::from_secs(15)) + .build() + .map(|client| Self { client }) + .map_err(|error| error.to_string()) + } +} + +impl AvatarHttp for HttpAvatarClient { + fn get(&self, url: &str) -> Result, String> { + let response = self + .client + .get(url) + .send() + .and_then(|response| response.error_for_status()) + .map_err(|error| error.to_string())?; + let bytes = response.bytes().map_err(|error| error.to_string())?; + if bytes.len() > MAX_AVATAR_BYTES { + return Err(format!("avatar too large: {} bytes", bytes.len())); + } + Ok(bytes.to_vec()) + } +} + +/// The job half of [`AvatarFetch::Start`]: downloads `url`. +pub fn fetch_avatar_image(url: &str, http: &dyn AvatarHttp) -> Result { + let bytes = http.get(url)?; + if bytes.is_empty() { + return Err("empty avatar".to_string()); + } + Ok(AvatarImage::Bytes { + key: url.to_string(), + bytes: Arc::from(bytes), + }) +} + +/// [`fetch_avatar_image`] through `cache`: the stored copy when there is +/// one, else the download, stored for the next launch. `http` is only +/// called on a miss. +pub fn fetch_avatar_image_cached( + url: &str, + http: impl FnOnce() -> Result, String>, + cache: Option<&AvatarDiskCache>, +) -> Result { + if let Some(bytes) = cache.and_then(|cache| cache.read(url)) { + return Ok(AvatarImage::Bytes { + key: url.to_string(), + bytes: Arc::from(bytes), + }); + } + let image = fetch_avatar_image(url, http()?.as_ref())?; + if let (Some(cache), AvatarImage::Bytes { bytes, .. }) = (cache, &image) { + // A failed write only costs the next launch a download. + let _ = cache.write(url, bytes); + } + Ok(image) +} + +/// Files [`AvatarDiskCache`] keeps; a write past it drops the oldest. +pub const AVATAR_CACHE_MAX_FILES: usize = 512; + +/// How long a file from a host outside [`AVATAR_CACHE_STABLE_HOSTS`] serves +/// before the URL is downloaded again. +pub const AVATAR_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +/// CDNs whose avatar URLs name the picture by its hash (Steam's +/// `_full.jpg`, Roblox's `AvatarHeadshot-`): a new picture is a +/// new URL, so a stored file never goes stale. +pub const AVATAR_CACHE_STABLE_HOSTS: &[&str] = + &["steamstatic.com", "steamcdn-a.akamaihd.net", "rbxcdn.com"]; + +/// Downloaded avatars on disk, so a launch draws them without the network +/// (the Tauri app had WebView2's HTTP cache for this). One file per URL, +/// named by the SHA-256 of the URL; it starts with the URL and a newline, so +/// a read checks it has the right one. Only bytes that open like an image +/// (JPEG, PNG, GIF, WebP) are stored. +#[derive(Debug, Clone)] +pub struct AvatarDiskCache { + dir: PathBuf, + max_files: usize, +} + +impl AvatarDiskCache { + pub fn new(dir: PathBuf) -> Self { + Self { + dir, + max_files: AVATAR_CACHE_MAX_FILES, + } + } + + /// Keeps at most `max_files` files instead of [`AVATAR_CACHE_MAX_FILES`]. + pub fn with_max_files(self, max_files: usize) -> Self { + Self { max_files, ..self } + } + + fn path(&self, url: &str) -> PathBuf { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(url.as_bytes()); + let name: String = digest.iter().map(|b| format!("{b:02x}")).collect(); + self.dir.join(name) + } + + /// The stored bytes of `url`, if fresh. + pub fn read(&self, url: &str) -> Option> { + let path = self.path(url); + if !stable_host(url) { + let age = std::fs::metadata(&path) + .ok()? + .modified() + .ok()? + .elapsed() + .ok()?; + if age > AVATAR_CACHE_TTL { + return None; + } + } + let data = std::fs::read(&path).ok()?; + let body = data.strip_prefix(url.as_bytes())?.strip_prefix(b"\n")?; + (body.len() <= MAX_AVATAR_BYTES && looks_like_image(body)).then(|| body.to_vec()) + } + + /// Stores `bytes` for `url` (written aside, then renamed over), then + /// trims the folder to its file budget. + pub fn write(&self, url: &str, bytes: &[u8]) -> Result<(), String> { + if !looks_like_image(bytes) { + return Err("not an image".to_string()); + } + std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?; + let path = self.path(url); + let tmp = path.with_extension(format!("{}.tmp", std::process::id())); + let mut data = Vec::with_capacity(url.len() + 1 + bytes.len()); + data.extend_from_slice(url.as_bytes()); + data.push(b'\n'); + data.extend_from_slice(bytes); + std::fs::write(&tmp, &data) + .and_then(|()| std::fs::rename(&tmp, &path)) + .map_err(|e| { + let _ = std::fs::remove_file(&tmp); + e.to_string() + })?; + self.trim(); + Ok(()) + } + + fn trim(&self) { + let Ok(entries) = std::fs::read_dir(&self.dir) else { + return; + }; + let mut files: Vec<(std::time::SystemTime, PathBuf)> = entries + .filter_map(Result::ok) + .filter_map(|entry| { + let meta = entry.metadata().ok().filter(|meta| meta.is_file())?; + Some((meta.modified().ok()?, entry.path())) + }) + .collect(); + if files.len() <= self.max_files { + return; + } + files.sort(); + for (_, path) in &files[..files.len() - self.max_files] { + let _ = std::fs::remove_file(path); + } + } +} + +/// Whether `url` is on one of [`AVATAR_CACHE_STABLE_HOSTS`]: its picture +/// never changes. +pub fn stable_host(url: &str) -> bool { + let Some(host) = reqwest::Url::parse(url) + .ok() + .and_then(|url| url.host_str().map(str::to_ascii_lowercase)) + else { + return false; + }; + AVATAR_CACHE_STABLE_HOSTS.iter().any(|stable| { + host == *stable + || host + .strip_suffix(stable) + .is_some_and(|rest| rest.ends_with('.')) + }) +} + +fn looks_like_image(bytes: &[u8]) -> bool { + bytes.starts_with(&[0xFF, 0xD8, 0xFF]) + || bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) + || bytes.starts_with(b"GIF8") + || (bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP") +} diff --git a/crates/accshift-app/src/accounts/loader.rs b/crates/accshift-app/src/accounts/loader.rs new file mode 100644 index 00000000..afe9b418 --- /dev/null +++ b/crates/accshift-app/src/accounts/loader.rs @@ -0,0 +1,860 @@ +//! The accounts of the active tab, ported from `src/lib/shared/useAccountLoader.svelte.ts`. +//! +//! Each async TS function is split in two around its `await`: a `begin_*` +//! that changes the state the way TS does before awaiting and returns a +//! ticket, and a `finish_*` that takes the ticket and the job's result. The +//! generation checks TS makes after each `await` (`loadId`, `switchId`, the +//! prime run, the account epoch) are made on the ticket in `finish_*`, so a +//! result that lands late changes nothing it should not. +//! +//! What the adapter answers on the UI thread (cached warnings, cached +//! profiles, toast wording) comes through [`LoaderHooks`]; what blocks runs +//! on the job pool (see [`super::loading`]). + +use super::avatars::{AvatarPatch, AvatarStates, ProfileSource}; +use super::{Toast, ToastKind}; +use crate::error::BackendError; +use crate::platforms::warnings::WarningPresentation; +use crate::platforms::{ + AddAccountResult, AddFlowStatus, PlatformAccount, PlatformProfileInfo, StartupSnapshot, +}; +use crate::text::Text; +use std::collections::{BTreeMap, HashSet}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +/// `LOAD_TOAST_COOLDOWN_MS`. +pub const LOAD_TOAST_COOLDOWN_MS: f64 = 30_000.0; + +/// What the loader reads from the active adapter on the UI thread. +pub trait LoaderHooks: ProfileSource { + /// `adapter.id`. + fn platform_id(&self) -> &str; + /// `getNoAccountsToastMessage`. + fn no_accounts_toast(&self) -> Option; + /// `getLoadErrorToastMessage`. + fn load_error_toast(&self, message: &str) -> Option; + /// `getSwitchErrorToastMessage`. + fn switch_error_toast(&self, message: &str) -> Option; + /// `getCachedWarningStates`; `None` when the adapter has none. + fn cached_warning_states(&self) -> Option>; + /// `loadWarningStates` exists. + fn has_warnings(&self) -> bool; +} + +/// The flags of `load(onAfterLoad, silent, showRefreshedToast, forceRefresh, +/// checkBans, deferBackground)`. `Default` is the TS defaults (all false). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct LoadOptions { + pub silent: bool, + pub show_refreshed_toast: bool, + pub force_refresh: bool, + pub check_bans: bool, + pub defer_background: bool, +} + +/// A load in flight. +#[derive(Debug, Clone, PartialEq)] +pub struct LoadTicket { + pub load_id: u64, + epoch: u64, + pub platform_id: String, + pub options: LoadOptions, +} + +/// `runBackgroundTasks`: the refresh of the visible accounts a load asks for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BackgroundRefresh { + load_id: u64, + pub check_bans: bool, + pub force_refresh: bool, + pub silent: bool, + /// `deferBackground`: run it on a later frame (`requestIdleCallback`). + pub deferred: bool, +} + +/// What [`AccountLoader::finish_load`] asks of the caller. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct LoadOutcome { + /// False when a newer load or a platform change superseded this one. + pub applied: bool, + pub toasts: Vec, + /// `onAfterLoad(platformId)`: the platform the accounts came from. + pub after_load: Option, + /// Hand to [`AccountLoader::run_background`] once the grid's visible ids + /// reflect the new accounts. + pub background: Option, +} + +/// A switch in flight. +#[derive(Debug, Clone, PartialEq)] +pub struct SwitchTicket { + switch_id: u64, + pub account: PlatformAccount, + /// The account being left (`currentAccountId` before the switch). + pub previous_account_id: Option, + pub platform_id: String, +} + +/// What [`AccountLoader::finish_switch`] asks of the caller. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct SwitchOutcome { + /// What `switchTo` resolves to. + pub succeeded: bool, + pub toasts: Vec, + /// Steam: `triggerCs2BridgeCheck(previousAccountId)`. + pub cs2_check: Option, + /// Fetch this account's profile and hand it to + /// [`AccountLoader::finish_switch_profile`]. + pub fetch_profile: Option, +} + +/// An add in flight. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddTicket { + pub platform_id: String, +} + +/// What [`AccountLoader::finish_add`] resolves to. +#[derive(Debug, Clone, PartialEq)] +pub enum AddOutcome { + /// The add flow runs; `addFlow.start(platformId, status)`. + SetupStarted(AddFlowStatus), + /// `reloadAfterAdd`: run this load; `adding` stays set until it lands. + Reload(LoadTicket), + /// Nothing more to do. + Done, + /// The add failed with this toast. + Failed(Toast), +} + +/// A refresh of some accounts (`refreshAccounts`), between its prime guard +/// and its run. +#[derive(Debug, Clone, PartialEq)] +pub struct RefreshTicket { + run_id: u64, + pub platform_id: String, + pub targets: Vec, + pub check_bans: bool, + pub force_refresh: bool, + pub silent: bool, + /// `deferBackground`. + pub deferred: bool, +} + +/// What a refresh run has to fetch, decided by [`AccountLoader::run_refresh`]. +#[derive(Debug, Clone, PartialEq)] +pub struct RefreshWork { + pub ticket: RefreshTicket, + /// `needsRefresh` of `loadProfilesForAccounts`; empty means no fetch. + pub profiles: Vec, + /// `checkBans && adapter.loadWarningStates`. + pub warnings: bool, +} + +/// The profiles a refresh fetched. +#[derive(Debug, Clone, PartialEq)] +pub enum ProfileResults { + /// `getProfileInfos` answered for `accounts` (`needsRefresh`): one + /// commit, an id missing from `profiles` read as no data. + Batch { + accounts: Vec, + profiles: BTreeMap>, + }, + /// The per account path, in fetch order. + Each(Vec<(PlatformAccount, Option)>), +} + +/// Lets a job thread ask `shouldContinue()` while it runs. +#[derive(Debug, Clone)] +pub struct PrimeGuard { + latest: Arc, + run_id: u64, +} + +impl PrimeGuard { + /// False once a newer load, refresh or platform change started. + pub fn is_current(&self) -> bool { + self.latest.load(Ordering::SeqCst) == self.run_id + } +} + +/// `createAccountLoader`. +#[derive(Debug, Default)] +pub struct AccountLoader { + /// `getAdapter()?.id`. + adapter_id: Option, + accounts: Vec, + accounts_version: u64, + current_account: String, + loading: bool, + adding: bool, + switching: bool, + switching_account_id: Option, + error: Option, + avatars: AvatarStates, + warning_states: BTreeMap, + warnings_version: u64, + last_load_error_toast_at: f64, + last_no_accounts_toast_at: f64, + latest_load_id: u64, + latest_prime_run: Arc, + latest_switch_id: u64, + current_account_epoch: u64, + /// The reload `addNew` awaits before it clears `adding`. + add_reload_load_id: Option, +} + +/// `resolveAccountsByIds`: the accounts of `ids`, in `ids` order, once each. +pub fn resolve_accounts_by_ids(source: &[PlatformAccount], ids: &[String]) -> Vec { + let mut seen = HashSet::new(); + ids.iter() + .filter(|id| seen.insert(id.as_str())) + .filter_map(|id| source.iter().find(|account| &account.id == id).cloned()) + .collect() +} + +fn is_client_missing(message: &str) -> bool { + // `/could not locate .* executable/i` + let lower = message.to_lowercase(); + lower + .find("could not locate ") + .is_some_and(|start| lower[start + "could not locate ".len()..].contains(" executable")) +} + +impl AccountLoader { + /// A loader for `adapter_id`, `loading` set like the TS initial state. + pub fn new(adapter_id: Option) -> Self { + Self { + adapter_id, + loading: true, + ..Self::default() + } + } + + // -------------------------------------------------------- getters + + pub fn adapter_id(&self) -> Option<&str> { + self.adapter_id.as_deref() + } + + /// `getAdapter` changed (tab switch, registry reload). Call + /// [`AccountLoader::clear_for_platform_change`] too when the tab moved. + pub fn set_adapter(&mut self, adapter_id: Option) { + self.adapter_id = adapter_id; + } + + pub fn accounts(&self) -> &[PlatformAccount] { + &self.accounts + } + + /// The `accounts` setter. + pub fn set_accounts(&mut self, accounts: Vec) { + self.accounts = accounts; + self.accounts_version += 1; + } + + /// Bumped by each change of the account list. + pub fn accounts_version(&self) -> u64 { + self.accounts_version + } + + /// `accountMap[id]`. + pub fn account(&self, account_id: &str) -> Option<&PlatformAccount> { + self.accounts + .iter() + .find(|account| account.id == account_id) + } + + pub fn current_account(&self) -> &str { + &self.current_account + } + + /// `currentAccountId`: the account whose id, username or display name + /// matches the backend's current account, case-insensitive. + pub fn current_account_id(&self) -> Option<&str> { + let raw = crate::js::trim(&self.current_account); + if raw.is_empty() { + return None; + } + let needle = raw.to_lowercase(); + let same = |value: &str| crate::js::trim(value).to_lowercase() == needle; + if let Some(direct) = self.accounts.iter().find(|account| same(&account.id)) { + return Some(&direct.id); + } + self.accounts + .iter() + .find(|account| same(&account.username) || same(&account.display_name)) + .map(|account| account.id.as_str()) + } + + pub fn loading(&self) -> bool { + self.loading + } + + pub fn adding(&self) -> bool { + self.adding + } + + pub fn switching(&self) -> bool { + self.switching + } + + pub fn switching_account_id(&self) -> Option<&str> { + self.switching_account_id.as_deref() + } + + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } + + /// `avatarStates`. + pub fn avatars(&self) -> &AvatarStates { + &self.avatars + } + + pub fn avatars_mut(&mut self) -> &mut AvatarStates { + &mut self.avatars + } + + pub fn warning_states(&self) -> &BTreeMap { + &self.warning_states + } + + /// Bumped by each commit that changed the warnings. + pub fn warnings_version(&self) -> u64 { + self.warnings_version + } + + /// The per frame flush (`requestAnimationFrame`). True when the avatar + /// states changed. + pub fn flush(&mut self) -> bool { + self.avatars.flush() + } + + // -------------------------------------------------------- warnings + + /// `commitWarningStates`: skipped when nothing changed. + fn commit_warning_states(&mut self, next: BTreeMap) { + if next == self.warning_states { + return; + } + self.warning_states = next; + self.warnings_version += 1; + } + + /// `queueWarningStates`. TS coalesces a tick's results in a microtask; + /// each result here is applied as it lands, which is the same last write. + pub fn queue_warning_states(&mut self, next: BTreeMap) { + self.commit_warning_states(next); + } + + /// `replaceWarningStates`. + pub fn replace_warning_states(&mut self, next: BTreeMap) { + self.commit_warning_states(next); + } + + // -------------------------------------------------------- guards + + /// `createPrimeGuard`: starts a new prime run. + fn new_prime_run(&mut self) -> u64 { + self.latest_prime_run.fetch_add(1, Ordering::SeqCst) + 1 + } + + fn bump_prime_run(&mut self) { + self.latest_prime_run.fetch_add(1, Ordering::SeqCst); + } + + /// The guard of `ticket`, for a job thread. + pub fn prime_guard(&self, ticket: &RefreshTicket) -> PrimeGuard { + PrimeGuard { + latest: Arc::clone(&self.latest_prime_run), + run_id: ticket.run_id, + } + } + + /// `shouldContinue()`: the same run, and the same adapter. + pub fn should_continue(&self, ticket: &RefreshTicket) -> bool { + self.latest_prime_run.load(Ordering::SeqCst) == ticket.run_id + && self.adapter_id.as_deref() == Some(ticket.platform_id.as_str()) + } + + fn resolve_visible(&self, visible: Option<&[String]>) -> Vec { + match visible { + Some(ids) => resolve_accounts_by_ids(&self.accounts, ids), + None => self.accounts.clone(), + } + } + + /// `pruneOrphanedAvatarStates`. + fn prune_orphaned_avatar_states(&mut self) { + for id in self.avatars.ids() { + if self.account(&id).is_none() { + self.avatars.remove(&id); + } + } + } + + // -------------------------------------------------------- load + + /// `load`, up to its first `await`. The caller runs + /// `adapter.startup_snapshot` on the job pool and hands the answer to + /// [`AccountLoader::finish_load`]. + pub fn begin_load(&mut self, hooks: &dyn LoaderHooks, options: LoadOptions) -> LoadTicket { + self.latest_load_id += 1; + let ticket = LoadTicket { + load_id: self.latest_load_id, + epoch: self.current_account_epoch, + platform_id: hooks.platform_id().to_string(), + options, + }; + self.bump_prime_run(); + self.loading = true; + self.error = None; + self.replace_warning_states(hooks.cached_warning_states().unwrap_or_default()); + ticket + } + + /// `load`, after the snapshot. `visible` is `getVisibleAccountIds()` + /// (`None` when the loader has no such reader); `now_ms` drives the toast + /// cooldowns. + pub fn finish_load( + &mut self, + ticket: &LoadTicket, + result: Result, + hooks: &dyn LoaderHooks, + visible: Option<&[String]>, + now_ms: f64, + ) -> LoadOutcome { + if self.add_reload_load_id == Some(ticket.load_id) { + self.add_reload_load_id = None; + self.adding = false; + } + let mut outcome = LoadOutcome::default(); + if ticket.load_id != self.latest_load_id { + return outcome; + } + outcome.applied = true; + let options = ticket.options; + match result { + Ok(snapshot) => { + self.set_accounts(snapshot.accounts); + if ticket.epoch == self.current_account_epoch { + self.current_account = snapshot.current_account; + } + let seed = self.resolve_visible(visible); + self.avatars + .seed_for_accounts(&seed, options.force_refresh, hooks); + if self.accounts.is_empty() { + if let Some(text) = hooks.no_accounts_toast() { + if now_ms - self.last_no_accounts_toast_at >= LOAD_TOAST_COOLDOWN_MS { + outcome.toasts.push(Toast::new(text, ToastKind::Default)); + self.last_no_accounts_toast_at = now_ms; + } + } + } else if options.show_refreshed_toast && !options.silent { + let count = self.accounts.len(); + let key = if count == 1 { + "toast.accountsRefreshed.single" + } else { + "toast.accountsRefreshed.multiple" + }; + outcome.toasts.push(Toast::new( + Text::with(key, [("count", count as f64)]), + ToastKind::Success, + )); + } + outcome.after_load = Some(ticket.platform_id.clone()); + outcome.background = Some(BackgroundRefresh { + load_id: ticket.load_id, + check_bans: options.check_bans, + force_refresh: options.force_refresh, + silent: options.silent, + deferred: options.defer_background, + }); + } + Err(error) => { + let message = error.message; + self.error = Some(message.clone()); + self.set_accounts(Vec::new()); + self.current_account.clear(); + self.replace_warning_states(BTreeMap::new()); + if let Some(text) = hooks.load_error_toast(&message) { + if now_ms - self.last_load_error_toast_at >= LOAD_TOAST_COOLDOWN_MS { + outcome.toasts.push(Toast::new(text, ToastKind::Error)); + self.last_load_error_toast_at = now_ms; + } + } + } + } + self.loading = false; + outcome + } + + /// `runBackgroundTasks`: the refresh of the visible accounts, unless a + /// newer load started. Never deferred again (`deferBackground = false`). + pub fn run_background( + &mut self, + background: &BackgroundRefresh, + visible: Option<&[String]>, + ) -> Option { + if background.load_id != self.latest_load_id { + return None; + } + self.refresh_visible_accounts( + visible, + background.check_bans, + background.force_refresh, + background.silent, + false, + ) + } + + // -------------------------------------------------------- switch + + /// `switchTo`, up to its `await`: `None` when a switch already runs or + /// there is no adapter. The caller runs the switch on the job pool. + pub fn begin_switch(&mut self, account: &PlatformAccount) -> Option { + let platform_id = self.adapter_id.clone()?; + if self.switching { + return None; + } + self.latest_switch_id += 1; + let previous_account_id = self.current_account_id().map(str::to_string); + self.switching = true; + self.switching_account_id = Some(account.id.clone()); + self.error = None; + Some(SwitchTicket { + switch_id: self.latest_switch_id, + account: account.clone(), + previous_account_id, + platform_id, + }) + } + + /// `switchTo`, after the switch. + pub fn finish_switch( + &mut self, + ticket: &SwitchTicket, + result: Result<(), BackendError>, + hooks: &dyn LoaderHooks, + ) -> SwitchOutcome { + let mut outcome = SwitchOutcome::default(); + if ticket.switch_id != self.latest_switch_id { + return outcome; + } + let account = &ticket.account; + match result { + Ok(()) => { + outcome.succeeded = true; + // A load in flight read the current account before this + // switch landed: the epoch keeps it from clobbering ours. + self.current_account_epoch += 1; + self.current_account = account.id.clone(); + if ticket.platform_id == "steam" { + if let Some(previous) = &ticket.previous_account_id { + if previous != &account.id { + outcome.cs2_check = Some(previous.clone()); + } + } + } + if hooks.has_profile_info() { + self.avatars.update_state( + &account.id, + AvatarPatch { + refreshing: Some(true), + ..AvatarPatch::default() + }, + ); + outcome.fetch_profile = Some(account.clone()); + } + } + Err(error) => { + // Refused for want of the PIN: the lock screen is up, nothing + // failed that a toast could explain. + if !error.is_pin_locked() { + let message = error.message; + self.error = Some(message.clone()); + let text = hooks + .switch_error_toast(&message) + .unwrap_or_else(|| Text::key("toast.switchFailed")); + outcome.toasts.push(Toast::new(text, ToastKind::Error)); + if let Some(cached) = hooks.cached_warning_states() { + self.replace_warning_states(cached); + } + } + } + } + self.switching = false; + self.switching_account_id = None; + outcome + } + + /// The profile fetch after a switch. `None` is a failed fetch. + pub fn finish_switch_profile( + &mut self, + account: &PlatformAccount, + profile: Option, + ) { + // The account may have been removed while this fetch was in flight. + if self.account(&account.id).is_none() { + return; + } + let mut accounts = std::mem::take(&mut self.accounts); + let changed = self + .avatars + .apply_profile_update(account, profile.as_ref(), &mut accounts); + self.accounts = accounts; + if changed { + self.accounts_version += 1; + } + } + + // -------------------------------------------------------- add + + /// `addNew`, up to its `await`: `None` when an add already runs or there + /// is no adapter. + pub fn begin_add(&mut self) -> Option { + if self.adding { + return None; + } + let platform_id = self.adapter_id.clone()?; + self.adding = true; + self.current_account.clear(); + Some(AddTicket { platform_id }) + } + + /// `addNew`, after `adapter.addAccount()`. `platform_name` is the + /// registry name for the "client missing" toast; `reload_after_add` is + /// the adapter's flag. + pub fn finish_add( + &mut self, + _ticket: &AddTicket, + result: Result, + platform_name: &str, + reload_after_add: bool, + hooks: &dyn LoaderHooks, + ) -> AddOutcome { + let result = match result { + Ok(result) => result, + Err(error) => { + let message = error.message; + self.error = Some(message.clone()); + self.adding = false; + let text = if is_client_missing(&message) { + Text::with( + "toast.addAccountClientMissing", + [("platform", platform_name)], + ) + } else { + Text::key("toast.addAccountFailed") + }; + return AddOutcome::Failed(Toast::new(text, ToastKind::Error)); + } + }; + if let Some(status) = result.setup_status { + self.adding = false; + return AddOutcome::SetupStarted(status); + } + if reload_after_add { + let ticket = self.begin_load( + hooks, + LoadOptions { + silent: true, + ..LoadOptions::default() + }, + ); + self.add_reload_load_id = Some(ticket.load_id); + return AddOutcome::Reload(ticket); + } + self.adding = false; + AddOutcome::Done + } + + // -------------------------------------------------------- refresh + + /// `refreshVisibleAccounts`. + pub fn refresh_visible_accounts( + &mut self, + visible: Option<&[String]>, + check_bans: bool, + force_refresh: bool, + silent: bool, + deferred: bool, + ) -> Option { + let targets = self.resolve_visible(visible); + self.refresh_accounts(targets, check_bans, force_refresh, silent, deferred) + } + + /// `refreshAccountIds` (`primeAccountIds`). + pub fn refresh_account_ids( + &mut self, + account_ids: &[String], + check_bans: bool, + force_refresh: bool, + silent: bool, + deferred: bool, + ) -> Option { + let targets = resolve_accounts_by_ids(&self.accounts, account_ids); + self.refresh_accounts(targets, check_bans, force_refresh, silent, deferred) + } + + /// `refreshAccounts`, up to its deferral: `None` (TS resolves 0) when + /// there is nothing to refresh or no adapter. Starts a prime run. + fn refresh_accounts( + &mut self, + targets: Vec, + check_bans: bool, + force_refresh: bool, + silent: bool, + deferred: bool, + ) -> Option { + if targets.is_empty() { + return None; + } + let platform_id = self.adapter_id.clone()?; + let run_id = self.new_prime_run(); + Some(RefreshTicket { + run_id, + platform_id, + targets, + check_bans, + force_refresh, + silent, + deferred, + }) + } + + /// `run()` of `refreshAccounts`: seeds the avatars and says what to fetch. + /// `None` when the run was superseded (TS resolves 0). + pub fn run_refresh( + &mut self, + ticket: RefreshTicket, + hooks: &dyn LoaderHooks, + ) -> Option { + if !self.should_continue(&ticket) { + return None; + } + let profiles = self + .avatars + .seed_for_accounts(&ticket.targets, ticket.force_refresh, hooks); + let warnings = ticket.check_bans && hooks.has_warnings(); + Some(RefreshWork { + ticket, + profiles, + warnings, + }) + } + + /// The profile half of a run, landed. False when superseded. + pub fn finish_refresh_profiles( + &mut self, + ticket: &RefreshTicket, + results: ProfileResults, + ) -> bool { + let applied = self.should_continue(ticket); + if applied { + let mut accounts = std::mem::take(&mut self.accounts); + let changed = match &results { + ProfileResults::Batch { + accounts: targets, + profiles, + } => self + .avatars + .apply_batch_profile_updates(targets, profiles, &mut accounts), + ProfileResults::Each(list) => { + let mut changed = false; + for (account, profile) in list { + changed |= self.avatars.apply_profile_update( + account, + profile.as_ref(), + &mut accounts, + ); + } + changed + } + }; + self.accounts = accounts; + if changed { + self.accounts_version += 1; + } + } + self.prune_orphaned_avatar_states(); + applied + } + + /// The warning half of a run, landed. False when superseded. + pub fn finish_refresh_warnings( + &mut self, + ticket: &RefreshTicket, + warnings: BTreeMap, + ) -> bool { + let applied = self.should_continue(ticket); + if applied { + self.queue_warning_states(warnings); + } + self.prune_orphaned_avatar_states(); + applied + } + + /// `prepareVisibleAccounts`. + pub fn prepare_visible_accounts( + &mut self, + visible: Option<&[String]>, + force_refresh: bool, + hooks: &dyn LoaderHooks, + ) -> usize { + let targets = self.resolve_visible(visible); + self.avatars + .seed_for_accounts(&targets, force_refresh, hooks); + targets.len() + } + + /// `prepareAccountIds`. + pub fn prepare_account_ids( + &mut self, + account_ids: &[String], + force_refresh: bool, + hooks: &dyn LoaderHooks, + ) -> usize { + let targets = resolve_accounts_by_ids(&self.accounts, account_ids); + self.avatars + .seed_for_accounts(&targets, force_refresh, hooks); + targets.len() + } + + // -------------------------------------------------------- removal + + /// `removeAccount`. + pub fn remove_account(&mut self, account_id: &str) { + let before = self.accounts.len(); + self.accounts.retain(|account| account.id != account_id); + if self.accounts.len() != before { + self.accounts_version += 1; + } + if self.current_account == account_id { + self.current_account.clear(); + } + self.avatars.remove(account_id); + self.prune_orphaned_avatar_states(); + let mut rest = self.warning_states.clone(); + rest.remove(account_id); + self.replace_warning_states(rest); + } + + /// `clearForPlatformChange`: every load, run and switch in flight is + /// dropped. + pub fn clear_for_platform_change(&mut self) { + self.latest_load_id += 1; + self.bump_prime_run(); + self.latest_switch_id += 1; + self.set_accounts(Vec::new()); + self.current_account.clear(); + self.loading = true; + self.error = None; + self.switching = false; + self.switching_account_id = None; + self.avatars.clear(); + self.replace_warning_states(BTreeMap::new()); + } +} diff --git a/crates/accshift-app/src/accounts/loading.rs b/crates/accshift-app/src/accounts/loading.rs new file mode 100644 index 00000000..4084b5eb --- /dev/null +++ b/crates/accshift-app/src/accounts/loading.rs @@ -0,0 +1,795 @@ +//! The account loader of the active tab and everything that drives it, +//! ported from `src/lib/app/useAccountLoading.svelte.ts`, plus +//! `src/lib/app/detectedPlatforms.svelte.ts`. +//! +//! [`AccountLoading`] owns the [`AccountLoader`], the [`VisiblePriming`] and +//! the profile caches, starts every blocking call on [`Jobs`] and keeps the +//! [`Pending`] handles. The shell calls [`AccountLoading::poll`] once per +//! frame: it applies the results that landed, runs the work deferred to that +//! frame and returns [`LoadingEvent`]s (toasts, the add flow start, the +//! window minimize). Nothing here blocks the UI thread. +//! +//! Adapters are built up front by the registry, so `ensureAdapterReady` is a +//! lookup and `adapterLoading` is always false. + +use super::avatars::{ProfileSource, BATCH_SIZE}; +use super::loader::{ + AccountLoader, AddOutcome, AddTicket, BackgroundRefresh, LoadOptions, LoadTicket, LoaderHooks, + ProfileResults, RefreshTicket, RefreshWork, SwitchTicket, +}; +use super::priming::VisiblePriming; +use super::profile_cache::{CacheExpiry, CacheWrite, Fetched, ProfileCaches}; +use super::refresh::has_profile_info; +use super::{LoadingEvent, Toast, ToastKind}; +use crate::error::{BackendError, BackendResult}; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::platforms::roblox::RobloxSessions; +use crate::platforms::steam::warnings::{self as steam_warnings, BanFetch, BanPlan, BanStep}; +use crate::platforms::warnings::{WarningLoad, WarningPresentation}; +use crate::platforms::{ + AddAccountResult, PlatformAccount, PlatformAdapter, PlatformProfileInfo, PlatformRegistry, + RuntimeOs, StartupSnapshot, WarningLoadOptions, +}; +use crate::stores::ClientStores; +use crate::text::Text; +use crate::time::Clock; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Instant; + +/// `isPlatformUsable`: implemented and supported on this OS. +pub fn is_platform_usable(registry: &PlatformRegistry, platform_id: &str, os: RuntimeOs) -> bool { + registry + .definition(platform_id) + .is_some_and(|def| def.implemented && def.supports(os)) +} + +/// `detectedPlatforms.svelte.ts`: what launcher detection found this launch, +/// set once during boot on a fresh install. Empty means detection did not +/// run or found nothing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DetectedPlatforms(Vec); + +impl DetectedPlatforms { + /// `setDetectedPlatforms`. + pub fn set(&mut self, platform_ids: &[String]) { + self.0 = platform_ids.to_vec(); + } + + /// `getDetectedPlatforms`. + pub fn get(&self) -> &[String] { + &self.0 + } +} + +/// [`LoaderHooks`] over a real adapter and the client stores. +pub struct AdapterHooks<'a> { + pub adapter: &'a PlatformAdapter, + pub stores: &'a ClientStores, + pub caches: &'a ProfileCaches, + pub expiry: CacheExpiry, +} + +impl ProfileSource for AdapterHooks<'_> { + fn cached_profile(&self, account_id: &str) -> Option { + self.caches.cached_profile( + self.adapter.id(), + self.stores.storage(), + account_id, + &self.expiry, + ) + } + + fn has_profile_info(&self) -> bool { + has_profile_info(self.adapter) + } +} + +impl LoaderHooks for AdapterHooks<'_> { + fn platform_id(&self) -> &str { + self.adapter.id() + } + + fn no_accounts_toast(&self) -> Option { + self.adapter.no_accounts_toast() + } + + fn load_error_toast(&self, message: &str) -> Option { + self.adapter.load_error_toast(message) + } + + fn switch_error_toast(&self, message: &str) -> Option { + self.adapter.switch_error_toast(message) + } + + fn cached_warning_states(&self) -> Option> { + self.adapter + .has_warnings() + .then(|| self.adapter.cached_warning_states(self.stores)) + } + + fn has_warnings(&self) -> bool { + self.adapter.has_warnings() + } +} + +/// What [`AccountLoading::poll`] and the handlers read from the app. +pub struct LoadingContext<'a> { + pub jobs: &'a Jobs, + pub registry: &'a PlatformRegistry, + pub stores: &'a mut ClientStores, + /// `shell.activeTab`. + pub active_tab: &'a str, + pub runtime_os: RuntimeOs, + /// The grid's `visibleRenderedAccountIds`, from the display pipeline. + pub visible_ids: &'a [String], + /// `navigation.isSearching`. + pub is_searching: bool, + /// Unix milliseconds for toasts cooldowns and cache stamps. + pub clock: &'a Clock, + /// Frame time for the priming deadlines. + pub now: Instant, +} + +impl LoadingContext<'_> { + fn now_ms(&self) -> f64 { + self.clock.now_ms() + } + + fn adapter(&self) -> Option> { + self.registry.adapter(self.active_tab) + } +} + +enum WarningJob { + SteamBans { + plan: BanPlan, + pending: Pending, + }, + RobloxProbe(Pending, BackendError>>), +} + +type ProfileJob = Pending<(ProfileResults, Vec)>; + +/// `createAccountLoading`. +pub struct AccountLoading { + pub loader: AccountLoader, + pub priming: VisiblePriming, + pub detected: DetectedPlatforms, + caches: Arc, + loads: Vec<(LoadTicket, Pending>)>, + switches: Vec<(SwitchTicket, Pending>)>, + switch_profiles: Vec<( + PlatformAccount, + Pending>>, + )>, + adds: Vec<(AddTicket, Pending>)>, + profiles: Vec<(RefreshTicket, ProfileJob)>, + warnings: Vec<(RefreshTicket, WarningJob)>, + /// Background refreshes waiting for the next frame's visible ids. + deferred_background: Vec, + /// Refresh runs deferred to the next frame (`requestIdleCallback`). + deferred_runs: Vec, +} + +impl AccountLoading { + pub fn new(active_tab: Option, caches: Arc) -> Self { + Self { + loader: AccountLoader::new(active_tab), + priming: VisiblePriming::new(), + detected: DetectedPlatforms::default(), + caches, + loads: Vec::new(), + switches: Vec::new(), + switch_profiles: Vec::new(), + adds: Vec::new(), + profiles: Vec::new(), + warnings: Vec::new(), + deferred_background: Vec::new(), + deferred_runs: Vec::new(), + } + } + + pub fn caches(&self) -> &Arc { + &self.caches + } + + /// `adapterLoading`: adapters are never loaded lazily here. + pub fn adapter_loading(&self) -> bool { + false + } + + /// An account load still to land (`loadAccounts` not settled yet). + pub fn loads_in_flight(&self) -> bool { + !self.loads.is_empty() + } + + /// Jobs or deferred work still to land. + pub fn has_pending_work(&self) -> bool { + !(self.loads.is_empty() + && self.switches.is_empty() + && self.switch_profiles.is_empty() + && self.adds.is_empty() + && self.profiles.is_empty() + && self.warnings.is_empty() + && self.deferred_background.is_empty() + && self.deferred_runs.is_empty()) + || self.loader.avatars().has_pending() + } + + /// The next deadline [`AccountLoading::poll`] has to run at (request a + /// repaint for it). Deferred work wants the very next frame. + pub fn next_deadline(&self, now: Instant) -> Option { + let soon = (!self.deferred_background.is_empty() + || !self.deferred_runs.is_empty() + || self.loader.avatars().has_pending()) + .then_some(now); + match (soon, self.priming.next_deadline()) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + } + } + + fn expiry(&self, ctx: &LoadingContext<'_>) -> CacheExpiry { + self.caches + .expiry(ctx.stores.cache_duration_ms(), ctx.now_ms()) + } + + /// The tab changed: drop everything in flight and point the loader at + /// `platform_id`'s adapter. + pub fn switch_tab(&mut self, registry: &PlatformRegistry, platform_id: &str) { + let adapter = registry.adapter(platform_id).map(|a| a.id().to_string()); + self.loader.set_adapter(adapter); + self.loader.clear_for_platform_change(); + self.priming.reset(); + self.deferred_background.clear(); + self.deferred_runs.clear(); + } + + // -------------------------------------------------------- handlers + + /// `loadAccounts`. False when the tab is not usable or has no adapter. + pub fn load_accounts(&mut self, ctx: &mut LoadingContext<'_>, options: LoadOptions) -> bool { + if !is_platform_usable(ctx.registry, ctx.active_tab, ctx.runtime_os) { + return false; + } + let Some(adapter) = ctx.adapter() else { + return false; + }; + self.loader.set_adapter(Some(adapter.id().to_string())); + let caches = Arc::clone(&self.caches); + let hooks = AdapterHooks { + adapter: &adapter, + stores: ctx.stores, + caches: &caches, + expiry: self.expiry(ctx), + }; + let ticket = self.loader.begin_load(&hooks, options); + self.spawn_load(ctx, adapter, ticket); + true + } + + fn spawn_load( + &mut self, + ctx: &LoadingContext<'_>, + adapter: Arc, + ticket: LoadTicket, + ) { + let pending = ctx + .jobs + .run(move |backend| adapter.startup_snapshot(backend)); + self.loads.push((ticket, pending)); + } + + /// `loadPlatformAccounts`: accounts of any platform, not just the active + /// tab (personas, deep links). `None` for an unknown platform. + pub fn load_platform_accounts( + jobs: &Jobs, + registry: &PlatformRegistry, + platform_id: &str, + ) -> Option>>> { + let adapter = registry.adapter(platform_id)?; + Some(jobs.run(move |backend| adapter.load_accounts(backend))) + } + + /// `handleRefreshClick`. + pub fn handle_refresh_click(&mut self, ctx: &mut LoadingContext<'_>) -> bool { + self.load_accounts( + ctx, + LoadOptions { + silent: false, + show_refreshed_toast: true, + force_refresh: false, + check_bans: true, + defer_background: true, + }, + ) + } + + /// `handleAddAccountClick` and `handleAddAccount`. `add_flow_active` is + /// `addFlow.flow` being set. + pub fn handle_add_account_click( + &mut self, + ctx: &mut LoadingContext<'_>, + add_flow_active: bool, + ) -> bool { + if !is_platform_usable(ctx.registry, ctx.active_tab, ctx.runtime_os) + || self.loader.adding() + || add_flow_active + { + return false; + } + let Some(adapter) = ctx.adapter() else { + return false; + }; + let Some(ticket) = self.loader.begin_add() else { + return false; + }; + let settings = ctx.stores.settings().clone(); + let pending = ctx + .jobs + .run(move |backend| adapter.add_account(backend, &settings)); + self.adds.push((ticket, pending)); + true + } + + /// `handleAccountSwitch`: false when a persona switch runs, a switch is + /// already running or there is no adapter. The result arrives as + /// [`LoadingEvent::SwitchFinished`]. + pub fn handle_account_switch( + &mut self, + ctx: &mut LoadingContext<'_>, + account: &PlatformAccount, + persona_switching: bool, + ) -> bool { + if persona_switching { + return false; + } + let Some(adapter) = ctx.adapter() else { + return false; + }; + let Some(ticket) = self.loader.begin_switch(account) else { + return false; + }; + let settings = ctx.stores.settings().clone(); + let target = account.clone(); + let pending = ctx + .jobs + .run(move |backend| adapter.switch_account(backend, &target, &settings)); + self.switches.push((ticket, pending)); + true + } + + /// `trackVisiblePriming`'s effect: call every frame. + pub fn track_priming(&mut self, ctx: &LoadingContext<'_>, grid_hidden: bool) { + if ctx.adapter().is_none() { + self.priming.reset(); + return; + } + if self.loader.loading() || grid_hidden || ctx.visible_ids.is_empty() { + self.priming.pause(); + return; + } + self.priming + .process_visible(ctx.visible_ids, ctx.active_tab, ctx.is_searching, ctx.now); + } + + // -------------------------------------------------------- frame + + /// Applies every finished job, runs the deferred work and the priming + /// timer, then flushes the avatar states. Call once per frame. + pub fn poll(&mut self, ctx: &mut LoadingContext<'_>) -> Vec { + let mut events = Vec::new(); + self.run_deferred(ctx, &mut events); + self.poll_loads(ctx, &mut events); + self.poll_switches(ctx, &mut events); + self.poll_adds(ctx, &mut events); + self.poll_profiles(ctx); + self.poll_warnings(ctx, &mut events); + if let Some(request) = self.priming.poll(ctx.now) { + if let Some(adapter) = ctx.adapter() { + let caches = Arc::clone(&self.caches); + let hooks = AdapterHooks { + adapter: &adapter, + stores: ctx.stores, + caches: &caches, + expiry: self.expiry(ctx), + }; + self.loader + .prepare_account_ids(&request.visible_ids, false, &hooks); + } + if !request.due_ids.is_empty() { + if let Some(ticket) = + self.loader + .refresh_account_ids(&request.due_ids, true, false, true, true) + { + self.deferred_runs.push(ticket); + } + } + } + self.loader.flush(); + events + } + + fn run_deferred(&mut self, ctx: &mut LoadingContext<'_>, events: &mut Vec) { + for background in std::mem::take(&mut self.deferred_background) { + let visible = ctx.visible_ids.to_vec(); + if let Some(ticket) = self.loader.run_background(&background, Some(&visible)) { + self.start_run(ctx, ticket, events); + } + } + for ticket in std::mem::take(&mut self.deferred_runs) { + self.start_run(ctx, ticket, events); + } + } + + fn start_run( + &mut self, + ctx: &mut LoadingContext<'_>, + ticket: RefreshTicket, + events: &mut Vec, + ) { + let Some(adapter) = ctx.registry.adapter(&ticket.platform_id) else { + return; + }; + let caches = Arc::clone(&self.caches); + let work = { + let hooks = AdapterHooks { + adapter: &adapter, + stores: ctx.stores, + caches: &caches, + expiry: self.expiry(ctx), + }; + self.loader.run_refresh(ticket, &hooks) + }; + if let Some(work) = work { + self.start_work(ctx, adapter, work, events); + } + } + + fn start_work( + &mut self, + ctx: &mut LoadingContext<'_>, + adapter: Arc, + work: RefreshWork, + events: &mut Vec, + ) { + let RefreshWork { + ticket, + profiles, + warnings, + } = work; + if !profiles.is_empty() { + let guard = self.loader.prime_guard(&ticket); + let caches = Arc::clone(&self.caches); + let clock = ctx.clock.clone(); + let adapter = Arc::clone(&adapter); + let pending = ctx.jobs.run(move |backend| { + let now_ms = || clock.now_ms(); + if adapter.id() == "steam" { + let ids: Vec = profiles.iter().map(|a| a.id.clone()).collect(); + let fetched = caches.steam_profile_infos(backend, &ids, &now_ms); + return ( + ProfileResults::Batch { + accounts: profiles, + profiles: fetched.value, + }, + fetched.writes, + ); + } + let mut results = Vec::new(); + let mut writes = Vec::new(); + for chunk in profiles.chunks(BATCH_SIZE) { + if !guard.is_current() { + break; + } + let fetched: Vec<_> = std::thread::scope(|scope| { + let handles: Vec<_> = chunk + .iter() + .map(|account| { + let caches = &caches; + let adapter = &adapter; + let now_ms = &now_ms; + scope.spawn(move || { + caches.profile_info(adapter, backend, &account.id, now_ms) + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().ok()) + .collect() + }); + for (account, fetched) in chunk.iter().zip(fetched) { + let fetched = fetched.unwrap_or(Fetched { + value: None, + writes: Vec::new(), + }); + writes.extend(fetched.writes); + results.push((account.clone(), fetched.value)); + } + } + (ProfileResults::Each(results), writes) + }); + self.profiles.push((ticket.clone(), pending)); + } + if warnings { + self.start_warnings(ctx, &adapter, ticket, events); + } + } + + fn start_warnings( + &mut self, + ctx: &mut LoadingContext<'_>, + adapter: &Arc, + ticket: RefreshTicket, + events: &mut Vec, + ) { + let options = WarningLoadOptions { + force_refresh: ticket.force_refresh, + silent: ticket.silent, + }; + match adapter.id() { + "steam" => { + let settings = ctx.stores.settings().clone(); + let step = adapter.steam_ban_plan( + ctx.stores, + &settings, + &ticket.targets, + options, + ctx.now_ms(), + ); + match step { + Some(BanStep::Done(load)) => self.land_warnings(&ticket, load, events), + Some(BanStep::Fetch(plan)) => { + let job_plan = plan.clone(); + let pending = ctx + .jobs + .run(move |backend| steam_warnings::fetch(&job_plan, backend)); + self.warnings + .push((ticket, WarningJob::SteamBans { plan, pending })); + } + None => {} + } + } + "roblox" => { + let settings = ctx.stores.settings().clone(); + let needs_probe = adapter.state().roblox_sessions.needs_probe( + &settings, + &ticket.targets, + options.force_refresh, + ); + if needs_probe { + let pending = ctx.jobs.run(RobloxSessions::probe); + self.warnings + .push((ticket, WarningJob::RobloxProbe(pending))); + } else { + let load = adapter.state().roblox_sessions.warning_map(); + self.land_warnings(&ticket, load, events); + } + } + _ => {} + } + } + + fn land_warnings( + &mut self, + ticket: &RefreshTicket, + load: WarningLoad, + events: &mut Vec, + ) { + for request in load.toasts { + let kind = if request.error { + ToastKind::Error + } else { + ToastKind::Default + }; + events.push(LoadingEvent::Toast(Toast::new(request.text, kind))); + } + self.loader.finish_refresh_warnings(ticket, load.warnings); + } + + fn poll_loads(&mut self, ctx: &mut LoadingContext<'_>, events: &mut Vec) { + let mut waiting = Vec::new(); + for (ticket, mut pending) in std::mem::take(&mut self.loads) { + let result = match pending.poll() { + Poll::Waiting => { + waiting.push((ticket, pending)); + continue; + } + Poll::Ready(result) => result, + Poll::Gone => Err(BackendError::new("account load job died")), + }; + let Some(adapter) = ctx.registry.adapter(&ticket.platform_id) else { + continue; + }; + let caches = Arc::clone(&self.caches); + let visible = ctx.visible_ids.to_vec(); + let outcome = { + let hooks = AdapterHooks { + adapter: &adapter, + stores: ctx.stores, + caches: &caches, + expiry: self.expiry(ctx), + }; + self.loader + .finish_load(&ticket, result, &hooks, Some(&visible), ctx.now_ms()) + }; + events.extend(outcome.toasts.into_iter().map(LoadingEvent::Toast)); + if let Some(platform_id) = outcome.after_load { + let ids: Vec = self + .loader + .accounts() + .iter() + .map(|account| account.id.clone()) + .collect(); + ctx.stores.sync_accounts(&ids, &platform_id); + events.push(LoadingEvent::AccountsLoaded { platform_id }); + } + // Deferred or not, the background refresh reads the visible ids + // of the next frame, once the grid shows the new accounts. + if let Some(background) = outcome.background { + self.deferred_background.push(background); + } + } + self.loads.extend(waiting); + } + + fn poll_switches(&mut self, ctx: &mut LoadingContext<'_>, events: &mut Vec) { + let mut waiting = Vec::new(); + for (ticket, mut pending) in std::mem::take(&mut self.switches) { + let result = match pending.poll() { + Poll::Waiting => { + waiting.push((ticket, pending)); + continue; + } + Poll::Ready(result) => result, + Poll::Gone => Err(BackendError::new("account switch job died")), + }; + let Some(adapter) = ctx.registry.adapter(&ticket.platform_id) else { + continue; + }; + let caches = Arc::clone(&self.caches); + let outcome = { + let hooks = AdapterHooks { + adapter: &adapter, + stores: ctx.stores, + caches: &caches, + expiry: self.expiry(ctx), + }; + self.loader.finish_switch(&ticket, result, &hooks) + }; + events.extend(outcome.toasts.into_iter().map(LoadingEvent::Toast)); + if let Some(steam_id) = outcome.cs2_check { + events.push(LoadingEvent::Cs2Check { steam_id }); + } + if let Some(account) = outcome.fetch_profile { + let clock = ctx.clock.clone(); + let adapter = Arc::clone(&adapter); + let account_id = account.id.clone(); + let pending = ctx.jobs.run(move |backend| { + caches.profile_info(&adapter, backend, &account_id, &|| clock.now_ms()) + }); + self.switch_profiles.push((account, pending)); + } + events.push(LoadingEvent::SwitchFinished { + account_id: ticket.account.id.clone(), + succeeded: outcome.succeeded, + }); + if outcome.succeeded && ctx.stores.settings().minimize_on_account_switch { + events.push(LoadingEvent::MinimizeWindow); + } + } + self.switches.extend(waiting); + + let mut waiting = Vec::new(); + for (account, mut pending) in std::mem::take(&mut self.switch_profiles) { + match pending.poll() { + Poll::Waiting => waiting.push((account, pending)), + Poll::Ready(fetched) => { + if let Some(adapter_id) = self.loader.adapter_id().map(str::to_string) { + self.caches.apply_writes( + &adapter_id, + ctx.stores.storage_mut(), + fetched.writes, + ); + } + self.loader.finish_switch_profile(&account, fetched.value); + } + Poll::Gone => self.loader.finish_switch_profile(&account, None), + } + } + self.switch_profiles.extend(waiting); + } + + fn poll_adds(&mut self, ctx: &mut LoadingContext<'_>, events: &mut Vec) { + let mut waiting = Vec::new(); + for (ticket, mut pending) in std::mem::take(&mut self.adds) { + let result = match pending.poll() { + Poll::Waiting => { + waiting.push((ticket, pending)); + continue; + } + Poll::Ready(result) => result, + Poll::Gone => Err(BackendError::new("add account job died")), + }; + let Some(adapter) = ctx.registry.adapter(&ticket.platform_id) else { + continue; + }; + let name = ctx + .registry + .definition(&ticket.platform_id) + .map_or_else(|| ticket.platform_id.clone(), |def| def.name.clone()); + let caches = Arc::clone(&self.caches); + let outcome = { + let hooks = AdapterHooks { + adapter: &adapter, + stores: ctx.stores, + caches: &caches, + expiry: self.expiry(ctx), + }; + self.loader + .finish_add(&ticket, result, &name, adapter.reload_after_add(), &hooks) + }; + match outcome { + AddOutcome::SetupStarted(status) => events.push(LoadingEvent::AddFlowStarted { + platform_id: ticket.platform_id.clone(), + status, + }), + AddOutcome::Reload(load) => self.spawn_load(ctx, adapter, load), + AddOutcome::Failed(toast) => events.push(LoadingEvent::Toast(toast)), + AddOutcome::Done => {} + } + } + self.adds.extend(waiting); + } + + fn poll_profiles(&mut self, ctx: &mut LoadingContext<'_>) { + let mut waiting = Vec::new(); + for (ticket, mut pending) in std::mem::take(&mut self.profiles) { + match pending.poll() { + Poll::Waiting => waiting.push((ticket, pending)), + Poll::Ready((results, writes)) => { + self.caches + .apply_writes(&ticket.platform_id, ctx.stores.storage_mut(), writes); + self.loader.finish_refresh_profiles(&ticket, results); + } + Poll::Gone => { + self.loader + .finish_refresh_profiles(&ticket, ProfileResults::Each(Vec::new())); + } + } + } + self.profiles.extend(waiting); + } + + fn poll_warnings(&mut self, ctx: &mut LoadingContext<'_>, events: &mut Vec) { + let mut waiting = Vec::new(); + for (ticket, job) in std::mem::take(&mut self.warnings) { + let Some(adapter) = ctx.registry.adapter(&ticket.platform_id) else { + continue; + }; + match job { + WarningJob::SteamBans { plan, mut pending } => match pending.poll() { + Poll::Waiting => { + waiting.push((ticket, WarningJob::SteamBans { plan, pending })); + } + Poll::Ready(fetched) => { + let load = adapter.steam_ban_finish(&plan, fetched, ctx.stores); + self.land_warnings(&ticket, load, events); + } + Poll::Gone => {} + }, + WarningJob::RobloxProbe(mut pending) => match pending.poll() { + Poll::Waiting => waiting.push((ticket, WarningJob::RobloxProbe(pending))), + Poll::Ready(result) => { + let load = adapter.state().roblox_sessions.finish_probe(result); + self.land_warnings(&ticket, load, events); + } + Poll::Gone => {} + }, + } + } + self.warnings.extend(waiting); + } +} diff --git a/crates/accshift-app/src/accounts/mod.rs b/crates/accshift-app/src/accounts/mod.rs new file mode 100644 index 00000000..fa74cf46 --- /dev/null +++ b/crates/accshift-app/src/accounts/mod.rs @@ -0,0 +1,82 @@ +//! Account loading, profiles and avatars: what turns backend data into the +//! accounts a grid shows. +//! +//! - [`loader::AccountLoader`] (`useAccountLoader.svelte.ts`): the accounts +//! of the active tab, the current one, switch, add, removal, warnings and +//! the guards that keep stale results out. +//! - [`avatars`] (`useAvatarLoader.svelte.ts`): per account avatar state, and +//! the image fetch and cache that hands bytes or files to the UI. +//! - [`profile_cache`] (`shared/profileCache.ts` and the Steam and Roblox +//! `profileCache.ts`): the persisted profile caches with expiry, retries +//! and in-flight sharing. +//! - [`priming::VisiblePriming`] (`useVisiblePriming.svelte.ts`). +//! - [`refresh`] (`useProfileRefresh.ts`): the settings "refresh now" runs. +//! - [`loading::AccountLoading`] (`useAccountLoading.svelte.ts` and +//! `detectedPlatforms.svelte.ts`): drives the loader through [`crate::jobs`] +//! and turns finished jobs into [`LoadingEvent`]s. +//! +//! Svelte state becomes plain fields; each async TS function becomes a +//! `begin_*` that returns a ticket, the blocking work run on the job pool, +//! and a `finish_*` that applies the result on the UI thread. Every ticket +//! carries the generation counter TS compares after each `await`. + +pub mod avatars; +pub mod loader; +pub mod loading; +pub mod priming; +pub mod profile_cache; +pub mod refresh; + +use crate::platforms::AddFlowStatus; +use crate::text::Text; + +/// `addToast(..., { type })`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ToastKind { + /// No `type` given. + #[default] + Default, + Success, + Info, + Error, +} + +/// A toast the account code asks the UI to show. +#[derive(Debug, Clone, PartialEq)] +pub struct Toast { + pub text: Text, + pub kind: ToastKind, +} + +impl Toast { + pub fn new(text: Text, kind: ToastKind) -> Self { + Self { text, kind } + } +} + +/// What [`loading::AccountLoading::poll`] reports to the shell. +#[derive(Debug, Clone, PartialEq)] +pub enum LoadingEvent { + Toast(Toast), + /// A load of `platform_id` landed and its folder sync ran: refresh the + /// navigation items and the grid padding (`onAfterLoad`). + AccountsLoaded { + platform_id: String, + }, + /// `addFlow.start(platformId, status)`. + AddFlowStarted { + platform_id: String, + status: AddFlowStatus, + }, + /// Steam: re-check the account just left on the CS2 bridge. + Cs2Check { + steam_id: String, + }, + /// `invoke("minimize_window")` after a switch, when the setting asks. + MinimizeWindow, + /// A switch finished; `succeeded` is what `handleAccountSwitch` returns. + SwitchFinished { + account_id: String, + succeeded: bool, + }, +} diff --git a/crates/accshift-app/src/accounts/priming.rs b/crates/accshift-app/src/accounts/priming.rs new file mode 100644 index 00000000..6b116786 --- /dev/null +++ b/crates/accshift-app/src/accounts/priming.rs @@ -0,0 +1,121 @@ +//! Profile priming of the accounts on screen, ported from +//! `src/lib/app/useVisiblePriming.svelte.ts`. +//! +//! The 120 ms debounce timer becomes a deadline: [`VisiblePriming::poll`] +//! fires it once passed and hands back what the timer callback did, for the +//! caller to run on the loader. Time is an [`Instant`] passed in, so tests +//! drive it without sleeping. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// `VISIBLE_PRIME_DEBOUNCE_MS`. +pub const VISIBLE_PRIME_DEBOUNCE: Duration = Duration::from_millis(120); + +/// `VISIBLE_PRIME_TTL_MS`: how long an account primed on screen stays primed. +pub const VISIBLE_PRIME_TTL: Duration = Duration::from_secs(5 * 60); + +/// What the timer callback does: `prepareAccountIds(visible_ids)`, then +/// `primeAccountIds(due_ids, true, false, true, true)` when `due_ids` is not +/// empty. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrimeRequest { + pub visible_ids: Vec, + pub due_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Scheduled { + due: Instant, + visible_ids: Vec, + due_ids: Vec, +} + +/// `createVisiblePriming`. +#[derive(Debug, Clone, Default)] +pub struct VisiblePriming { + timer: Option, + last_prepared_visible_key: String, + primed_at: HashMap, +} + +impl VisiblePriming { + pub fn new() -> Self { + Self::default() + } + + /// `reset`: forget everything, the accounts on screen belong to another + /// load or tab. + pub fn reset(&mut self) { + self.timer = None; + self.last_prepared_visible_key.clear(); + self.primed_at.clear(); + } + + /// `pause`: the grid is out of sight. What was primed stays primed. + pub fn pause(&mut self) { + self.timer = None; + self.last_prepared_visible_key.clear(); + } + + /// `destroy`. + pub fn destroy(&mut self) { + self.timer = None; + } + + /// `processVisible`: call whenever the inputs may have changed (every + /// frame is fine, the visible key deduplicates). + pub fn process_visible( + &mut self, + visible_ids: &[String], + active_tab: &str, + is_searching: bool, + now: Instant, + ) { + let mut sorted = visible_ids.to_vec(); + sorted.sort(); + let key = format!( + "{active_tab}:{}:{}", + if is_searching { "search" } else { "folder" }, + sorted.join(",") + ); + if key == self.last_prepared_visible_key { + return; + } + self.last_prepared_visible_key = key; + let due_ids = visible_ids + .iter() + .filter(|id| { + self.primed_at + .get(*id) + .is_none_or(|primed| *primed + VISIBLE_PRIME_TTL < now) + }) + .cloned() + .collect(); + self.timer = Some(Scheduled { + due: now + VISIBLE_PRIME_DEBOUNCE, + visible_ids: visible_ids.to_vec(), + due_ids, + }); + } + + /// When [`VisiblePriming::poll`] has something to fire. + pub fn next_deadline(&self) -> Option { + self.timer.as_ref().map(|timer| timer.due) + } + + /// Fires the timer once its deadline passed, marking its due ids primed. + pub fn poll(&mut self, now: Instant) -> Option { + if self.timer.as_ref().is_none_or(|timer| timer.due > now) { + return None; + } + let timer = self.timer.take()?; + for id in &timer.due_ids { + self.primed_at.insert(id.clone(), now); + } + Some(PrimeRequest { + visible_ids: timer.visible_ids, + due_ids: timer.due_ids, + }) + } +} diff --git a/crates/accshift-app/src/accounts/profile_cache.rs b/crates/accshift-app/src/accounts/profile_cache.rs new file mode 100644 index 00000000..7e8f234c --- /dev/null +++ b/crates/accshift-app/src/accounts/profile_cache.rs @@ -0,0 +1,776 @@ +//! The persisted profile caches, ported from `src/lib/shared/profileCache.ts` +//! and the two caches built on it (`platforms/steam/profileCache.ts`, +//! `platforms/roblox/profileCache.ts`). +//! +//! The cache lives in a client store, which the UI thread owns, while fetches +//! run on the job pool. So a fetch does not write the store: it returns the +//! [`CacheWrite`]s TS would have made, in order, and the UI applies them with +//! [`ProfileCache::apply_writes`]. Reads ([`ProfileCache::cached_profile`]) +//! keep the TS revision memo. The in-flight map is shared between job threads +//! ([`InFlight`]): a second request for an id already being fetched waits for +//! that fetch instead of starting one, in both the unit and the batch path. + +use crate::backend::Backend; +use crate::error::BackendResult; +use crate::platforms::{CachedPlatformProfile, PlatformProfileInfo}; +use crate::storage::{ClientStorage, StoreId}; +use crate::types::{ProfileInfo, RobloxProfileInfo}; +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::time::Duration; + +/// Which avatar URLs a cache and the avatar images accept. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UrlPolicy { + /// `isSafeHttpUrl`: `http:` and `https:` only. The real backend. + #[default] + HttpOnly, + /// Also local files. The mock: its avatars are files under + /// `src/lib/mock/avatars/`, where the Tauri mock served them from the + /// Vite dev server over `http:`. + HttpOrFile, +} + +impl UrlPolicy { + /// An absolute path, a `file://` URL, or a repo-relative mock avatar. + pub fn is_local_file(url: &str) -> bool { + let path = url.strip_prefix("file://").unwrap_or(url); + !path.is_empty() + && (std::path::Path::new(path).is_absolute() + || path.starts_with('/') + || path.starts_with(crate::mock::AVATAR_DIR)) + } + + pub fn accepts(self, url: &str) -> bool { + crate::platforms::steam::is_safe_http_url(url) + || (self == UrlPolicy::HttpOrFile && Self::is_local_file(url)) + } +} + +/// `CacheEntry`. `url: None` is a negative entry: fetched, no avatar. +#[derive(Debug, Clone, PartialEq)] +pub struct CacheEntry { + pub url: Option, + pub display_name: Option, + /// Unix milliseconds. + pub timestamp: f64, +} + +impl CacheEntry { + fn to_value(&self) -> Value { + let mut out = Map::new(); + out.insert( + "url".into(), + self.url.clone().map_or(Value::Null, Value::String), + ); + if let Some(name) = &self.display_name { + out.insert("displayName".into(), Value::String(name.clone())); + } + out.insert( + "timestamp".into(), + serde_json::Number::from_f64(self.timestamp).map_or(Value::Null, Value::Number), + ); + Value::Object(out) + } +} + +/// One store write a fetch made, to replay on the UI thread. +#[derive(Debug, Clone, PartialEq)] +pub struct CacheWrite { + pub account_id: String, + pub entry: CacheEntry, +} + +/// `ProfileCacheOptions` minus the fetchers, which the call sites pass. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheConfig { + pub store_id: StoreId, + /// Total fetch attempts. + pub attempts: u32, + pub retry_delay: Duration, + /// Store `url: None` when every attempt returned a profile without a + /// usable avatar. + pub cache_negative_results: bool, +} + +/// `platforms/steam/profileCache.ts`: four attempts 750 ms apart, then a +/// negative entry (profile data can lag right after Steam writes +/// `loginusers.vdf`; private profiles expose no avatar). +pub const STEAM_PROFILE_CACHE: CacheConfig = CacheConfig { + store_id: crate::storage::CLIENT_STORE_STEAM_PROFILE_CACHE, + attempts: 4, + retry_delay: Duration::from_millis(750), + cache_negative_results: true, +}; + +/// `platforms/roblox/profileCache.ts`: one attempt, no negative entries. +pub const ROBLOX_PROFILE_CACHE: CacheConfig = CacheConfig { + store_id: crate::storage::CLIENT_STORE_ROBLOX_PROFILE_CACHE, + attempts: 1, + retry_delay: Duration::ZERO, + cache_negative_results: false, +}; + +/// `getAvatarUrl` and `getDisplayName` of a cache. +pub trait CachedProfile: Clone + Send + 'static { + fn avatar_url(&self) -> Option<&str>; + fn display_name(&self) -> Option<&str> { + None + } +} + +impl CachedProfile for ProfileInfo { + fn avatar_url(&self) -> Option<&str> { + self.avatar_url.as_deref() + } + fn display_name(&self) -> Option<&str> { + self.display_name.as_deref() + } +} + +impl CachedProfile for RobloxProfileInfo { + fn avatar_url(&self) -> Option<&str> { + self.avatar_url.as_deref() + } +} + +/// What expiry compares against: `cacheDurationProvider()` and the session +/// start. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CacheExpiry { + /// `getCacheDuration()`; 0 expires every entry older than the session. + pub duration_ms: u64, + /// `SESSION_START_MS`. + pub session_start_ms: f64, + pub now_ms: f64, +} + +impl CacheExpiry { + fn expired(&self, timestamp: f64) -> bool { + if self.duration_ms == 0 { + timestamp < self.session_start_ms + } else { + self.now_ms - timestamp > self.duration_ms as f64 + } + } +} + +// ------------------------------------------------------------ in flight + +#[derive(Debug)] +struct Slot

{ + value: Mutex>>, + ready: Condvar, +} + +impl Slot

{ + fn new() -> Self { + Self { + value: Mutex::new(None), + ready: Condvar::new(), + } + } + + fn set(&self, value: Option

) { + let mut guard = lock(&self.value); + *guard = Some(value); + self.ready.notify_all(); + } + + fn wait(&self) -> Option

{ + let mut guard = lock(&self.value); + loop { + if let Some(value) = guard.as_ref() { + return value.clone(); + } + guard = self + .ready + .wait(guard) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + } + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Fetch slots keyed by account id. +type Claimed

= Vec<(String, Arc>)>; + +/// A batch fetcher: profiles keyed by id for a list of ids. +pub type BatchFetcher<'a, P> = &'a dyn Fn(&[String]) -> BackendResult>; + +/// `inFlight`: the fetches running now, shared between job threads. +#[derive(Debug)] +pub struct InFlight

{ + slots: Mutex>>>, +} + +impl

Default for InFlight

{ + fn default() -> Self { + Self { + slots: Mutex::new(HashMap::new()), + } + } +} + +impl InFlight

{ + pub fn len(&self) -> usize { + lock(&self.slots).len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Claims every id nobody fetches yet; the others come back as slots to + /// wait on. + fn claim(&self, ids: &[String]) -> (Claimed

, Claimed

) { + let mut slots = lock(&self.slots); + let mut mine = Vec::new(); + let mut theirs = Vec::new(); + for id in ids { + match slots.get(id) { + Some(slot) => theirs.push((id.clone(), Arc::clone(slot))), + None => { + let slot = Arc::new(Slot::new()); + slots.insert(id.clone(), Arc::clone(&slot)); + mine.push((id.clone(), slot)); + } + } + } + (mine, theirs) + } + + fn release(&self, id: &str, slot: &Arc>, value: Option

) { + slot.set(value); + let mut slots = lock(&self.slots); + if slots + .get(id) + .is_some_and(|current| Arc::ptr_eq(current, slot)) + { + slots.remove(id); + } + } +} + +// ------------------------------------------------------------ cache + +/// One fetch's answer: the profiles and the cache writes to replay. +#[derive(Debug, Clone, PartialEq)] +pub struct Fetched { + pub value: T, + pub writes: Vec, +} + +/// `createProfileCache`. +#[derive(Debug)] +pub struct ProfileCache

{ + config: CacheConfig, + policy: UrlPolicy, + /// `cachedEntries` and `cachedRevision`. + memo: Mutex)>>, + in_flight: InFlight

, +} + +impl ProfileCache

{ + pub fn new(config: CacheConfig, policy: UrlPolicy) -> Self { + Self { + config, + policy, + memo: Mutex::new(None), + in_flight: InFlight::default(), + } + } + + pub fn config(&self) -> &CacheConfig { + &self.config + } + + pub fn policy(&self) -> UrlPolicy { + self.policy + } + + pub fn in_flight(&self) -> &InFlight

{ + &self.in_flight + } + + /// `sanitizeEntry`. + fn sanitize_entry(&self, value: &Value) -> Option { + let raw = value.as_object()?; + let url = raw.get("url"); + let valid = match url { + Some(Value::Null) => self.config.cache_negative_results, + Some(Value::String(text)) => { + !crate::js::trim(text).is_empty() && self.policy.accepts(text) + } + _ => false, + }; + if !valid { + return None; + } + let timestamp = crate::js::to_number(raw.get("timestamp")); + if !timestamp.is_finite() || timestamp < 0.0 { + return None; + } + Some(CacheEntry { + url: url.and_then(Value::as_str).map(str::to_string), + display_name: raw + .get("displayName") + .and_then(Value::as_str) + .map(str::to_string), + timestamp, + }) + } + + fn sanitize(&self, value: Option<&Value>) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(Value::Object(map)) = value else { + return out; + }; + for (account_id, entry) in map { + if crate::js::trim(account_id).is_empty() { + continue; + } + if let Some(entry) = self.sanitize_entry(entry) { + out.insert(account_id.clone(), entry); + } + } + out + } + + /// `getCache`: the sanitized entries, rebuilt only when the store's + /// revision moved. + pub fn entries(&self, storage: &ClientStorage) -> BTreeMap { + let revision = storage.revision(self.config.store_id); + let mut memo = lock(&self.memo); + if let Some((seen, entries)) = memo.as_ref() { + if *seen == revision { + return entries.clone(); + } + } + let entries = self.sanitize(storage.get(self.config.store_id)); + *memo = Some((revision, entries.clone())); + entries + } + + fn entry(&self, storage: &ClientStorage, account_id: &str) -> Option { + let revision = storage.revision(self.config.store_id); + { + let memo = lock(&self.memo); + if let Some((seen, entries)) = memo.as_ref() { + if *seen == revision { + return entries.get(account_id).cloned(); + } + } + } + self.entries(storage).get(account_id).cloned() + } + + /// `getCachedProfile`. + pub fn cached_profile( + &self, + storage: &ClientStorage, + account_id: &str, + expiry: &CacheExpiry, + ) -> Option { + let entry = self.entry(storage, account_id)?; + Some(CachedPlatformProfile { + url: entry.url, + display_name: entry.display_name, + expired: expiry.expired(entry.timestamp), + }) + } + + /// `saveCache`. + fn save(&self, storage: &mut ClientStorage, entries: BTreeMap) { + let value = Value::Object( + entries + .iter() + .map(|(id, entry)| (id.clone(), entry.to_value())) + .collect(), + ); + storage.set(self.config.store_id, value); + *lock(&self.memo) = Some((storage.revision(self.config.store_id), entries)); + } + + /// `setCachedProfile`: ignored for a URL the policy refuses. + pub fn set_cached_profile( + &self, + storage: &mut ClientStorage, + account_id: &str, + url: &str, + display_name: Option<&str>, + now_ms: f64, + ) { + if !self.policy.accepts(url) { + return; + } + self.apply_writes( + storage, + vec![CacheWrite { + account_id: account_id.to_string(), + entry: CacheEntry { + url: Some(url.to_string()), + display_name: display_name.map(str::to_string), + timestamp: now_ms, + }, + }], + ); + } + + /// Replays a fetch's writes, in order, in one store write. + pub fn apply_writes(&self, storage: &mut ClientStorage, writes: Vec) { + if writes.is_empty() { + return; + } + let mut entries = self.entries(storage); + for write in writes { + entries.insert(write.account_id, write.entry); + } + self.save(storage, entries); + } + + fn has_safe_avatar_url(&self, profile: &P) -> bool { + let avatar = crate::js::trim(profile.avatar_url().unwrap_or_default()); + !avatar.is_empty() && self.policy.accepts(avatar) + } + + fn positive_write(&self, account_id: &str, profile: &P, now_ms: f64) -> Option { + if !self.has_safe_avatar_url(profile) { + return None; + } + Some(CacheWrite { + account_id: account_id.to_string(), + entry: CacheEntry { + url: profile.avatar_url().map(str::to_string), + display_name: profile.display_name().map(str::to_string), + timestamp: now_ms, + }, + }) + } + + fn negative_write(&self, account_id: &str, profile: &P, now_ms: f64) -> CacheWrite { + CacheWrite { + account_id: account_id.to_string(), + entry: CacheEntry { + url: None, + display_name: profile.display_name().map(str::to_string), + timestamp: now_ms, + }, + } + } + + fn pause(&self, attempt: u32) { + if attempt + 1 < self.config.attempts && !self.config.retry_delay.is_zero() { + std::thread::sleep(self.config.retry_delay); + } + } + + /// `fetchWithRetries`. + fn fetch_with_retries( + &self, + account_id: &str, + fetcher: &dyn Fn(&str) -> BackendResult>, + now_ms: &dyn Fn() -> f64, + writes: &mut Vec, + ) -> Option

{ + let mut last = None; + for attempt in 0..self.config.attempts { + if let Ok(Some(profile)) = fetcher(account_id) { + writes.extend(self.positive_write(account_id, &profile, now_ms())); + let safe = self.has_safe_avatar_url(&profile); + last = Some(profile); + if safe { + return last; + } + } + self.pause(attempt); + } + if self.config.cache_negative_results { + if let Some(profile) = &last { + writes.push(self.negative_write(account_id, profile, now_ms())); + } + } + last + } + + /// `fetchProfile`: one account, deduplicated against every fetch in + /// flight. Blocks; run it on the job pool. + pub fn fetch_profile( + &self, + account_id: &str, + fetcher: &dyn Fn(&str) -> BackendResult>, + now_ms: &dyn Fn() -> f64, + ) -> Fetched> { + let ids = [account_id.to_string()]; + let (mine, theirs) = self.in_flight.claim(&ids); + if let Some((_, slot)) = theirs.into_iter().next() { + return Fetched { + value: slot.wait(), + writes: Vec::new(), + }; + } + let mut writes = Vec::new(); + let value = self.fetch_with_retries(account_id, fetcher, now_ms, &mut writes); + if let Some((id, slot)) = mine.into_iter().next() { + self.in_flight.release(&id, &slot, value.clone()); + } + Fetched { value, writes } + } + + /// `batchFetchWithRetries`: retries only the ids without a usable avatar. + fn batch_fetch_with_retries( + &self, + account_ids: &[String], + batch: BatchFetcher<'_, P>, + now_ms: &dyn Fn() -> f64, + writes: &mut Vec, + ) -> BTreeMap> { + let mut results: BTreeMap> = + account_ids.iter().map(|id| (id.clone(), None)).collect(); + let mut remaining: Vec = account_ids.to_vec(); + for attempt in 0..self.config.attempts { + if let Ok(fetched) = batch(&remaining) { + for (account_id, profile) in fetched { + if !results.contains_key(&account_id) { + continue; + } + writes.extend(self.positive_write(&account_id, &profile, now_ms())); + results.insert(account_id, Some(profile)); + } + } + remaining.retain(|id| { + results + .get(id) + .and_then(Option::as_ref) + .is_none_or(|profile| !self.has_safe_avatar_url(profile)) + }); + if remaining.is_empty() { + break; + } + self.pause(attempt); + } + if self.config.cache_negative_results { + for account_id in account_ids { + if let Some(Some(profile)) = results.get(account_id) { + if !self.has_safe_avatar_url(profile) { + writes.push(self.negative_write(account_id, profile, now_ms())); + } + } + } + } + results + } + + /// `fetchProfiles`: many accounts through `batch` when given, else one + /// [`ProfileCache::fetch_profile`] per id. Ids already in flight are + /// waited on, not fetched again. Blocks; run it on the job pool. + pub fn fetch_profiles( + &self, + account_ids: &[String], + batch: Option>, + fetcher: &dyn Fn(&str) -> BackendResult>, + now_ms: &dyn Fn() -> f64, + ) -> Fetched>> { + let mut unique: Vec = Vec::new(); + for id in account_ids { + if !unique.contains(id) { + unique.push(id.clone()); + } + } + let mut writes = Vec::new(); + let mut value = BTreeMap::new(); + let Some(batch) = batch else { + for id in &unique { + let fetched = self.fetch_profile(id, fetcher, now_ms); + writes.extend(fetched.writes); + value.insert(id.clone(), fetched.value); + } + return Fetched { value, writes }; + }; + let (mine, theirs) = self.in_flight.claim(&unique); + if !mine.is_empty() { + let ids: Vec = mine.iter().map(|(id, _)| id.clone()).collect(); + let results = self.batch_fetch_with_retries(&ids, batch, now_ms, &mut writes); + for (id, slot) in mine { + let profile = results.get(&id).cloned().flatten(); + self.in_flight.release(&id, &slot, profile.clone()); + value.insert(id, profile); + } + } + for (id, slot) in theirs { + value.insert(id, slot.wait()); + } + Fetched { value, writes } + } +} + +// ------------------------------------------------------------ the two caches + +/// The Steam and Roblox caches of a session, plus the platform profile +/// mapping (`toPlatformProfileInfo`) under the same URL policy. +#[derive(Debug)] +pub struct ProfileCaches { + pub steam: ProfileCache, + pub roblox: ProfileCache, + /// `SESSION_START_MS`. + pub session_start_ms: f64, +} + +impl ProfileCaches { + pub fn new(policy: UrlPolicy, session_start_ms: f64) -> Self { + Self { + steam: ProfileCache::new(STEAM_PROFILE_CACHE, policy), + roblox: ProfileCache::new(ROBLOX_PROFILE_CACHE, policy), + session_start_ms, + } + } + + pub fn policy(&self) -> UrlPolicy { + self.steam.policy() + } + + /// The expiry rule for `now_ms` with the settings' cache duration. + pub fn expiry(&self, duration_ms: u64, now_ms: f64) -> CacheExpiry { + CacheExpiry { + duration_ms, + session_start_ms: self.session_start_ms, + now_ms, + } + } + + /// `adapter.getCachedProfile(id)`: Steam and Roblox only. + pub fn cached_profile( + &self, + platform_id: &str, + storage: &ClientStorage, + account_id: &str, + expiry: &CacheExpiry, + ) -> Option { + match platform_id { + "steam" => self.steam.cached_profile(storage, account_id, expiry), + "roblox" => self.roblox.cached_profile(storage, account_id, expiry), + _ => None, + } + } + + /// Whether `platform_id` has a cache here. + pub fn has_cache(platform_id: &str) -> bool { + matches!(platform_id, "steam" | "roblox") + } + + /// Replays a fetch's writes into the platform's store. + pub fn apply_writes( + &self, + platform_id: &str, + storage: &mut ClientStorage, + writes: Vec, + ) { + match platform_id { + "steam" => self.steam.apply_writes(storage, writes), + "roblox" => self.roblox.apply_writes(storage, writes), + _ => {} + } + } + + /// Steam's `toPlatformProfileInfo` under this policy. + pub fn steam_profile_info(&self, profile: Option<&ProfileInfo>) -> Option { + let profile = profile?; + let avatar = crate::js::trim(profile.avatar_url.as_deref().unwrap_or_default()); + Some(PlatformProfileInfo { + avatar_url: (!avatar.is_empty() && self.policy().accepts(avatar)) + .then(|| avatar.to_string()), + display_name: profile.display_name.clone(), + avatar_loading: false, + }) + } + + /// Roblox's `getProfileInfo` mapping under this policy. + pub fn roblox_profile_info( + &self, + info: Option<&RobloxProfileInfo>, + ) -> Option { + let info = info?; + Some(PlatformProfileInfo { + avatar_url: info + .avatar_url + .as_deref() + .filter(|url| !url.is_empty() && self.policy().accepts(url)) + .map(str::to_string), + display_name: None, + avatar_loading: false, + }) + } + + /// `adapter.getProfileInfo(id)` through the caches: Steam and Roblox + /// fetch with their cache rules, Riot reads its profile cache, others + /// have none. Blocks. + pub fn profile_info( + &self, + adapter: &crate::platforms::PlatformAdapter, + backend: &dyn Backend, + account_id: &str, + now_ms: &dyn Fn() -> f64, + ) -> Fetched> { + match adapter.id() { + "steam" => { + let fetched = self.steam.fetch_profile( + account_id, + &|id| backend.steam_get_profile_info(id), + now_ms, + ); + Fetched { + value: self.steam_profile_info(fetched.value.as_ref()), + writes: fetched.writes, + } + } + "roblox" => { + let fetched = self.roblox.fetch_profile( + account_id, + &|id| backend.roblox_get_profile_info(id).map(Some), + now_ms, + ); + Fetched { + value: self.roblox_profile_info(fetched.value.as_ref()), + writes: fetched.writes, + } + } + _ => Fetched { + value: adapter.profile_info(backend, account_id).ok().flatten(), + writes: Vec::new(), + }, + } + } + + /// `adapter.getProfileInfos(ids)`: Steam's batch path. `None` for other + /// platforms (they have no batch fetcher). Blocks. + pub fn steam_profile_infos( + &self, + backend: &dyn Backend, + account_ids: &[String], + now_ms: &dyn Fn() -> f64, + ) -> Fetched>> { + let fetched = self.steam.fetch_profiles( + account_ids, + Some(&|ids: &[String]| backend.steam_get_profile_infos(ids)), + &|id| backend.steam_get_profile_info(id), + now_ms, + ); + let value = account_ids + .iter() + .map(|id| { + let profile = fetched.value.get(id).cloned().flatten(); + (id.clone(), self.steam_profile_info(profile.as_ref())) + }) + .collect(); + Fetched { + value, + writes: fetched.writes, + } + } +} diff --git a/crates/accshift-app/src/accounts/refresh.rs b/crates/accshift-app/src/accounts/refresh.rs new file mode 100644 index 00000000..e7ef1f0b --- /dev/null +++ b/crates/accshift-app/src/accounts/refresh.rs @@ -0,0 +1,443 @@ +//! The settings "refresh now" actions for avatars and bans, ported from +//! `src/lib/app/useProfileRefresh.ts`. +//! +//! The TS loop over platforms is [`refresh_avatars_now`] and +//! [`refresh_bans_now`], generic over [`RefreshEnv`] (what the loop asks of +//! the app and its adapters). The whole run blocks, so the shell runs it on the job pool +//! with [`run_refresh_job`]: the adapter calls go to the backend, the client +//! stores the warning loads write are a copy, and the UI applies what came +//! back with [`apply_refresh_job`]. + +use super::loader::LoadOptions; +use super::profile_cache::{CacheWrite, ProfileCaches}; +use super::{Toast, ToastKind}; +use crate::backend::Backend; +use crate::error::BackendError; +use crate::platforms::warnings::WarningLoad; +use crate::platforms::{ + PlatformAccount, PlatformAdapter, PlatformDef, PlatformProfileInfo, PlatformRegistry, + WarningLoadOptions, WarningRefreshOutcome, +}; +use crate::settings::AppSettings; +use crate::storage::{StoreId, CLIENT_STORE_IDS}; +use crate::stores::ClientStores; +use crate::text::Text; +use serde_json::Value; +use std::sync::Arc; + +/// `AvatarRefreshOutcome`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AvatarRefreshOutcome { + Complete { count: usize }, + Failed, + Partial { ok: usize, count: usize }, +} + +/// `avatarRefreshOutcome`: an error or a `None` is a miss (an adapter answers +/// `None` only for a failed fetch). +pub fn avatar_refresh_outcome( + settled: &[Result, BackendError>], +) -> AvatarRefreshOutcome { + let count = settled.len(); + let ok = settled + .iter() + .filter(|result| matches!(result, Ok(Some(_)))) + .count(); + if ok == count { + AvatarRefreshOutcome::Complete { count } + } else if ok == 0 { + AvatarRefreshOutcome::Failed + } else { + AvatarRefreshOutcome::Partial { ok, count } + } +} + +/// What the refresh loop asks of the app: `ensureAdapterReady` and the +/// adapter methods it calls. +pub trait RefreshEnv { + type Adapter; + /// `ensureAdapterReady`. + fn ensure(&mut self, platform_id: &str) -> Option; + /// `getProfileInfo` exists. + fn has_profile_info(&self, adapter: &Self::Adapter) -> bool; + /// `loadWarningStates` exists. + fn has_warnings(&self, adapter: &Self::Adapter) -> bool; + fn load_accounts( + &mut self, + adapter: &Self::Adapter, + ) -> Result, BackendError>; + fn profile_info( + &mut self, + adapter: &Self::Adapter, + account_id: &str, + ) -> Result, BackendError>; + /// `loadWarningStates(accounts, { forceRefresh, silent })`. + fn load_warning_states( + &mut self, + adapter: &Self::Adapter, + accounts: &[PlatformAccount], + options: WarningLoadOptions, + ) -> Result; + /// `getNoAccountsToastMessage`. + fn no_accounts_toast(&self, adapter: &Self::Adapter) -> Option; +} + +/// What a run asks the UI to do. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RefreshReport { + pub toasts: Vec, + /// `void loadAccounts(...)` on the active tab, with these flags. + pub reload_active: Option, +} + +fn toast(report: &mut RefreshReport, text: Text, kind: ToastKind) { + report.toasts.push(Toast::new(text, kind)); +} + +/// `refreshAvatarsNow`. `ensure` is `ensureAdapterReady`. +pub fn refresh_avatars_now( + defs: &[PlatformDef], + active_tab: &str, + env: &mut E, +) -> RefreshReport { + let mut report = RefreshReport::default(); + for def in defs { + if !def + .capabilities + .profile_refresh + .is_some_and(|refresh| refresh.avatars) + { + continue; + } + let Some(adapter) = env.ensure(&def.id) else { + continue; + }; + if !env.has_profile_info(&adapter) { + continue; + } + let accounts = match env.load_accounts(&adapter) { + Ok(accounts) => accounts, + Err(_) => { + toast( + &mut report, + Text::key("toast.refreshFailed"), + ToastKind::Error, + ); + continue; + } + }; + if accounts.is_empty() { + if let Some(text) = env.no_accounts_toast(&adapter) { + toast(&mut report, text, ToastKind::Default); + } + continue; + } + let settled: Vec<_> = accounts + .iter() + .map(|account| env.profile_info(&adapter, &account.id)) + .collect(); + match avatar_refresh_outcome(&settled) { + AvatarRefreshOutcome::Failed => { + toast( + &mut report, + Text::key("toast.refreshFailed"), + ToastKind::Error, + ); + continue; + } + outcome => { + if active_tab == def.id { + report.reload_active = Some(LoadOptions { + silent: true, + force_refresh: true, + ..LoadOptions::default() + }); + } + match outcome { + AvatarRefreshOutcome::Partial { ok, count } => toast( + &mut report, + Text::with( + "toast.refreshPartial", + [("ok", ok as f64), ("count", count as f64)], + ), + ToastKind::Info, + ), + _ => toast( + &mut report, + Text::with( + "toast.avatarRefreshComplete", + [("count", accounts.len() as f64)], + ), + ToastKind::Success, + ), + } + } + } + } + report +} + +/// `refreshBansNow`. +pub fn refresh_bans_now( + defs: &[PlatformDef], + active_tab: &str, + env: &mut E, +) -> RefreshReport { + let mut report = RefreshReport::default(); + for def in defs { + if !def + .capabilities + .profile_refresh + .is_some_and(|refresh| refresh.bans) + { + continue; + } + let Some(adapter) = env.ensure(&def.id) else { + continue; + }; + if !env.has_warnings(&adapter) { + continue; + } + let accounts = match env.load_accounts(&adapter) { + Ok(accounts) => accounts, + Err(_) => { + toast( + &mut report, + Text::key("toast.refreshFailed"), + ToastKind::Error, + ); + continue; + } + }; + if accounts.is_empty() { + if let Some(text) = env.no_accounts_toast(&adapter) { + toast(&mut report, text, ToastKind::Default); + } + continue; + } + let load = match env.load_warning_states( + &adapter, + &accounts, + WarningLoadOptions { + force_refresh: true, + silent: false, + }, + ) { + Ok(load) => load, + Err(_) => { + toast( + &mut report, + Text::key("toast.refreshFailed"), + ToastKind::Error, + ); + continue; + } + }; + // The adapter's own toasts (its error toast among them). + for request in &load.toasts { + let kind = if request.error { + ToastKind::Error + } else { + ToastKind::Default + }; + toast(&mut report, request.text.clone(), kind); + } + let outcome = load.outcome.unwrap_or(WarningRefreshOutcome::Complete { + checked: accounts.len(), + }); + match outcome { + // The adapter already showed its error toast. + WarningRefreshOutcome::Failed => continue, + WarningRefreshOutcome::NoApiKey => { + toast( + &mut report, + Text::key("toast.banRefreshNoApiKey"), + ToastKind::Error, + ); + continue; + } + _ => {} + } + if active_tab == def.id { + report.reload_active = Some(LoadOptions { + silent: true, + check_bans: true, + ..LoadOptions::default() + }); + } + if let WarningRefreshOutcome::Partial { checked, requested } = outcome { + toast( + &mut report, + Text::with( + "toast.refreshPartial", + [("ok", checked as f64), ("count", requested as f64)], + ), + ToastKind::Info, + ); + continue; + } + toast( + &mut report, + Text::with( + "toast.banRefreshComplete", + [("count", accounts.len() as f64)], + ), + ToastKind::Success, + ); + } + report +} + +// ------------------------------------------------------------ real adapters + +/// [`RefreshEnv`] over the real adapters, for a run on the job pool. +pub struct AdapterEnv<'a> { + pub registry: &'a PlatformRegistry, + pub backend: &'a dyn Backend, + pub caches: &'a ProfileCaches, + /// A copy of the client stores; the Steam ban load writes its caches here. + pub stores: ClientStores, + pub settings: &'a AppSettings, + pub now_ms: &'a dyn Fn() -> f64, + /// Profile cache writes, by platform. + pub writes: Vec<(String, Vec)>, +} + +impl RefreshEnv for AdapterEnv<'_> { + type Adapter = Arc; + + fn ensure(&mut self, platform_id: &str) -> Option { + self.registry.adapter(platform_id) + } + + fn has_profile_info(&self, adapter: &Self::Adapter) -> bool { + has_profile_info(adapter) + } + + fn has_warnings(&self, adapter: &Self::Adapter) -> bool { + adapter.has_warnings() + } + + fn load_accounts( + &mut self, + adapter: &Self::Adapter, + ) -> Result, BackendError> { + adapter.load_accounts(self.backend) + } + + fn profile_info( + &mut self, + adapter: &Self::Adapter, + account_id: &str, + ) -> Result, BackendError> { + let fetched = self + .caches + .profile_info(adapter, self.backend, account_id, self.now_ms); + self.writes.push((adapter.id().to_string(), fetched.writes)); + Ok(fetched.value) + } + + fn load_warning_states( + &mut self, + adapter: &Self::Adapter, + accounts: &[PlatformAccount], + options: WarningLoadOptions, + ) -> Result { + Ok(adapter + .load_warning_states_blocking( + self.backend, + &mut self.stores, + self.settings, + accounts, + options, + (self.now_ms)(), + ) + .unwrap_or_default()) + } + + fn no_accounts_toast(&self, adapter: &Self::Adapter) -> Option { + adapter.no_accounts_toast() + } +} + +/// Whether an adapter has `getProfileInfo`: Steam, Riot and Roblox. +pub fn has_profile_info(adapter: &PlatformAdapter) -> bool { + matches!(adapter.id(), "steam" | "riot" | "roblox") +} + +/// Which "refresh now" button. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefreshKind { + Avatars, + Bans, +} + +/// What [`run_refresh_job`] hands back to the UI thread. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RefreshJobResult { + pub report: RefreshReport, + pub cache_writes: Vec<(String, Vec)>, + /// Raw stores the run wrote in its copy (the Steam ban caches). + pub store_values: Vec<(StoreId, Value)>, +} + +/// A whole "refresh now" run, blocking: on the job pool, with a clone of the +/// client stores. +#[allow(clippy::too_many_arguments)] +pub fn run_refresh_job( + kind: RefreshKind, + registry: &PlatformRegistry, + active_tab: &str, + backend: &dyn Backend, + caches: &ProfileCaches, + stores: ClientStores, + settings: &AppSettings, + now_ms: &dyn Fn() -> f64, +) -> RefreshJobResult { + let before: Vec<(StoreId, u64)> = CLIENT_STORE_IDS + .iter() + .map(|id| (*id, stores.storage().revision(id))) + .collect(); + let defs = registry.defs().to_vec(); + let mut env = AdapterEnv { + registry, + backend, + caches, + stores, + settings, + now_ms, + writes: Vec::new(), + }; + let report = match kind { + RefreshKind::Avatars => refresh_avatars_now(&defs, active_tab, &mut env), + RefreshKind::Bans => refresh_bans_now(&defs, active_tab, &mut env), + }; + let stores = env.stores; + let store_values = before + .into_iter() + .filter(|(id, revision)| stores.storage().revision(id) != *revision) + .map(|(id, _)| (id, stores.storage().get(id).cloned().unwrap_or(Value::Null))) + .collect(); + RefreshJobResult { + report, + cache_writes: env.writes, + store_values, + } +} + +/// Applies a finished run on the UI thread: the profile cache writes, then +/// the raw stores it changed. Returns the run's report. +pub fn apply_refresh_job( + result: RefreshJobResult, + stores: &mut ClientStores, + caches: &ProfileCaches, +) -> RefreshReport { + for (platform_id, writes) in result.cache_writes { + caches.apply_writes(&platform_id, stores.storage_mut(), writes); + } + for (id, value) in result.store_values { + stores.storage_mut().set(id, value); + } + stores.sync(); + result.report +} diff --git a/crates/accshift-app/src/actions/avatar_fallback.rs b/crates/accshift-app/src/actions/avatar_fallback.rs new file mode 100644 index 00000000..b668eda9 --- /dev/null +++ b/crates/accshift-app/src/actions/avatar_fallback.rs @@ -0,0 +1,121 @@ +//! The fallback avatar of a card without a picture: initials over a gradient +//! derived from the account. Ported from `src/lib/shared/avatarFallback.ts`. + +use crate::js; +use std::collections::HashMap; + +/// `bitCycleHash`: every bit of every UTF-16 unit, low bit first. +fn bit_cycle_hash(text: &str, mult: u64, modulo: u64, seed: u64) -> u64 { + let mut acc = seed % modulo; + for unit in text.encode_utf16() { + for b in 0..16u64 { + let bit = (u64::from(unit) >> b) & 1; + acc = (acc * mult + bit + b) % modulo; + } + } + acc +} + +/// `normalizeSeed` without the NFKC step: trimmed, lowercase, whitespace +/// runs folded to one space. +fn normalize_seed(seed: &str) -> String { + js::trim(seed) + .to_lowercase() + .split_whitespace() + .collect::>() + .join(" ") +} + +/// `getAvatarInitials`: the first letter of the first two words, uppercase; +/// `?` for a blank name. +pub fn avatar_initials(name: &str) -> String { + let mut parts = name.split_whitespace(); + let Some(first) = parts.next() else { + return "?".to_string(); + }; + let mut initials: String = first.chars().take(1).collect(); + if let Some(second) = parts.next() { + initials.extend(second.chars().take(1)); + } + initials.to_uppercase() +} + +/// `getAvatarSeed`: the shown name (display name, else username, else +/// `unknown`), the id and the name reversed. +pub fn avatar_seed(display_name: &str, username: &str, account_id: &str) -> String { + let primary = if !display_name.is_empty() { + display_name + } else { + username + }; + let primary = js::trim(primary); + let stable = if primary.is_empty() { + "unknown" + } else { + primary + }; + let reversed: String = stable.chars().rev().collect(); + format!("{stable}::{account_id}::{reversed}") +} + +/// The two hues of the gradient, in degrees. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AvatarGradient { + /// `hsl(hue 72% 43%)` under the gradient, `hsl(hue 80% 56%)` at its start. + pub hue: u16, + /// `hsl(hue_fade 66% 38%)` at its end. + pub hue_fade: u16, +} + +impl AvatarGradient { + /// `getAvatarGradientStyle`'s CSS, for parity checks. + pub fn css(&self) -> String { + let AvatarGradient { hue, hue_fade } = *self; + format!( + "background-color:hsl({hue} 72% 43%);background-image:linear-gradient(145deg,hsl({hue} 80% 56%),hsl({hue_fade} 66% 38%))" + ) + } +} + +/// The hues of `getAvatarGradientStyle(seed)`. +pub fn avatar_gradient(seed: &str) -> AvatarGradient { + let normalized = normalize_seed(if seed.is_empty() { "?" } else { seed }); + let base = bit_cycle_hash(&normalized, 33, 997, 17); + let fade = bit_cycle_hash(&normalized, 29, 991, 53); + let hue = (base * 47) % 360; + let hue_fade = (hue + ((fade * 61) % 181) + 37) % 360; + AvatarGradient { + hue: hue as u16, + hue_fade: hue_fade as u16, + } +} + +/// `gradientCache`: one hash per distinct seed, cleared past 1000 seeds. +#[derive(Debug, Clone, Default)] +pub struct GradientCache { + entries: HashMap, +} + +impl GradientCache { + pub const MAX: usize = 1000; + + pub fn get(&mut self, seed: &str) -> AvatarGradient { + if let Some(gradient) = self.entries.get(seed) { + return *gradient; + } + let gradient = avatar_gradient(seed); + if self.entries.len() >= Self::MAX { + self.entries.clear(); + } + self.entries.insert(seed.to_string(), gradient); + gradient + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} diff --git a/crates/accshift-app/src/actions/card_actions.rs b/crates/accshift-app/src/actions/card_actions.rs new file mode 100644 index 00000000..bc4b1ce7 --- /dev/null +++ b/crates/accshift-app/src/actions/card_actions.rs @@ -0,0 +1,249 @@ +//! What a click, a right click or a key does to an account or folder card, +//! and the bulk edit bar actions on the selected cards. Ported from +//! `src/lib/app/useCardActions.ts`. + +use super::dialogs::AppDialogs; +use super::AppEffect; +use crate::accounts::{Toast, ToastKind}; +use crate::folders::{ItemKind, ItemRef}; +use crate::platforms::{PlatformAccount, PlatformAdapter}; +use crate::stores::ClientStores; +use crate::text::Text; + +/// What the card handlers read. +pub struct CardContext<'a> { + /// `getActiveTab()`. + pub active_tab: &'a str, + /// `getIsSearching()`. + pub is_searching: bool, + /// `getAdapter()`. + pub adapter: Option<&'a PlatformAdapter>, + /// `bulkEdit.bulkEditMode`. + pub bulk_edit_mode: bool, + /// `addFlow.isPendingSetupAccount(id)`: the account a running add flow + /// is still setting up. + pub pending_setup_account_id: Option<&'a str>, + /// `display.renderedAccountMap`. + pub rendered_accounts: &'a [PlatformAccount], +} + +impl CardContext<'_> { + fn is_pending_setup(&self, account_id: &str) -> bool { + self.pending_setup_account_id == Some(account_id) + } + + fn rendered(&self, account_id: &str) -> Option<&PlatformAccount> { + self.rendered_accounts + .iter() + .find(|account| account.id == account_id) + } + + fn cancel_conflicting(&self, account_id: Option<&str>) -> AppEffect { + AppEffect::AddFlowCancelIfConflicting { + platform_id: self.active_tab.to_string(), + account_id: account_id.map(str::to_string), + } + } +} + +/// The focused card for the keyboard handlers: `cardFocus.focusedItem` and +/// the centre of its element (`findElement(item).getBoundingClientRect()`), +/// `None` when the card is not on screen. +#[derive(Debug, Clone, Copy)] +pub struct FocusedCard<'a> { + pub item: &'a ItemRef, + pub center: Option<(f32, f32)>, +} + +/// `handleBackgroundContextMenu`. +pub fn background_context_menu( + dialogs: &mut AppDialogs, + ctx: &CardContext<'_>, + x: f32, + y: f32, +) -> Vec { + let effects = vec![ctx.cancel_conflicting(None)]; + dialogs.open_background_context_menu(x, y); + effects +} + +/// `handleWorkspaceMouseDown`: in selection mode a press starts a paint +/// selection, otherwise the drag manager (never while searching). +pub fn workspace_mouse_down(ctx: &CardContext<'_>) -> Vec { + if ctx.bulk_edit_mode { + return vec![AppEffect::BulkEditPaintStart]; + } + if !ctx.is_searching { + return vec![AppEffect::DragGridMouseDown]; + } + Vec::new() +} + +/// `handleWorkspaceAccountActivate`. +pub fn account_activate(ctx: &CardContext<'_>, account: &PlatformAccount) -> Vec { + if ctx.bulk_edit_mode { + return Vec::new(); + } + vec![ctx.cancel_conflicting(Some(&account.id))] +} + +/// `handleWorkspaceAccountSwitch`. +pub fn account_switch(ctx: &CardContext<'_>, account: &PlatformAccount) -> Vec { + if ctx.bulk_edit_mode { + return vec![AppEffect::BulkEditToggleAccount(account.id.clone())]; + } + if ctx.is_pending_setup(&account.id) { + return Vec::new(); + } + vec![ + ctx.cancel_conflicting(Some(&account.id)), + AppEffect::SwitchAccount(account.clone()), + ] +} + +/// `handleWorkspaceAccountContextMenu`. +pub fn account_context_menu( + dialogs: &mut AppDialogs, + ctx: &CardContext<'_>, + account: &PlatformAccount, + x: f32, + y: f32, +) -> Vec { + if ctx.bulk_edit_mode { + return vec![AppEffect::BulkEditToggleAccount(account.id.clone())]; + } + if ctx.is_pending_setup(&account.id) { + return Vec::new(); + } + let effects = vec![ctx.cancel_conflicting(Some(&account.id))]; + dialogs.open_account_context_menu(x, y, account.clone()); + effects +} + +/// `handleWorkspaceFolderContextMenu`. +pub fn folder_context_menu( + dialogs: &mut AppDialogs, + ctx: &CardContext<'_>, + stores: &ClientStores, + folder_id: &str, + x: f32, + y: f32, +) -> Vec { + let effects = vec![ctx.cancel_conflicting(None)]; + if let Some(folder) = stores.folders().folder(folder_id) { + dialogs.open_folder_context_menu(x, y, folder.clone()); + } + effects +} + +/// `activateFocusedCard`: open a folder, or activate then switch an +/// account. `None` when nothing is focused or the card is gone (the key +/// then passes). +pub fn activate_focused_card( + ctx: &CardContext<'_>, + focused: Option<&ItemRef>, +) -> Option> { + let item = focused?; + if item.kind == ItemKind::Folder { + return Some(vec![AppEffect::NavigateToFolder(Some(item.id.clone()))]); + } + let account = ctx.rendered(&item.id)?; + let mut effects = account_activate(ctx, account); + effects.extend(account_switch(ctx, account)); + Some(effects) +} + +/// `openFocusedCardContextMenu`: the menu opens at the card's centre. +pub fn open_focused_card_context_menu( + dialogs: &mut AppDialogs, + ctx: &CardContext<'_>, + stores: &ClientStores, + focused: Option>, +) -> Option> { + let focused = focused?; + let (x, y) = focused.center?; + let item = focused.item; + if item.kind == ItemKind::Folder { + stores.folders().folder(&item.id)?; + return Some(folder_context_menu(dialogs, ctx, stores, &item.id, x, y)); + } + let account = ctx.rendered(&item.id)?.clone(); + Some(account_context_menu(dialogs, ctx, &account, x, y)) +} + +/// `renameFocusedCard`: true when a rename dialog opened. +pub fn rename_focused_card( + dialogs: &mut AppDialogs, + ctx: &CardContext<'_>, + stores: &ClientStores, + focused: Option<&ItemRef>, +) -> bool { + let Some(item) = focused else { + return false; + }; + if item.kind == ItemKind::Folder { + let Some(folder) = stores.folders().folder(&item.id) else { + return false; + }; + dialogs.open_rename_folder_dialog(folder); + return true; + } + let Some(account) = ctx.rendered(&item.id) else { + return false; + }; + let Some(adapter) = ctx + .adapter + .filter(|adapter| adapter.supports_account_labels()) + else { + return false; + }; + dialogs.open_rename_account_dialog(Some(adapter), account); + true +} + +/// `copyBulkEditUrls`: nothing for an empty list, otherwise the clipboard +/// write, one URL per line. +pub fn copy_bulk_edit_urls(urls: &[String]) -> Vec { + if urls.is_empty() { + return Vec::new(); + } + vec![AppEffect::CopyBulkUrls(urls.to_vec())] +} + +/// The toast after [`AppEffect::CopyBulkUrls`]: `bulkEdit.urlsCopied` with the +/// count, `toast.copyFailed` when the write failed. +pub fn bulk_urls_copied_toast(count: usize, write_ok: bool) -> Toast { + if write_ok { + Toast::new( + Text::with("bulkEdit.urlsCopied", [("count", count as f64)]), + ToastKind::Success, + ) + } else { + Toast::new(Text::key("toast.copyFailed"), ToastKind::Error) + } +} + +/// `applyBulkEditCardColor`: writes every selected id in the store (an empty +/// colour clears), then the version bump and the toast. +pub fn apply_bulk_edit_card_color( + stores: &mut ClientStores, + selected_ids: &[String], + color: &str, +) -> Vec { + if selected_ids.is_empty() { + return Vec::new(); + } + stores.set_account_card_colors(selected_ids, color); + let key = if color.is_empty() { + "bulkEdit.colorCleared" + } else { + "bulkEdit.colorApplied" + }; + vec![ + AppEffect::CardColorsChanged, + AppEffect::Toast(Toast::new( + Text::with(key, [("count", selected_ids.len() as f64)]), + ToastKind::Success, + )), + ] +} diff --git a/crates/accshift-app/src/actions/dialogs.rs b/crates/accshift-app/src/actions/dialogs.rs new file mode 100644 index 00000000..b60c8c8d --- /dev/null +++ b/crates/accshift-app/src/actions/dialogs.rs @@ -0,0 +1,895 @@ +//! Dialogs and the context menu, ported from `src/lib/app/useAppDialogs.svelte.ts`, +//! with the submit rule of `src/lib/shared/components/InputDialog.svelte`. +//! +//! TS dialogs carry an `onConfirm` closure. Here an input dialog carries an +//! [`InputPurpose`] and a confirm dialog a [`ConfirmPurpose`]: data saying +//! what confirming does. [`AppDialogs::submit_input`] and +//! [`AppDialogs::confirm_current`] apply it: the stores the dialog owns change +//! in place, the rest comes back as [`AppEffect`]s. +//! +//! `requestConfirm` returned a promise; here it returns a [`ConfirmTicket`] +//! and every way out of the dialog emits [`AppEffect::ConfirmSettled`] for it. + +use super::{load_options, AppEffect}; +use crate::accounts::{Toast, ToastKind}; +use crate::backend::Backend; +use crate::error::BackendResult; +use crate::folders::{FolderInfo, ItemRef}; +use crate::js; +use crate::menu::account::{ + build_account_context_menu, color_label_key, AccountFolderChoice, AppearanceInput, FolderInput, +}; +use crate::menu::{InputRequest, MenuCommand, MenuEntry, MenuSwatch, PlatformTask, SubmenuLoader}; +use crate::platforms::adapter::MenuContext; +use crate::platforms::{ + PlatformAccount, PlatformAdapter, PlatformBulkEditResult, PlatformRegistry, +}; +use crate::stores::{ClientStores, ACCOUNT_CARD_COLOR_PRESETS, MAX_NOTE_LENGTH}; +use crate::text::Text; +use std::sync::Arc; + +// ------------------------------------------------------------- menu + +/// What clicking an entry of the app's context menu does: the platform +/// menu's own [`MenuCommand`]s plus the folder and background entries +/// `useAppDialogs` builds itself. +#[derive(Debug, Clone, PartialEq)] +pub enum AppMenuCommand { + Platform(MenuCommand), + /// "Rename": the folder rename dialog. + RenameFolder(FolderInfo), + /// A folder colour swatch. + SetFolderColor { + folder_id: String, + color: String, + }, + /// "Delete folder": its confirm dialog. + DeleteFolder(FolderInfo), + /// Background "Refresh": `loadAccounts(false, true, false, true)`. + RefreshAccounts, + /// Background "New folder". + NewFolder, + /// An entry of a caller-built menu, by the id that caller gave it. + Custom(String), +} + +/// `ContextMenuSwatchAction` with an app command. +#[derive(Debug, Clone, PartialEq)] +pub struct AppMenuSwatch { + pub id: String, + pub label: Text, + pub color: String, + pub active: bool, + pub command: AppMenuCommand, +} + +/// `ContextMenuItem`: what the menu draws, in order. +#[derive(Debug, Clone, PartialEq)] +pub enum AppMenuEntry { + Separator, + Item { + id: String, + label: Text, + command: Option, + submenu: Option>, + loader: Option, + }, + Swatches { + id: String, + label: Text, + swatches: Vec, + }, +} + +impl AppMenuEntry { + /// A plain entry. + pub fn item(id: impl Into, label: Text, command: AppMenuCommand) -> Self { + AppMenuEntry::Item { + id: id.into(), + label, + command: Some(command), + submenu: None, + loader: None, + } + } + + pub fn id(&self) -> Option<&str> { + match self { + AppMenuEntry::Separator => None, + AppMenuEntry::Item { id, .. } | AppMenuEntry::Swatches { id, .. } => Some(id), + } + } + + pub fn label(&self) -> Option<&Text> { + match self { + AppMenuEntry::Separator => None, + AppMenuEntry::Item { label, .. } | AppMenuEntry::Swatches { label, .. } => Some(label), + } + } + + pub fn command(&self) -> Option<&AppMenuCommand> { + match self { + AppMenuEntry::Item { command, .. } => command.as_ref(), + _ => None, + } + } +} + +impl From for AppMenuSwatch { + fn from(swatch: MenuSwatch) -> Self { + Self { + id: swatch.id, + label: swatch.label, + color: swatch.color, + active: swatch.active, + command: AppMenuCommand::Platform(swatch.command), + } + } +} + +impl From for AppMenuEntry { + /// The platform menu (and a loaded submenu) as app entries. + fn from(entry: MenuEntry) -> Self { + match entry { + MenuEntry::Separator => AppMenuEntry::Separator, + MenuEntry::Item { + id, + label, + command, + submenu, + loader, + } => AppMenuEntry::Item { + id, + label, + command: command.map(AppMenuCommand::Platform), + submenu: submenu.map(|entries| entries.into_iter().map(Into::into).collect()), + loader, + }, + MenuEntry::Swatches { + id, + label, + swatches, + } => AppMenuEntry::Swatches { + id, + label, + swatches: swatches.into_iter().map(Into::into).collect(), + }, + } + } +} + +/// What the menu was opened on. +#[derive(Debug, Clone, PartialEq)] +pub enum ContextMenuTarget { + Account(PlatformAccount), + Folder(FolderInfo), + Background, + /// Pre-built entries from outside the account and folder world. + Custom(Vec), +} + +/// `ContextMenuState`. +#[derive(Debug, Clone, PartialEq)] +pub struct ContextMenuState { + pub x: f32, + pub y: f32, + pub target: ContextMenuTarget, +} + +/// What building the menu reads. +#[derive(Clone, Copy)] +pub struct MenuInput<'a> { + pub stores: &'a ClientStores, + /// `shell.adapter`: the active tab's adapter. + pub adapter: Option<&'a PlatformAdapter>, + pub active_tab: &'a str, + pub active_tab_usable: bool, + pub current_account_id: Option<&'a str>, +} + +/// `folderPathLabel`: "Parent / Child", the id when the folder is unknown. +pub fn folder_path_label(stores: &ClientStores, folder_id: &str) -> String { + let path = stores.folders().folder_path(Some(folder_id)); + if path.is_empty() { + return folder_id.to_string(); + } + path.iter() + .map(|folder| folder.name.as_str()) + .collect::>() + .join(" / ") +} + +fn color_label(preset_id: &str) -> Text { + Text::key(color_label_key(preset_id).unwrap_or(preset_id)) +} + +/// The folder card menu. +fn folder_menu(folder: &FolderInfo, stores: &ClientStores) -> Vec { + let current_color = stores.folder_card_color(&folder.id); + let swatches = ACCOUNT_CARD_COLOR_PRESETS + .iter() + .map(|preset| AppMenuSwatch { + id: preset.id.to_string(), + label: color_label(preset.id), + color: preset.color.to_string(), + active: current_color == preset.color, + command: AppMenuCommand::SetFolderColor { + folder_id: folder.id.clone(), + color: preset.color.to_string(), + }, + }) + .collect(); + vec![ + AppMenuEntry::item( + "folder.rename", + Text::key("context.menu.rename"), + AppMenuCommand::RenameFolder(folder.clone()), + ), + AppMenuEntry::Swatches { + id: "folder.color".to_string(), + label: Text::key("context.menu.folderColor"), + swatches, + }, + AppMenuEntry::item( + "folder.delete", + Text::key("context.menu.deleteFolder"), + AppMenuCommand::DeleteFolder(folder.clone()), + ), + ] +} + +/// The account card menu: the platform menu with the folder world wired in. +fn account_menu( + account: &PlatformAccount, + adapter: &PlatformAdapter, + input: &MenuInput<'_>, +) -> Vec { + let stores = input.stores; + let folders = FolderInput { + folders: stores + .folders() + .list_folders(input.active_tab) + .into_iter() + .map(|folder| AccountFolderChoice { + id: folder.id.clone(), + label: folder_path_label(stores, &folder.id), + }) + .collect(), + current_folder_id: stores + .folders() + .find_item_folder_id(&ItemRef::account(account.id.clone()), input.active_tab) + .map(str::to_string), + }; + let appearance = AppearanceInput { + current_color: stores.account_card_color(&account.id).to_string(), + existing_note: stores.account_card_note(&account.id).to_string(), + }; + let ctx = MenuContext { + current_account_id: input.current_account_id, + stores, + input_dialog: true, + }; + build_account_context_menu(account, adapter, &ctx, &appearance, Some(&folders)) + .into_iter() + .map(Into::into) + .collect() +} + +// ----------------------------------------------------------- dialogs + +/// What confirming an input dialog does. +#[derive(Debug, Clone, PartialEq)] +pub enum InputPurpose { + /// `openNewFolderDialog`: a folder in the current folder of the active + /// tab, both read when confirming. + NewFolder, + /// `openRenameFolderDialog`. + RenameFolder { folder_id: String }, + /// The note editor of the appearance submenu: set, or clear when blank. + CardNote { account_id: String }, + /// A platform menu's rename (`openInputDialog` from the adapter). + PlatformInput(InputRequest), + /// `openRenameAccountDialog` (F2). + RenameAccount { + platform_id: String, + account_id: String, + }, + /// `promptRenameNewAccount`: a blank answer keeps the detected name. + RenameNewAccount { + platform_id: String, + account_id: String, + }, +} + +/// `InputDialogConfig` plus the draft `InputDialog.svelte` edits. +#[derive(Debug, Clone, PartialEq)] +pub struct InputDialogState { + pub title: Text, + pub placeholder: Text, + pub initial_value: String, + pub allow_empty: bool, + /// `maxlength`, in UTF-16 units as the HTML input counts. + pub maxlength: Option, + pub purpose: InputPurpose, + value: String, +} + +impl InputDialogState { + fn new( + title: Text, + placeholder: Text, + initial_value: String, + allow_empty: bool, + maxlength: Option, + purpose: InputPurpose, + ) -> Self { + Self { + title, + placeholder, + value: initial_value.clone(), + initial_value, + allow_empty, + maxlength, + purpose, + } + } + + /// The draft. + pub fn value(&self) -> &str { + &self.value + } + + /// Typing: the input refuses what goes past `maxlength`. + pub fn set_value(&mut self, value: impl Into) { + let value = value.into(); + self.value = match self.maxlength { + Some(max) if js::utf16_len(&value) > max => js::utf16_prefix(&value, max), + _ => value, + }; + } + + /// The OK button: enabled with `allowEmpty` or a non-blank draft. + pub fn can_submit(&self) -> bool { + self.allow_empty || !js::trim(&self.value).is_empty() + } + + /// The `n/max` counter, shown in the last 20 characters. + pub fn char_count(&self) -> Option<(usize, usize)> { + let max = self.maxlength.filter(|max| *max > 0)?; + let len = js::utf16_len(&self.value); + (len + 20 >= max).then_some((len, max)) + } + + /// `submit`: the value handed to `onConfirm` (trimmed unless + /// `allowEmpty`), `None` when it cannot submit. + pub fn submitted_value(&self) -> Option { + if !self.can_submit() { + return None; + } + Some(if self.allow_empty { + self.value.clone() + } else { + js::trim(&self.value).to_string() + }) + } +} + +/// A `requestConfirm` in flight. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ConfirmTicket(pub u64); + +/// What confirming a confirm dialog does. +#[derive(Debug, Clone, PartialEq)] +pub enum ConfirmPurpose { + /// A platform menu's confirm: run its task. + PlatformTask(PlatformTask), + /// The folder's "Delete folder". + DeleteFolder { folder_id: String }, + /// `requestConfirm`: nothing to run, the ticket's owner acts. + Request(ConfirmTicket), +} + +/// `PlatformContextMenuConfirmConfig`. +#[derive(Debug, Clone, PartialEq)] +pub struct ConfirmDialogState { + pub title: Text, + pub message: Text, + pub confirm_label: Option, + pub confirm_color: String, + pub purpose: ConfirmPurpose, +} + +impl ConfirmDialogState { + /// `confirmDialogConfirmLabel`. + pub fn confirm_label(&self) -> Text { + self.confirm_label + .clone() + .unwrap_or_else(|| Text::key("common.confirm")) + } +} + +/// What a `requestConfirm` asks. +#[derive(Debug, Clone, PartialEq)] +pub struct ConfirmConfig { + pub title: Text, + pub message: Text, + pub confirm_label: Option, + pub confirm_color: Option, +} + +/// What the dialogs write and read when they act. +pub struct DialogContext<'a> { + pub stores: &'a mut ClientStores, + pub active_tab: &'a str, + pub current_folder_id: Option<&'a str>, +} + +/// `createAppDialogsController`. +#[derive(Debug, Clone, Default)] +pub struct AppDialogs { + context_menu: Option, + input_dialog: Option, + confirm_dialog: Option, + pending_confirm: Option, + next_ticket: u64, +} + +impl AppDialogs { + pub fn new() -> Self { + Self::default() + } + + pub fn context_menu(&self) -> Option<&ContextMenuState> { + self.context_menu.as_ref() + } + + pub fn input_dialog(&self) -> Option<&InputDialogState> { + self.input_dialog.as_ref() + } + + /// The draft the input dialog edits. + pub fn input_dialog_mut(&mut self) -> Option<&mut InputDialogState> { + self.input_dialog.as_mut() + } + + pub fn confirm_dialog(&self) -> Option<&ConfirmDialogState> { + self.confirm_dialog.as_ref() + } + + /// An input or confirm dialog is up (the `dialog` key scope). + pub fn has_dialog(&self) -> bool { + self.input_dialog.is_some() || self.confirm_dialog.is_some() + } + + /// The ticket a confirm dialog answers, if one is pending. + pub fn pending_confirm(&self) -> Option { + self.pending_confirm + } + + // ------------------------------------------------- context menu + + /// `contextMenuItems`. + pub fn context_menu_items(&self, input: &MenuInput<'_>) -> Vec { + let Some(menu) = &self.context_menu else { + return Vec::new(); + }; + match &menu.target { + ContextMenuTarget::Custom(items) => items.clone(), + ContextMenuTarget::Account(account) => match input.adapter { + Some(adapter) => account_menu(account, adapter, input), + None => Vec::new(), + }, + ContextMenuTarget::Folder(folder) => folder_menu(folder, input.stores), + ContextMenuTarget::Background => { + let mut items = Vec::new(); + if input.active_tab_usable && input.adapter.is_some() { + items.push(AppMenuEntry::item( + "background.refresh", + Text::key("context.menu.refresh"), + AppMenuCommand::RefreshAccounts, + )); + } + items.push(AppMenuEntry::item( + "background.newFolder", + Text::key("context.menu.newFolder"), + AppMenuCommand::NewFolder, + )); + items + } + } + } + + pub fn open_background_context_menu(&mut self, x: f32, y: f32) { + self.open_menu(x, y, ContextMenuTarget::Background); + } + + pub fn open_account_context_menu(&mut self, x: f32, y: f32, account: PlatformAccount) { + self.open_menu(x, y, ContextMenuTarget::Account(account)); + } + + pub fn open_folder_context_menu(&mut self, x: f32, y: f32, folder: FolderInfo) { + self.open_menu(x, y, ContextMenuTarget::Folder(folder)); + } + + /// `openCustomContextMenu`: pre-built entries (the persona cards). + pub fn open_custom_context_menu(&mut self, x: f32, y: f32, items: Vec) { + self.open_menu(x, y, ContextMenuTarget::Custom(items)); + } + + fn open_menu(&mut self, x: f32, y: f32, target: ContextMenuTarget) { + self.context_menu = Some(ContextMenuState { x, y, target }); + } + + pub fn close_context_menu(&mut self) { + self.context_menu = None; + } + + /// A click on an entry or swatch: the menu closes and the command runs. + pub fn activate( + &mut self, + command: &AppMenuCommand, + ctx: &mut DialogContext<'_>, + ) -> Vec { + self.close_context_menu(); + let mut effects = Vec::new(); + match command { + AppMenuCommand::Platform(command) => self.platform_command(command, ctx, &mut effects), + AppMenuCommand::RenameFolder(folder) => self.open_rename_folder_dialog(folder), + AppMenuCommand::SetFolderColor { folder_id, color } => { + ctx.stores.set_folder_card_color(folder_id, color); + effects.push(AppEffect::CardColorsChanged); + } + AppMenuCommand::DeleteFolder(folder) => self.open_confirm( + ConfirmDialogState { + title: Text::with("dialog.deleteFolderTitle", [("name", folder.name.as_str())]), + message: Text::key("dialog.deleteFolderMessage"), + confirm_label: Some(Text::key("context.menu.deleteFolder")), + confirm_color: String::new(), + purpose: ConfirmPurpose::DeleteFolder { + folder_id: folder.id.clone(), + }, + }, + &mut effects, + ), + AppMenuCommand::RefreshAccounts => effects.push(AppEffect::LoadAccounts(load_options( + false, true, false, true, + ))), + AppMenuCommand::NewFolder => self.open_new_folder_dialog(), + AppMenuCommand::Custom(id) => effects.push(AppEffect::CustomMenu(id.clone())), + } + effects + } + + fn platform_command( + &mut self, + command: &MenuCommand, + ctx: &mut DialogContext<'_>, + effects: &mut Vec, + ) { + match command { + MenuCommand::Close => {} + MenuCommand::Copy { value, label } => effects.push(AppEffect::CopyToClipboard { + text: value.clone(), + label: label.clone(), + }), + MenuCommand::Confirm(request) => self.open_confirm( + ConfirmDialogState { + title: request.title.clone(), + message: request.message.clone(), + confirm_label: request.confirm_label.clone(), + confirm_color: String::new(), + purpose: ConfirmPurpose::PlatformTask(request.on_confirm.clone()), + }, + effects, + ), + MenuCommand::Input(request) => { + self.input_dialog = Some(InputDialogState::new( + request.title.clone(), + request.placeholder.clone(), + request.initial_value.clone(), + request.allow_empty, + None, + InputPurpose::PlatformInput((**request).clone()), + )); + } + MenuCommand::MoveToFolder { + account_id, + folder_id, + } => { + let item = ItemRef::account(account_id.clone()); + let from = ctx + .stores + .folders() + .find_item_folder_id(&item, ctx.active_tab) + .map(str::to_string); + ctx.stores.move_item( + &item, + from.as_deref(), + folder_id.as_deref(), + ctx.active_tab, + None, + ); + effects.push(AppEffect::RefreshCurrentItems); + } + MenuCommand::EditNote { + account_id, + initial_note, + } => { + self.input_dialog = Some(InputDialogState::new( + Text::key("dialog.cardNoteTitle"), + Text::key("dialog.cardNotePlaceholder"), + initial_note.clone(), + true, + Some(MAX_NOTE_LENGTH), + InputPurpose::CardNote { + account_id: account_id.clone(), + }, + )); + } + MenuCommand::SetCardColor { account_id, color } => { + ctx.stores.set_account_card_color(account_id, color); + effects.push(AppEffect::CardColorsChanged); + } + MenuCommand::Task(task) => effects.push(AppEffect::RunPlatformTask(task.clone())), + } + } + + // ------------------------------------------------- input dialog + + /// `openNewFolderDialog`. + pub fn open_new_folder_dialog(&mut self) { + self.input_dialog = Some(InputDialogState::new( + Text::key("dialog.newFolderTitle"), + Text::key("dialog.folderNamePlaceholder"), + String::new(), + false, + None, + InputPurpose::NewFolder, + )); + } + + /// `openRenameFolderDialog`. + pub fn open_rename_folder_dialog(&mut self, folder: &FolderInfo) { + self.input_dialog = Some(InputDialogState::new( + Text::key("dialog.renameFolderTitle"), + Text::key("dialog.folderNamePlaceholder"), + folder.name.clone(), + false, + None, + InputPurpose::RenameFolder { + folder_id: folder.id.clone(), + }, + )); + } + + /// `openRenameAccountDialog`: nothing when the active adapter cannot + /// label accounts. + pub fn open_rename_account_dialog( + &mut self, + adapter: Option<&PlatformAdapter>, + account: &PlatformAccount, + ) { + let Some(adapter) = adapter.filter(|adapter| adapter.supports_account_labels()) else { + return; + }; + self.input_dialog = Some(InputDialogState::new( + Text::key("platform.renameTitle"), + Text::key("platform.renamePlaceholder"), + account.display_name.clone(), + true, + None, + InputPurpose::RenameAccount { + platform_id: adapter.id().to_string(), + account_id: account.id.clone(), + }, + )); + } + + /// `promptRenameNewAccount`: prefilled with the identity the platform + /// detected, when the platform can label accounts. + pub fn prompt_rename_new_account( + &mut self, + registry: &PlatformRegistry, + platform_id: &str, + account_id: &str, + display_name: Option<&str>, + ) { + let supported = registry + .adapter(platform_id) + .is_some_and(|adapter| adapter.supports_account_labels()); + if !supported { + return; + } + self.input_dialog = Some(InputDialogState::new( + Text::key("platform.renameNewAccount"), + Text::key("platform.renamePlaceholder"), + display_name.map(js::trim).unwrap_or_default().to_string(), + true, + None, + InputPurpose::RenameNewAccount { + platform_id: platform_id.to_string(), + account_id: account_id.to_string(), + }, + )); + } + + /// `closeInputDialog` (Cancel, Escape). + pub fn close_input_dialog(&mut self) { + self.input_dialog = None; + } + + /// The OK button or Enter: closes the dialog and applies its purpose with + /// the submitted value. Nothing while it cannot submit. + pub fn submit_input(&mut self, ctx: &mut DialogContext<'_>) -> Vec { + let Some(value) = self + .input_dialog + .as_ref() + .and_then(InputDialogState::submitted_value) + else { + return Vec::new(); + }; + let Some(dialog) = self.input_dialog.take() else { + return Vec::new(); + }; + let mut effects = Vec::new(); + match dialog.purpose { + InputPurpose::NewFolder => { + ctx.stores + .create_folder(&value, ctx.current_folder_id, ctx.active_tab); + effects.push(AppEffect::RefreshCurrentItems); + } + InputPurpose::RenameFolder { folder_id } => { + ctx.stores.rename_folder(&folder_id, &value); + effects.push(AppEffect::RefreshCurrentItems); + } + InputPurpose::CardNote { account_id } => { + if js::trim(&value).is_empty() { + ctx.stores.clear_account_card_note(&account_id); + } else { + ctx.stores.set_account_card_note(&account_id, &value); + } + effects.push(AppEffect::CardNotesChanged); + } + InputPurpose::PlatformInput(request) => { + effects.push(AppEffect::RunPlatformTask(request.confirm(&value))); + } + InputPurpose::RenameAccount { + platform_id, + account_id, + } => effects.push(AppEffect::RenameAccount { + platform_id, + account_id, + label: value, + }), + InputPurpose::RenameNewAccount { + platform_id, + account_id, + } => { + if !js::trim(&value).is_empty() { + effects.push(AppEffect::RenameAccount { + platform_id, + account_id, + label: value, + }); + } + } + } + effects + } + + // ----------------------------------------------- confirm dialog + + fn settle_pending(&mut self, allowed: bool, effects: &mut Vec) { + if let Some(ticket) = self.pending_confirm.take() { + effects.push(AppEffect::ConfirmSettled { ticket, allowed }); + } + } + + /// `openConfirmDialog`: a dialog replacing a pending request settles it + /// as refused first. + fn open_confirm(&mut self, dialog: ConfirmDialogState, effects: &mut Vec) { + self.settle_pending(false, effects); + self.confirm_dialog = Some(dialog); + } + + /// `requestConfirm`: opens the confirm dialog; its answer comes back as + /// [`AppEffect::ConfirmSettled`] for the returned ticket. A request open + /// before is settled as refused into `effects`. + pub fn request_confirm( + &mut self, + config: ConfirmConfig, + effects: &mut Vec, + ) -> ConfirmTicket { + self.next_ticket += 1; + let ticket = ConfirmTicket(self.next_ticket); + self.open_confirm( + ConfirmDialogState { + title: config.title, + message: config.message, + confirm_label: config.confirm_label, + confirm_color: config.confirm_color.unwrap_or_default(), + purpose: ConfirmPurpose::Request(ticket), + }, + effects, + ); + self.pending_confirm = Some(ticket); + ticket + } + + /// `closeConfirmDialog` (Cancel, Escape): a pending request is refused. + pub fn close_confirm_dialog(&mut self) -> Vec { + self.confirm_dialog = None; + let mut effects = Vec::new(); + self.settle_pending(false, &mut effects); + effects + } + + /// `confirmCurrentDialog`: runs the purpose, then a pending request is + /// accepted. + pub fn confirm_current(&mut self, ctx: &mut DialogContext<'_>) -> Vec { + let mut effects = Vec::new(); + if let Some(dialog) = self.confirm_dialog.take() { + match dialog.purpose { + ConfirmPurpose::PlatformTask(task) => { + effects.push(AppEffect::RunPlatformTask(task)) + } + ConfirmPurpose::DeleteFolder { folder_id } => { + ctx.stores.delete_folder(&folder_id); + effects.push(AppEffect::RefreshCurrentItems); + } + ConfirmPurpose::Request(_) => {} + } + } + self.settle_pending(true, &mut effects); + effects + } +} + +/// `handleBulkEditResult`: all done, partial, or failed. +pub fn bulk_edit_result_toast(result: &PlatformBulkEditResult) -> Toast { + if result.failed.is_empty() && result.succeeded > 0 { + Toast::new( + Text::with( + "bulkEdit.toastSuccess", + [("count", result.succeeded as f64)], + ), + ToastKind::Success, + ) + } else if !result.failed.is_empty() && result.succeeded > 0 { + Toast::new( + Text::with( + "bulkEdit.toastPartial", + [ + ("succeeded", result.succeeded as f64), + ("failed", result.failed.len() as f64), + ], + ), + ToastKind::Default, + ) + } else { + Toast::new(Text::key("bulkEdit.toastFailed"), ToastKind::Error) + } +} + +/// The blocking half of [`AppEffect::RenameAccount`], for the job pool. +pub fn rename_account_blocking( + backend: &dyn Backend, + adapter: &Arc, + account_id: &str, + label: &str, +) -> BackendResult<()> { + adapter.set_account_label(backend, account_id, label) +} + +/// The UI half: reload silently, or `toast.renameAccountFailed`. +pub fn finish_rename_account(result: BackendResult<()>) -> Vec { + match result { + Ok(()) => vec![AppEffect::LoadAccounts(load_options( + true, false, false, false, + ))], + Err(error) => vec![AppEffect::Toast(Toast::new( + Text::with("toast.renameAccountFailed", [("error", error.message)]), + ToastKind::Error, + ))], + } +} diff --git a/crates/accshift-app/src/actions/extension.rs b/crates/accshift-app/src/actions/extension.rs new file mode 100644 index 00000000..21872345 --- /dev/null +++ b/crates/accshift-app/src/actions/extension.rs @@ -0,0 +1,193 @@ +//! The extension panel content of each visible account card, ported from +//! `src/lib/app/useExtensionContent.svelte.ts`. +//! +//! A card shows, in order: the add flow's setup content when the account is +//! being set up (it replaces the rest), else a warnings section, a note +//! section (unless notes show inline) and the platform's extra sections (the +//! CS2 bridge on Steam). One memo entry per account is rebuilt only when that +//! account's own inputs change, so the other cards keep the same `Arc`. + +use crate::i18n::Locale; +use crate::js; +use crate::platforms::card::{warning_chips_to_extension_chips, CardSection}; +use crate::platforms::warnings::{WarningChip, WarningPresentation}; +use crate::text::Text; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +/// `CardExtensionContent`: the sections of one card, `None` when it has none. +pub type CardExtension = Option>>; + +/// What the memo reads on each frame. +pub struct ExtensionInputs<'a> { + /// `shell.locale`. + pub locale: Locale, + /// `loader.warningStates`. + pub warning_states: &'a BTreeMap, + /// `display.visibleRenderedAccountIds`. + pub visible_ids: &'a [String], + /// `addFlow.getSetupExtensionContent(id)`, its sections. + pub setup_content: &'a dyn Fn(&str) -> Option>, + /// `getAccountNote(id)`. + pub account_note: &'a dyn Fn(&str) -> String, + /// `cardNoteVersion`. + pub card_note_version: u64, + /// `settings.accountDisplay.showCardNotesInline`. + pub show_card_notes_inline: bool, + /// `getExtraSections(id)`: Steam's CS2 sections, nothing elsewhere. + pub extra_sections: &'a dyn Fn(&str) -> Vec, + /// `getCs2BridgeVersion()`. + pub extra_sections_version: u64, +} + +#[derive(Debug, Clone, PartialEq)] +struct SharedKey { + locale: Locale, + card_note_version: u64, + show_card_notes_inline: bool, + extra_sections_version: u64, +} + +/// `accountKey(id)`: the account's own inputs. +#[derive(Debug, Clone, PartialEq)] +struct AccountKey { + tooltip: Option, + chips: Vec, + note: String, + setup: Option>, +} + +#[derive(Debug, Clone)] +struct Entry { + key: AccountKey, + content: CardExtension, +} + +/// `createExtensionContentController`. +#[derive(Debug, Clone, Default)] +pub struct ExtensionContent { + shared_key: Option, + entries: HashMap, + cache: Arc>, +} + +/// `createWarningExtensionSection`: chips when the warning has any, else the +/// tooltip lines joined with a bullet. +pub fn warning_section( + warning: Option<&WarningPresentation>, + locale: Locale, +) -> Option { + let warning = warning?; + let chips = warning_chips_to_extension_chips(&warning.chips); + let tooltip = warning.tooltip.resolve(locale); + let lines: Vec = tooltip + .split('\n') + .map(|line| js::trim(line).to_string()) + .filter(|line| !line.is_empty()) + .collect(); + if lines.is_empty() && chips.is_empty() { + return None; + } + let has_chips = !chips.is_empty(); + Some(CardSection { + title: Some(Text::key("card.extensionWarnings")), + text: (!has_chips).then(|| Text::raw(lines.join(" \u{2022} "))), + lines: if has_chips { Vec::new() } else { lines }, + chips, + ..CardSection::default() + }) +} + +/// `createNoteExtensionSection`. +pub fn note_section(note: &str, show_inline: bool) -> Option { + if show_inline { + return None; + } + let note = js::trim(note); + if note.is_empty() { + return None; + } + Some(CardSection { + title: Some(Text::key("card.extensionNote")), + lines: vec![note.to_string()], + ..CardSection::default() + }) +} + +impl ExtensionContent { + pub fn new() -> Self { + Self::default() + } + + fn account_key(inputs: &ExtensionInputs<'_>, id: &str) -> AccountKey { + let warning = inputs.warning_states.get(id); + AccountKey { + tooltip: warning.map(|warning| warning.tooltip.clone()), + chips: warning + .map(|warning| warning.chips.clone()) + .unwrap_or_default(), + note: (inputs.account_note)(id), + setup: (inputs.setup_content)(id), + } + } + + fn build_content(inputs: &ExtensionInputs<'_>, id: &str, key: &AccountKey) -> CardExtension { + if let Some(setup) = &key.setup { + return Some(Arc::new(setup.clone())); + } + let mut sections = Vec::new(); + sections.extend(warning_section( + inputs.warning_states.get(id), + inputs.locale, + )); + sections.extend(note_section(&key.note, inputs.show_card_notes_inline)); + sections.extend((inputs.extra_sections)(id)); + (!sections.is_empty()).then(|| Arc::new(sections)) + } + + /// `accountExtensionContentById`: the same map `Arc` while nothing + /// changed, and the same entry `Arc` for every card whose inputs held. + pub fn content_by_id( + &mut self, + inputs: &ExtensionInputs<'_>, + ) -> Arc> { + let shared = SharedKey { + locale: inputs.locale, + card_note_version: inputs.card_note_version, + show_card_notes_inline: inputs.show_card_notes_inline, + extra_sections_version: inputs.extra_sections_version, + }; + let same_shared = self.shared_key.as_ref() == Some(&shared); + let previous = if same_shared { + std::mem::take(&mut self.entries) + } else { + HashMap::new() + }; + let mut changed = !same_shared || inputs.visible_ids.len() != self.cache.len(); + let mut next = HashMap::with_capacity(inputs.visible_ids.len()); + for id in inputs.visible_ids { + let key = Self::account_key(inputs, id); + if let Some(cached) = previous.get(id).filter(|cached| cached.key == key) { + if !self.cache.contains_key(id) { + changed = true; + } + next.insert(id.clone(), cached.clone()); + continue; + } + let content = Self::build_content(inputs, id, &key); + next.insert(id.clone(), Entry { key, content }); + changed = true; + } + self.shared_key = Some(shared); + self.entries = next; + if changed { + let map = self + .entries + .iter() + .map(|(id, entry)| (id.clone(), entry.content.clone())) + .collect(); + self.cache = Arc::new(map); + } + Arc::clone(&self.cache) + } +} diff --git a/crates/accshift-app/src/actions/keyboard.rs b/crates/accshift-app/src/actions/keyboard.rs new file mode 100644 index 00000000..5c8e69bc --- /dev/null +++ b/crates/accshift-app/src/actions/keyboard.rs @@ -0,0 +1,699 @@ +//! The keyboard model, ported from `src/lib/shared/keyboard/{controller,types}.ts` +//! and `src/lib/app/keyboardBindings.ts`. +//! +//! The binding table is data: each entry names a [`KeyCommand`] instead of a +//! closure. [`KeyboardController::handle`] walks it in order for a key event +//! egui feeds in, with the current [`KeyScope`], and runs the first match +//! through a [`KeyHost`] (the app). A command answers [`KeyResult::Pass`] for +//! "not handled here" (the TS `PASS`): the next binding is tried and the event +//! stays untouched. The DOM listener stays out. + +use crate::folders::ItemKind; +use crate::folders::ItemRef; + +/// `KeyScope`: the layer that owns the keyboard. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum KeyScope { + Locked, + Onboarding, + Palette, + Dialog, + ContextMenu, + BulkEdit, + Settings, + Personas, + App, +} + +impl KeyScope { + pub const ALL: [KeyScope; 9] = [ + KeyScope::Locked, + KeyScope::Onboarding, + KeyScope::Palette, + KeyScope::Dialog, + KeyScope::ContextMenu, + KeyScope::BulkEdit, + KeyScope::Settings, + KeyScope::Personas, + KeyScope::App, + ]; + + pub fn as_str(self) -> &'static str { + match self { + KeyScope::Locked => "locked", + KeyScope::Onboarding => "onboarding", + KeyScope::Palette => "palette", + KeyScope::Dialog => "dialog", + KeyScope::ContextMenu => "context-menu", + KeyScope::BulkEdit => "bulk-edit", + KeyScope::Settings => "settings", + KeyScope::Personas => "personas", + KeyScope::App => "app", + } + } +} + +/// The layers `createKeyScopeResolver` checks, topmost first. `App` is what +/// is left when none holds. +pub const LAYER_ORDER: [KeyScope; 8] = [ + KeyScope::Locked, + KeyScope::Palette, + KeyScope::Onboarding, + KeyScope::Dialog, + KeyScope::ContextMenu, + KeyScope::BulkEdit, + KeyScope::Settings, + KeyScope::Personas, +]; + +/// `currentKeyScope`: the first layer of [`LAYER_ORDER`] that is up. `is_up` +/// is asked in that order and no further than the first hit. +pub fn current_key_scope(mut is_up: impl FnMut(KeyScope) -> bool) -> KeyScope { + LAYER_ORDER + .into_iter() + .find(|scope| is_up(*scope)) + .unwrap_or(KeyScope::App) +} + +/// `CARD_NAV_SCOPES`: where the grid takes arrows and Enter. +pub const CARD_NAV_SCOPES: [KeyScope; 2] = [KeyScope::App, KeyScope::BulkEdit]; + +/// `ParsedCombo`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedCombo { + pub mod_key: bool, + pub shift: bool, + pub alt: bool, + /// Lowercased `e.key`, `digitN` for physical digits, `plus` / `minus` + /// for the zoom keys, `space`. + pub key: String, +} + +/// `parseCombo`. +pub fn parse_combo(combo: &str) -> ParsedCombo { + let lower = combo.to_lowercase(); + let parts: Vec<&str> = lower.split('+').collect(); + let key = parts.last().copied().unwrap_or_default().to_string(); + ParsedCombo { + mod_key: parts.contains(&"mod"), + shift: parts.contains(&"shift"), + alt: parts.contains(&"alt"), + key, + } +} + +/// A key press, as the DOM `KeyboardEvent` fields the controller reads. +/// `key` and `code` use the DOM names (`"Escape"`, `"ArrowLeft"`, `"F2"`, +/// `" "`, `"n"` or `"N"`; `"Digit2"`, `"Equal"`, `"NumpadAdd"`, `"Minus"`). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct KeyEvent { + pub key: String, + pub code: String, + pub ctrl: bool, + pub meta: bool, + pub shift: bool, + pub alt: bool, + /// `isEditableTarget(e.target)`: focus is in a text field. + pub editable_target: bool, +} + +impl KeyEvent { + pub fn new(key: impl Into) -> Self { + Self { + key: key.into(), + ..Self::default() + } + } + + pub fn code(mut self, code: impl Into) -> Self { + self.code = code.into(); + self + } + + pub fn ctrl(mut self) -> Self { + self.ctrl = true; + self + } + + pub fn meta(mut self) -> Self { + self.meta = true; + self + } + + pub fn shift(mut self) -> Self { + self.shift = true; + self + } + + pub fn alt(mut self) -> Self { + self.alt = true; + self + } + + pub fn in_input(mut self) -> Self { + self.editable_target = true; + self + } +} + +/// `eventMatchesKey`. +fn event_matches_key(event: &KeyEvent, key: &str) -> bool { + if key.starts_with("digit") { + return event.code.to_lowercase() == key; + } + match key { + "plus" => event.code == "Equal" || event.code == "NumpadAdd", + "minus" => event.code == "Minus" || event.code == "NumpadSubtract", + "space" => event.key == " ", + _ => event.key.to_lowercase() == key, + } +} + +/// `comboMatches`: `mod` is Cmd on macOS and Ctrl elsewhere; the other one +/// pressed alone spoils a modifier-free combo. +pub fn combo_matches(event: &KeyEvent, combo: &ParsedCombo, is_mac: bool) -> bool { + let mod_pressed = if is_mac { event.meta } else { event.ctrl }; + let stray_mod = if is_mac { event.ctrl } else { event.meta }; + if combo.mod_key != mod_pressed { + return false; + } + if !combo.mod_key && stray_mod { + return false; + } + if combo.shift != event.shift || combo.alt != event.alt { + return false; + } + event_matches_key(event, &combo.key) +} + +/// A direction for the card focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Left, + Right, + Up, + Down, +} + +/// What a binding does: one variant per `run` closure of the TS table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyCommand { + /// `run: () => {}`: take the key, do nothing (WebView built-ins). + Swallow, + /// `run: () => PASS`. + Pass, + /// Close the context menu, open the palette. + OpenPalette, + ClosePalette, + /// A card drag in flight is cancelled, else PASS. + EscapeCancelDrag, + /// Close the input dialog if one is up, else the confirm dialog. + EscapeCloseDialog, + /// The persona wizard steps back, else the panel closes. + EscapePersonas, + /// Clear and blur the search, else drop the card focus, else PASS. + EscapeApp, + /// Focus and select the grid search. + FocusSearch, + FocusSettingsSearch, + AddAccount, + NewFolder, + Refresh, + /// Toggle bulk edit when the active tab allows it; taken either way. + ToggleBulkEdit, + /// Close personas, toggle the settings panel. + ToggleSettings, + OpenPersonas, + ToggleViewMode, + /// `cycleTab(1)` / `cycleTab(-1)`. + CycleTab(i8), + /// `mod+digitN`: the N-th enabled platform (0 based) unless unavailable. + TabByIndex(usize), + ZoomIn, + ZoomOut, + ResetZoom, + /// `alt+arrowleft` in the app: up a folder when in one; taken either way. + AltBack, + /// `backspace`: up a folder, PASS at the root. + BackspaceBack, + /// Move the card focus, PASS when it cannot move. + CardMove(Direction), + /// Enter: activate the focused card when the grid owns the key. + CardActivate, + /// F2. + RenameFocused, + /// Delete, Shift+F10, ContextMenu. + ContextMenuFocused, + /// Space in bulk edit: toggle the focused account. + BulkToggleFocused, + BulkSelectAll, + BulkDeselectAll, +} + +/// The scopes a binding fires in (`"*"` or a list). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Scopes { + All, + Only(Vec), +} + +impl Scopes { + pub fn contains(&self, scope: KeyScope) -> bool { + match self { + Scopes::All => true, + Scopes::Only(list) => list.contains(&scope), + } + } +} + +/// `ShortcutBinding`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Binding { + pub combo: String, + pub scopes: Scopes, + /// Single keys run in a text field only with this set. + pub allow_in_input: bool, + /// Skip the binding, modifier combos included, in a text field. + pub skip_in_input: bool, + /// `preventDefault !== false`. Every binding of the app table keeps it. + pub prevent_default: bool, + pub command: KeyCommand, +} + +impl Binding { + fn new(combo: impl Into, scopes: Scopes, command: KeyCommand) -> Self { + Self { + combo: combo.into(), + scopes, + allow_in_input: false, + skip_in_input: false, + prevent_default: true, + command, + } + } + + fn in_input(mut self) -> Self { + self.allow_in_input = true; + self + } + + fn skip_input(mut self) -> Self { + self.skip_in_input = true; + self + } +} + +fn only(scopes: &[KeyScope]) -> Scopes { + Scopes::Only(scopes.to_vec()) +} + +/// `createKeyboardBindings`: the whole table, in match order. +pub fn app_bindings() -> Vec { + use KeyCommand as C; + use KeyScope as S; + let all = || Scopes::All; + let card_nav = || only(&CARD_NAV_SCOPES); + let mut table = vec![ + // WebView built-ins that must never fire in a desktop shell. + Binding::new("mod+w", all(), C::Swallow), + Binding::new("mod+p", all(), C::Swallow), + Binding::new("f3", all(), C::Swallow).in_input(), + Binding::new("mod+g", all(), C::Swallow), + Binding::new("f7", all(), C::Swallow).in_input(), + Binding::new("mod+u", all(), C::Swallow), + Binding::new("mod+j", all(), C::Swallow), + Binding::new("alt+arrowright", all(), C::Swallow).in_input(), + Binding::new("alt+arrowleft", all(), C::Pass).in_input(), + // Command palette. + Binding::new( + "mod+k", + only(&[ + S::App, + S::Settings, + S::Personas, + S::BulkEdit, + S::ContextMenu, + ]), + C::OpenPalette, + ), + Binding::new("mod+k", only(&[S::Palette]), C::ClosePalette).in_input(), + // Escape cascade: one layer per press. + Binding::new("escape", all(), C::EscapeCancelDrag).in_input(), + Binding::new("escape", only(&[S::Palette]), C::ClosePalette).in_input(), + Binding::new("escape", only(&[S::Dialog]), C::EscapeCloseDialog).in_input(), + Binding::new("escape", only(&[S::ContextMenu]), C::Pass).in_input(), + Binding::new("escape", only(&[S::BulkEdit]), C::Pass).in_input(), + Binding::new("escape", only(&[S::Settings]), C::Pass).in_input(), + Binding::new("escape", only(&[S::Onboarding, S::Locked]), C::Pass).in_input(), + Binding::new("escape", only(&[S::Personas]), C::EscapePersonas).in_input(), + Binding::new("escape", only(&[S::App]), C::EscapeApp).in_input(), + // App-level shortcuts. + Binding::new("mod+f", only(&[S::App, S::BulkEdit]), C::FocusSearch), + Binding::new("mod+f", only(&[S::Settings]), C::FocusSettingsSearch), + Binding::new("mod+f", all(), C::Swallow), + Binding::new("mod+n", only(&[S::App]), C::AddAccount), + Binding::new("mod+shift+n", only(&[S::App]), C::NewFolder), + Binding::new("mod+r", only(&[S::App]), C::Refresh), + Binding::new("f5", only(&[S::App]), C::Refresh).in_input(), + Binding::new("f5", all(), C::Swallow).in_input(), + Binding::new("mod+shift+r", all(), C::Swallow), + Binding::new("mod+e", only(&[S::App, S::BulkEdit]), C::ToggleBulkEdit), + Binding::new( + "mod+,", + only(&[S::App, S::Settings, S::Personas]), + C::ToggleSettings, + ), + Binding::new("mod+shift+p", only(&[S::App]), C::OpenPersonas), + Binding::new("mod+shift+l", only(&[S::App]), C::ToggleViewMode), + Binding::new("mod+tab", only(&[S::App]), C::CycleTab(1)).in_input(), + Binding::new("mod+shift+tab", only(&[S::App]), C::CycleTab(-1)).in_input(), + ]; + for index in 0..9 { + table.push(Binding::new( + format!("mod+digit{}", index + 1), + only(&[S::App, S::Settings, S::Personas]), + C::TabByIndex(index), + )); + } + table.extend([ + Binding::new("mod+plus", all(), C::ZoomIn), + Binding::new("mod+shift+plus", all(), C::ZoomIn), + Binding::new("mod+minus", all(), C::ZoomOut), + Binding::new("mod+digit0", all(), C::ResetZoom), + Binding::new("alt+arrowleft", only(&[S::App]), C::AltBack).in_input(), + Binding::new("backspace", only(&[S::App]), C::BackspaceBack), + // Card focus navigation. + Binding::new("arrowleft", card_nav(), C::CardMove(Direction::Left)), + Binding::new("arrowright", card_nav(), C::CardMove(Direction::Right)), + Binding::new("arrowup", card_nav(), C::CardMove(Direction::Up)), + Binding::new("arrowdown", card_nav(), C::CardMove(Direction::Down)), + Binding::new("enter", card_nav(), C::CardActivate), + Binding::new("f2", only(&[S::App]), C::RenameFocused), + Binding::new("delete", only(&[S::App]), C::ContextMenuFocused), + Binding::new("shift+f10", only(&[S::App]), C::ContextMenuFocused), + Binding::new("contextmenu", only(&[S::App]), C::ContextMenuFocused), + Binding::new("space", only(&[S::BulkEdit]), C::BulkToggleFocused), + // Bulk edit selection, never from inside a text field. + Binding::new("mod+a", only(&[S::BulkEdit]), C::BulkSelectAll).skip_input(), + Binding::new("mod+d", only(&[S::BulkEdit]), C::BulkDeselectAll).skip_input(), + ]); + table +} + +/// A plain call a binding makes into the app, no answer expected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeyAction { + CloseContextMenu, + SetPaletteOpen(bool), + CloseInputDialog, + CloseConfirmDialog, + ClosePersonas, + /// `navigation.searchQuery = ""`. + ClearSearchQuery, + /// `active.blur()` on the search field. + BlurSearch, + /// `cardFocus.clear()`. + ClearCardFocus, + /// `searchInput.focus(); searchInput.select()`. + FocusSearch, + FocusSettingsSearch, + AddAccount, + NewFolder, + Refresh, + ToggleBulkEdit, + ToggleSettingsPanel, + OpenPersonas, + ToggleViewMode, + ChangeTab(String), + ZoomIn, + ZoomOut, + ResetZoom, + /// `handleNavigateBack`. + NavigateBack, + ToggleBulkEditAccount(String), + BulkEditSelectAll, + BulkEditDeselectAll, +} + +/// What the bindings read from and do to the app (`KeyboardBindingDeps`). +pub trait KeyHost { + /// `getCurrentFolderId() != null`. + fn in_folder(&self) -> bool; + /// `getEnabledPlatforms()` ids, in tab order. + fn enabled_platform_ids(&self) -> Vec; + /// `getUnavailablePlatformIds().has(id)`. + fn is_platform_unavailable(&self, platform_id: &str) -> bool; + fn active_tab(&self) -> String; + fn has_input_dialog(&self) -> bool; + /// `document.activeElement` is the grid search input. + fn search_input_focused(&self) -> bool; + /// `cardFocus.focusedItem`. + fn focused_item(&self) -> Option; + /// `cardFocus.ownsActivationKey(e.target)`: no other control holds real + /// focus. + fn card_focus_owns_activation_key(&self, event: &KeyEvent) -> bool; + fn is_bulk_edit_toggle_allowed(&self) -> bool; + + /// `drag.cancelFromEscape()`: true when a drag was cancelled. + fn cancel_drag_from_escape(&mut self) -> bool; + /// `personasPanel.handleEscape()`: true when the wizard took the key. + fn personas_panel_escape(&mut self) -> bool; + /// `cardFocus.move(direction)`: true when the focus moved. + fn card_focus_move(&mut self, direction: Direction) -> bool; + /// `cardActions.activateFocusedCard()`. + fn activate_focused_card(&mut self) -> bool; + /// `cardActions.renameFocusedCard()`. + fn rename_focused_card(&mut self) -> bool; + /// `cardActions.openFocusedCardContextMenu()`. + fn open_focused_card_context_menu(&mut self) -> bool; + + fn act(&mut self, action: KeyAction); +} + +/// What a command answered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyResult { + Handled, + /// The TS `PASS`. + Pass, +} + +fn handled_if(taken: bool) -> KeyResult { + if taken { + KeyResult::Handled + } else { + KeyResult::Pass + } +} + +/// `pickCycledTab`: the next tab for mod+tab / mod+shift+tab. Off the usable +/// list, forward enters at the first tab and backward at the last; `None` +/// with fewer than two tabs. +pub fn pick_cycled_tab(usable_ids: &[String], active_id: &str, direction: i8) -> Option { + let count = usable_ids.len(); + if count < 2 { + return None; + } + let Some(index) = usable_ids.iter().position(|id| id == active_id) else { + return Some(if direction >= 0 { + usable_ids[0].clone() + } else { + usable_ids[count - 1].clone() + }); + }; + let step = if direction >= 0 { 1 } else { count - 1 }; + Some(usable_ids[(index + step) % count].clone()) +} + +/// Runs one command against the host (the `run` closures of the TS table). +pub fn run_command(command: KeyCommand, event: &KeyEvent, host: &mut dyn KeyHost) -> KeyResult { + use KeyCommand as C; + let act = |host: &mut dyn KeyHost, action| { + host.act(action); + KeyResult::Handled + }; + match command { + C::Swallow => KeyResult::Handled, + C::Pass => KeyResult::Pass, + C::OpenPalette => { + host.act(KeyAction::CloseContextMenu); + act(host, KeyAction::SetPaletteOpen(true)) + } + C::ClosePalette => act(host, KeyAction::SetPaletteOpen(false)), + C::EscapeCancelDrag => handled_if(host.cancel_drag_from_escape()), + C::EscapeCloseDialog => { + if host.has_input_dialog() { + act(host, KeyAction::CloseInputDialog) + } else { + act(host, KeyAction::CloseConfirmDialog) + } + } + C::EscapePersonas => { + if !host.personas_panel_escape() { + host.act(KeyAction::ClosePersonas); + } + KeyResult::Handled + } + C::EscapeApp => { + if host.search_input_focused() { + host.act(KeyAction::ClearSearchQuery); + return act(host, KeyAction::BlurSearch); + } + if host.focused_item().is_some() { + return act(host, KeyAction::ClearCardFocus); + } + KeyResult::Pass + } + C::FocusSearch => act(host, KeyAction::FocusSearch), + C::FocusSettingsSearch => act(host, KeyAction::FocusSettingsSearch), + C::AddAccount => act(host, KeyAction::AddAccount), + C::NewFolder => act(host, KeyAction::NewFolder), + C::Refresh => act(host, KeyAction::Refresh), + C::ToggleBulkEdit => { + if host.is_bulk_edit_toggle_allowed() { + host.act(KeyAction::ToggleBulkEdit); + } + KeyResult::Handled + } + C::ToggleSettings => { + host.act(KeyAction::ClosePersonas); + act(host, KeyAction::ToggleSettingsPanel) + } + C::OpenPersonas => act(host, KeyAction::OpenPersonas), + C::ToggleViewMode => act(host, KeyAction::ToggleViewMode), + C::CycleTab(direction) => { + let usable: Vec = host + .enabled_platform_ids() + .into_iter() + .filter(|id| !host.is_platform_unavailable(id)) + .collect(); + if let Some(next) = pick_cycled_tab(&usable, &host.active_tab(), direction) { + host.act(KeyAction::ClosePersonas); + host.act(KeyAction::ChangeTab(next)); + } + KeyResult::Handled + } + C::TabByIndex(index) => { + let platform = host.enabled_platform_ids().get(index).cloned(); + if let Some(id) = platform.filter(|id| !host.is_platform_unavailable(id)) { + host.act(KeyAction::ClosePersonas); + host.act(KeyAction::ChangeTab(id)); + } + KeyResult::Handled + } + C::ZoomIn => act(host, KeyAction::ZoomIn), + C::ZoomOut => act(host, KeyAction::ZoomOut), + C::ResetZoom => act(host, KeyAction::ResetZoom), + C::AltBack => { + if host.in_folder() { + host.act(KeyAction::NavigateBack); + } + KeyResult::Handled + } + C::BackspaceBack => { + if !host.in_folder() { + return KeyResult::Pass; + } + act(host, KeyAction::NavigateBack) + } + C::CardMove(direction) => handled_if(host.card_focus_move(direction)), + C::CardActivate => { + handled_if(host.card_focus_owns_activation_key(event) && host.activate_focused_card()) + } + C::RenameFocused => handled_if(host.rename_focused_card()), + C::ContextMenuFocused => handled_if(host.open_focused_card_context_menu()), + C::BulkToggleFocused => { + if !host.card_focus_owns_activation_key(event) { + return KeyResult::Pass; + } + match host.focused_item() { + Some(item) if item.kind == ItemKind::Account => { + act(host, KeyAction::ToggleBulkEditAccount(item.id)) + } + _ => KeyResult::Pass, + } + } + C::BulkSelectAll => act(host, KeyAction::BulkEditSelectAll), + C::BulkDeselectAll => act(host, KeyAction::BulkEditDeselectAll), + } +} + +/// What [`KeyboardController::handle`] did with an event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyOutcome { + /// No binding took it: let egui widgets see it. + Unhandled, + /// Binding `index` of the table took it. `consume` is the TS + /// `preventDefault` plus `stopPropagation`. + Handled { index: usize, consume: bool }, +} + +impl KeyOutcome { + pub fn is_handled(self) -> bool { + matches!(self, KeyOutcome::Handled { .. }) + } +} + +/// `createKeyboardController` without the listener. +#[derive(Debug, Clone)] +pub struct KeyboardController { + bindings: Vec<(Binding, ParsedCombo)>, +} + +impl Default for KeyboardController { + fn default() -> Self { + Self::new(app_bindings()) + } +} + +impl KeyboardController { + pub fn new(bindings: Vec) -> Self { + Self { + bindings: bindings + .into_iter() + .map(|binding| { + let combo = parse_combo(&binding.combo); + (binding, combo) + }) + .collect(), + } + } + + pub fn bindings(&self) -> impl Iterator { + self.bindings.iter().map(|(binding, _)| binding) + } + + /// `handleKeydown`: the first binding whose scope and combo match, that + /// the text-field rules allow, and that does not pass, takes the key. + pub fn handle( + &self, + event: &KeyEvent, + scope: KeyScope, + is_mac: bool, + host: &mut dyn KeyHost, + ) -> KeyOutcome { + for (index, (binding, combo)) in self.bindings.iter().enumerate() { + if !binding.scopes.contains(scope) { + continue; + } + if !combo_matches(event, combo, is_mac) { + continue; + } + let has_modifier = combo.mod_key || combo.alt; + if event.editable_target && !has_modifier && !binding.allow_in_input { + continue; + } + if event.editable_target && binding.skip_in_input { + continue; + } + if run_command(binding.command, event, host) == KeyResult::Pass { + continue; + } + return KeyOutcome::Handled { + index, + consume: binding.prevent_default, + }; + } + KeyOutcome::Unhandled + } +} diff --git a/crates/accshift-app/src/actions/mod.rs b/crates/accshift-app/src/actions/mod.rs new file mode 100644 index 00000000..31587d81 --- /dev/null +++ b/crates/accshift-app/src/actions/mod.rs @@ -0,0 +1,177 @@ +//! What happens when the user acts: dialogs, card actions, navigation, +//! personas, the command palette, the keyboard model and toasts. +//! +//! Ported from the orchestration composables of `src/lib/app/` and the +//! feature stores they drive. Each TS composable becomes a state struct with +//! methods. A method that reaches into something another module owns (the +//! account loader, the add flow, bulk edit, the shell's active tab, the +//! clipboard) does not call it: it returns [`AppEffect`]s, in the order TS +//! performs those calls, and the egui `AppModel` applies them after the frame. +//! What these modules own themselves (dialog state, the history stack, the +//! toast queue, the stores they write) changes in place. +//! +//! - [`toasts::ToastQueue`] ports `features/notifications/store.svelte.ts` and the +//! timer of `Toast.svelte`. +//! - [`keyboard`] ports `shared/keyboard/{controller,types}.ts` and +//! `app/keyboardBindings.ts`: the binding table as data, combo parsing, +//! scope resolution and dispatch through a [`keyboard::KeyHost`]. +//! - [`palette`] ports `features/commandPalette/{fuzzy,registry}.ts` and the +//! filtering of `CommandPalette.svelte`. +//! - [`dialogs::AppDialogs`] ports `app/useAppDialogs.svelte.ts`, the validation of +//! `InputDialog.svelte`. +//! - [`card_actions`] ports `app/useCardActions.ts`. +//! - [`navigation`] ports `app/useAppNavigation.svelte.ts` with the browser +//! history as a stack, `app/tabCycle.ts`, `app/useSettingsPanel.svelte.ts`, +//! `features/settings/useSettingsTabBar.svelte.ts`. +//! - [`extension::ExtensionContent`] ports `app/useExtensionContent.svelte.ts`. +//! - [`personas`] ports `app/usePersonas.svelte.ts`, `app/usePersonaSwitch.svelte.ts`. +//! - [`onboarding::OnboardingTour`] ports `app/useOnboardingTour.svelte.ts`. +//! - [`avatar_fallback`] ports `shared/avatarFallback.ts`. +//! - [`shell`] ports the handlers `src/App.svelte` defines inline over several of +//! these. + +pub mod avatar_fallback; +pub mod card_actions; +pub mod dialogs; +pub mod extension; +pub mod keyboard; +pub mod navigation; +pub mod onboarding; +pub mod palette; +pub mod personas; +pub mod shell; +pub mod toasts; + +use crate::accounts::loader::LoadOptions; +use crate::accounts::Toast; +use crate::menu::PlatformTask; +use crate::platforms::PlatformAccount; +use crate::stores::ViewMode; +use crate::text::Text; + +/// `loadAccounts(silent, showRefreshedToast, forceRefresh, checkBans, +/// deferBackground)` with the TS defaults (`deferBackground` true). +pub fn load_options( + silent: bool, + show_refreshed_toast: bool, + force_refresh: bool, + check_bans: bool, +) -> LoadOptions { + LoadOptions { + silent, + show_refreshed_toast, + force_refresh, + check_bans, + defer_background: true, + } +} + +/// One call into something another module owns, in the order TS makes it. +#[derive(Debug, Clone, PartialEq)] +pub enum AppEffect { + /// `addToast` / `showToast`. + Toast(Toast), + /// `loadAccounts(...)` on the active tab. + LoadAccounts(LoadOptions), + /// `handleRefreshClick`. + RefreshAccounts, + /// `handleAddAccountClick`. + AddAccount, + /// `handleAccountSwitch(account)`. + SwitchAccount(PlatformAccount), + /// `handleNavigateToFolder(folderId)`: + /// [`navigation::AppNavigation::navigate_to`] with history. + NavigateToFolder(Option), + /// `navigation.refreshCurrentItems()`. + RefreshCurrentItems, + /// `loader.prepareVisibleAccounts()`. + PrepareVisibleAccounts, + /// `grid.queueCalculatePadding()`. + QueueGridPadding, + /// `loader.clearForPlatformChange()`. + ClearForPlatformChange, + /// `visiblePriming.reset()`. + ResetVisiblePrimeState, + /// `shell.setActiveTab(tab)`. + SetActiveTab(String), + /// `shell.refreshSettings()`: re-read the settings store. + RefreshSettings, + /// `secureScreen.handleSettingsClosed()`. + SettingsClosed, + /// `appNavigation.closeSettingsPanel()`: + /// [`navigation::AppNavigation::close_settings_panel`]. + CloseSettingsPanel, + /// `appNavigation.toggleSettingsPanel()`. + ToggleSettingsPanel, + /// `appNavigation.handleTabChange(tab)`. + ChangeTab(String), + /// `handleNavigateBack`: [`navigation::AppNavigation::navigate_to_parent_folder`] + /// (its add flow cancel comes first in the list). + NavigateToParentFolder, + /// The removal of an account from the grid (`removeAccount` glue of + /// App.svelte: loader, folder sync, items). + RemoveAccount(String), + /// `copyToClipboard(text, label)`: write it, then + /// [`shell::copy_result_toast`] with the label resolved. + CopyToClipboard { text: String, label: Text }, + /// Write several lines to the clipboard, then + /// [`card_actions::bulk_urls_copied_toast`] (`copyBulkEditUrls`). + CopyBulkUrls(Vec), + /// `cardColorVersion += 1`: card colours changed in the stores. + CardColorsChanged, + /// `cardNoteVersion += 1`: card notes changed in the stores. + CardNotesChanged, + /// A menu task for `PlatformAdapter::run_task` on the job pool. + RunPlatformTask(PlatformTask), + /// `adapter.setAccountLabel` then `loadAccounts(true)`, a + /// `toast.renameAccountFailed` on error: run + /// [`dialogs::rename_account_blocking`] on the pool and hand the result to + /// [`dialogs::finish_rename_account`]. + RenameAccount { + platform_id: String, + account_id: String, + label: String, + }, + /// A `requestConfirm` settled: route it to whoever holds the ticket. + ConfirmSettled { + ticket: dialogs::ConfirmTicket, + allowed: bool, + }, + /// `addFlow.cancel()`. + AddFlowCancel, + /// `addFlow.cancelIfConflicting(platformId, accountId?)`. + AddFlowCancelIfConflicting { + platform_id: String, + account_id: Option, + }, + /// `bulkEdit.closeBulkEdit()`. + CloseBulkEdit, + /// `bulkEdit.toggleBulkEdit()`. + ToggleBulkEdit, + /// `bulkEdit.toggleBulkEditAccount(id)`. + BulkEditToggleAccount(String), + /// `bulkEdit.handlePaintMouseDown(event)`. + BulkEditPaintStart, + /// `bulkEdit.bulkEditSelectAll()`. + BulkEditSelectAll, + /// `bulkEdit.bulkEditDeselectAll()`. + BulkEditDeselectAll, + /// `drag.handleGridMouseDown(event)`. + DragGridMouseDown, + /// `uiScale.zoomIn()`. + ZoomIn, + /// `uiScale.zoomOut()`. + ZoomOut, + /// `uiScale.resetZoom()`. + ResetZoom, + /// `updates.applyReadyUpdate()`. + ApplyUpdate, + /// The view mode changed and was stored; the grid recomputes its padding + /// when it is the grid. + ViewModeChanged(ViewMode), + /// An entry of a caller-built menu (`openCustomContextMenu`, the persona + /// cards) was clicked: the id that caller gave it. + CustomMenu(String), + /// `invoke("telemetry_track_persona_switch", ...)`, errors ignored. + TrackPersonaSwitch { platforms: u64, succeeded: u64 }, +} diff --git a/crates/accshift-app/src/actions/navigation.rs b/crates/accshift-app/src/actions/navigation.rs new file mode 100644 index 00000000..3c666ef2 --- /dev/null +++ b/crates/accshift-app/src/actions/navigation.rs @@ -0,0 +1,441 @@ +//! Navigation between tabs, folders and the settings panel, ported from +//! `src/lib/app/useAppNavigation.svelte.ts`, `src/lib/app/useSettingsPanel.svelte.ts` +//! and `src/lib/features/settings/useSettingsTabBar.svelte.ts`. +//! +//! The TS code drives the WebView history (`pushState`, `replaceState`, +//! `back`, `popstate`) so the mouse back button and Alt+Left walk folders and +//! close settings. [`HistoryStack`] is that history: `back` hands the entry the +//! `popstate` listener would get, and the caller applies it at once. + +use super::{load_options, AppEffect}; +use crate::accounts::loading::is_platform_usable; +use crate::display::FolderNavigation; +use crate::platforms::{PlatformRegistry, RuntimeOs}; +use crate::settings::AppSettings; +use crate::stores::ClientStores; + +pub use super::keyboard::pick_cycled_tab; + +/// `AppHistoryEntry`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppHistoryEntry { + pub tab: String, + pub folder_id: Option, + pub show_settings: bool, +} + +impl AppHistoryEntry { + pub fn new(tab: impl Into, folder_id: Option, show_settings: bool) -> Self { + Self { + tab: tab.into(), + folder_id, + show_settings, + } + } +} + +/// The session history of the window, one entry per `pushState`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoryStack { + entries: Vec, + index: usize, +} + +impl HistoryStack { + /// The mount's `history.replaceState({ tab, folderId: null, + /// showSettings: false })`. + pub fn new(initial: AppHistoryEntry) -> Self { + Self { + entries: vec![initial], + index: 0, + } + } + + /// `history.state`. + pub fn state(&self) -> &AppHistoryEntry { + &self.entries[self.index] + } + + /// `pushState`: drops the forward entries. + pub fn push(&mut self, entry: AppHistoryEntry) { + self.entries.truncate(self.index + 1); + self.entries.push(entry); + self.index += 1; + } + + /// `replaceState`. + pub fn replace(&mut self, entry: AppHistoryEntry) { + self.entries[self.index] = entry; + } + + /// `history.back()`: the entry `popstate` delivers, `None` at the start. + pub fn back(&mut self) -> Option { + if self.index == 0 { + return None; + } + self.index -= 1; + Some(self.state().clone()) + } + + /// `history.forward()`. + pub fn forward(&mut self) -> Option { + if self.index + 1 >= self.entries.len() { + return None; + } + self.index += 1; + Some(self.state().clone()) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn index(&self) -> usize { + self.index + } +} + +/// `createSettingsPanel`. egui has no lazy import, so `loadComponent` and its +/// failure toast have nothing to do. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SettingsPanel { + pub show_settings: bool, +} + +impl SettingsPanel { + /// `open`. + pub fn open(&mut self) { + self.show_settings = true; + } + + /// `close`: hides the panel and runs `onClose` (App.svelte: refresh the + /// settings, tell the secure screen). + pub fn close(&mut self) -> Vec { + self.show_settings = false; + vec![AppEffect::RefreshSettings, AppEffect::SettingsClosed] + } +} + +/// `SettingsTabDef`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettingsTabDef { + pub id: String, + pub label_key: String, + pub accent: String, +} + +/// `createSettingsTabBar`: the selected settings tab. The scroll into view +/// is a request the UI takes once. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettingsTabBar { + active_tab: String, + scroll_requested: bool, +} + +impl Default for SettingsTabBar { + fn default() -> Self { + Self { + active_tab: "general".to_string(), + scroll_requested: false, + } + } +} + +impl SettingsTabBar { + pub fn new() -> Self { + Self::default() + } + + pub fn active_tab(&self) -> &str { + &self.active_tab + } + + /// `select`: the caller runs `onTabSelected(tab_id)`. + pub fn select(&mut self, tab_id: &str) { + self.active_tab = tab_id.to_string(); + self.scroll_requested = true; + } + + /// `ensureActiveVisible`: a hidden tab falls back to the first visible + /// one (`general` when none). The new tab when it changed, for + /// `onTabSelected`. + pub fn ensure_active_visible(&mut self, visible_ids: &[String]) -> Option { + let mut changed = None; + if !visible_ids.contains(&self.active_tab) { + self.active_tab = visible_ids + .first() + .cloned() + .unwrap_or_else(|| "general".to_string()); + changed = Some(self.active_tab.clone()); + } + self.scroll_requested = true; + changed + } + + /// `queueScrollActive`: true once after each select or check. + pub fn take_scroll_request(&mut self) -> bool { + std::mem::take(&mut self.scroll_requested) + } +} + +/// What a navigation step reads and writes. +pub struct NavContext<'a> { + /// `shell.activeTab` before the step. + pub active_tab: &'a str, + pub runtime_os: RuntimeOs, + pub registry: &'a PlatformRegistry, + /// `shell.settings`. For [`AppNavigation::handle_platforms_changed`], + /// the settings after `refreshSettings`. + pub settings: &'a AppSettings, + /// `addFlow.flow?.platformId`. + pub add_flow_platform: Option<&'a str>, + pub navigation: &'a mut FolderNavigation, + pub settings_panel: &'a mut SettingsPanel, + pub stores: &'a ClientStores, +} + +impl NavContext<'_> { + fn usable(&self, tab: &str) -> bool { + is_platform_usable(self.registry, tab, self.runtime_os) + } + + fn current_folder(&self) -> Option { + self.navigation.current_folder_id().map(str::to_string) + } +} + +/// `createAppNavigationController`, with the history it drives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppNavigation { + pub history: HistoryStack, +} + +impl AppNavigation { + /// The history starts on `tab` at its root, settings closed. + pub fn new(tab: &str) -> Self { + Self { + history: HistoryStack::new(AppHistoryEntry::new(tab, None, false)), + } + } + + /// `toggleSettingsPanel`. + pub fn toggle_settings_panel(&mut self, ctx: &mut NavContext<'_>) -> Vec { + if ctx.settings_panel.show_settings { + return self.close_settings_panel(ctx); + } + let entry = AppHistoryEntry::new(ctx.active_tab, ctx.current_folder(), true); + self.history.push(entry); + ctx.settings_panel.show_settings = true; + vec![AppEffect::AddFlowCancel, AppEffect::CloseBulkEdit] + } + + /// `applyAppState`: what a history entry restores. + pub fn apply_app_state( + &mut self, + entry: &AppHistoryEntry, + ctx: &mut NavContext<'_>, + ) -> Vec { + let mut effects = Vec::new(); + if let Some(flow_platform) = ctx.add_flow_platform { + if entry.show_settings + || entry.tab != flow_platform + || entry.folder_id.as_deref() != ctx.navigation.current_folder_id() + { + effects.push(AppEffect::AddFlowCancel); + } + } + let tab_changed = ctx.active_tab != entry.tab; + let settings_closing = ctx.settings_panel.show_settings && !entry.show_settings; + if tab_changed { + effects.push(AppEffect::ClearForPlatformChange); + effects.push(AppEffect::SetActiveTab(entry.tab.clone())); + } + ctx.navigation + .set_current_folder_id(entry.folder_id.clone()); + ctx.settings_panel.show_settings = entry.show_settings; + if settings_closing { + effects.push(AppEffect::RefreshSettings); + effects.push(AppEffect::SettingsClosed); + } + if tab_changed && ctx.usable(&entry.tab) { + effects.push(AppEffect::LoadAccounts(load_options( + true, false, false, false, + ))); + } else { + effects.extend([ + AppEffect::RefreshCurrentItems, + AppEffect::PrepareVisibleAccounts, + AppEffect::QueueGridPadding, + ]); + } + ctx.navigation.set_search_query(""); + effects + } + + /// `handlePopState`. + pub fn handle_pop_state( + &mut self, + entry: &AppHistoryEntry, + ctx: &mut NavContext<'_>, + ) -> Vec { + self.apply_app_state(entry, ctx) + } + + /// The mouse back button: `history.back()` then its `popstate`. + pub fn go_back(&mut self, ctx: &mut NavContext<'_>) -> Vec { + match self.history.back() { + Some(entry) => self.handle_pop_state(&entry, ctx), + None => Vec::new(), + } + } + + /// The mouse forward button. + pub fn go_forward(&mut self, ctx: &mut NavContext<'_>) -> Vec { + match self.history.forward() { + Some(entry) => self.handle_pop_state(&entry, ctx), + None => Vec::new(), + } + } + + /// `closeSettingsPanel`: back out of the settings entry when it is the + /// current one, otherwise replace it with a closed state. + pub fn close_settings_panel(&mut self, ctx: &mut NavContext<'_>) -> Vec { + if !ctx.settings_panel.show_settings { + return Vec::new(); + } + if self.history.state().show_settings { + return self.go_back(ctx); + } + let next = AppHistoryEntry::new(ctx.active_tab, ctx.current_folder(), false); + self.history.replace(next.clone()); + self.apply_app_state(&next, ctx) + } + + /// `navigateToParentFolder`. + pub fn navigate_to_parent_folder(&mut self, ctx: &mut NavContext<'_>) -> Vec { + let Some(current) = ctx.current_folder() else { + return Vec::new(); + }; + let mut effects = vec![AppEffect::AddFlowCancelIfConflicting { + platform_id: ctx.active_tab.to_string(), + account_id: None, + }]; + let parent = ctx + .stores + .folders() + .folder(¤t) + .and_then(|folder| folder.parent_id.clone()); + self.history + .replace(AppHistoryEntry::new(ctx.active_tab, parent.clone(), false)); + ctx.navigation.set_current_folder_id(parent); + ctx.settings_panel.show_settings = false; + effects.extend([ + AppEffect::RefreshCurrentItems, + AppEffect::PrepareVisibleAccounts, + ]); + ctx.navigation.set_search_query(""); + effects.push(AppEffect::QueueGridPadding); + effects + } + + /// `navigateTo(folderId, { trackHistory })`. + pub fn navigate_to( + &mut self, + folder_id: Option, + track_history: bool, + ctx: &mut NavContext<'_>, + ) -> Vec { + let effects_head = AppEffect::AddFlowCancelIfConflicting { + platform_id: ctx.active_tab.to_string(), + account_id: None, + }; + if track_history { + self.history.push(AppHistoryEntry::new( + ctx.active_tab, + folder_id.clone(), + false, + )); + } + ctx.navigation.set_current_folder_id(folder_id); + ctx.settings_panel.show_settings = false; + vec![ + effects_head, + AppEffect::RefreshCurrentItems, + AppEffect::PrepareVisibleAccounts, + AppEffect::QueueGridPadding, + ] + } + + /// `handleTabChange`. Clicking the tab on screen only leaves settings. + pub fn handle_tab_change(&mut self, tab: &str, ctx: &mut NavContext<'_>) -> Vec { + if !ctx.usable(tab) { + return Vec::new(); + } + if tab == ctx.active_tab { + if ctx.settings_panel.show_settings { + return self.close_settings_panel(ctx); + } + return Vec::new(); + } + let mut effects = vec![AppEffect::AddFlowCancel, AppEffect::CloseBulkEdit]; + self.history.push(AppHistoryEntry::new(tab, None, false)); + effects.extend([ + AppEffect::ResetVisiblePrimeState, + AppEffect::ClearForPlatformChange, + AppEffect::SetActiveTab(tab.to_string()), + ]); + ctx.navigation.set_current_folder_id(None); + ctx.settings_panel.show_settings = false; + if ctx.usable(tab) { + effects.push(AppEffect::LoadAccounts(load_options( + true, false, false, false, + ))); + } else { + effects.extend([AppEffect::RefreshCurrentItems, AppEffect::QueueGridPadding]); + } + ctx.navigation.set_search_query(""); + effects + } + + /// `handlePlatformsChanged`: after the enabled platforms changed in + /// settings. A tab that is no longer enabled or usable moves to + /// `initial_tab()` (`getInitialActiveTab` of the platform shell, over the + /// refreshed settings). + pub fn handle_platforms_changed( + &mut self, + ctx: &mut NavContext<'_>, + initial_tab: impl FnOnce() -> String, + ) -> Vec { + let mut effects = Vec::new(); + if ctx.add_flow_platform.is_some() { + effects.push(AppEffect::AddFlowCancel); + } + effects.push(AppEffect::RefreshSettings); + let mut tab = ctx.active_tab.to_string(); + let enabled = ctx.settings.enabled_platforms.contains(&tab); + if !enabled || !ctx.usable(&tab) { + effects.push(AppEffect::ResetVisiblePrimeState); + effects.push(AppEffect::ClearForPlatformChange); + tab = initial_tab(); + effects.push(AppEffect::SetActiveTab(tab.clone())); + } + ctx.navigation.set_current_folder_id(None); + self.history.replace(AppHistoryEntry::new( + tab.clone(), + None, + ctx.settings_panel.show_settings, + )); + if ctx.usable(&tab) { + effects.push(AppEffect::LoadAccounts(load_options( + false, false, false, false, + ))); + } else { + effects.push(AppEffect::RefreshCurrentItems); + } + effects + } +} diff --git a/crates/accshift-app/src/actions/onboarding.rs b/crates/accshift-app/src/actions/onboarding.rs new file mode 100644 index 00000000..b7491fa7 --- /dev/null +++ b/crates/accshift-app/src/actions/onboarding.rs @@ -0,0 +1,152 @@ +//! The first-run telemetry onboarding and the fake account grid its feature +//! tour draws over. Ported from `src/lib/app/useOnboardingTour.svelte.ts` +//! (state and steps; the dialog itself is drawn by the egui crate). +//! +//! A new install has no accounts, which is when the tour needs cards to point +//! at: three mock accounts replace the workspace for the duration and the +//! previous tab comes back afterwards. + +use super::AppEffect; +use crate::backend::Backend; +use crate::folders::ItemRef; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::platforms::PlatformAccount; +use crate::text::Text; + +/// Ids of the fake accounts, prefixed so nothing takes one for a real id. +pub const MOCK_IDS: [&str; 3] = ["__tour_mock_1", "__tour_mock_2", "__tour_mock_3"]; +/// The tour walks through Steam's card features. +pub const TOUR_PLATFORM: &str = "steam"; + +/// A fake account: its display name stays a key, resolved at draw time. +#[derive(Debug, Clone, PartialEq)] +pub struct MockAccount { + pub account: PlatformAccount, + /// `onboarding.features.mockAccount` with `{number}`. + pub display_name: Text, +} + +/// `mockAccounts`. +pub fn mock_accounts() -> Vec { + MOCK_IDS + .iter() + .enumerate() + .map(|(index, id)| { + let number = index + 1; + MockAccount { + account: PlatformAccount { + id: (*id).to_string(), + display_name: String::new(), + username: format!("account_{number}"), + last_login_at_sec: None, + }, + display_name: Text::with( + "onboarding.features.mockAccount", + [("number", number as f64)], + ), + } + }) + .collect() +} + +/// `mockItems`. +pub fn mock_items() -> Vec { + MOCK_IDS.iter().map(|id| ItemRef::account(*id)).collect() +} + +/// `createOnboardingTour`. +#[derive(Default)] +pub struct OnboardingTour { + open: bool, + mock_active: bool, + previous_tab: Option, + state_read: Option>, +} + +impl OnboardingTour { + pub fn new() -> Self { + Self::default() + } + + pub fn open(&self) -> bool { + self.open + } + + /// True while the workspace shows the tour's fake accounts. + pub fn mock_active(&self) -> bool { + self.mock_active + } + + /// `openOnboarding`. + pub fn open_onboarding(&mut self) { + self.open = true; + } + + fn activate_mock(&mut self, active_tab: &str) -> Vec { + self.previous_tab = Some(active_tab.to_string()); + let mut effects = Vec::new(); + if active_tab != TOUR_PLATFORM { + effects.push(AppEffect::SetActiveTab(TOUR_PLATFORM.to_string())); + } + self.mock_active = true; + effects + } + + fn deactivate_mock(&mut self, active_tab: &str) -> Vec { + self.mock_active = false; + let mut effects = Vec::new(); + if let Some(previous) = self.previous_tab.take() { + if !previous.is_empty() && previous != active_tab { + effects.push(AppEffect::SetActiveTab(previous)); + } + } + effects + } + + /// `setMockActive`. + pub fn set_mock_active(&mut self, active: bool, active_tab: &str) -> Vec { + if active { + self.activate_mock(active_tab) + } else { + self.deactivate_mock(active_tab) + } + } + + /// `close`. + pub fn close(&mut self, active_tab: &str) -> Vec { + self.open = false; + self.deactivate_mock(active_tab) + } + + /// `openIfNeverCompleted`, started: reads `telemetry_get_state` on the + /// pool. A failed read shows nothing. + pub fn start_open_if_never_completed(&mut self, jobs: &Jobs) { + self.state_read = Some(jobs.run(|backend: &dyn Backend| { + backend + .telemetry_get_state() + .map(|state| !state.onboarding_completed) + .unwrap_or(false) + })); + } + + /// Applies the read once it lands: true when the onboarding opened. + pub fn poll(&mut self) -> bool { + let Some(pending) = self.state_read.as_mut() else { + return false; + }; + match pending.poll() { + Poll::Waiting => false, + Poll::Ready(show) => { + self.state_read = None; + if show { + self.open_onboarding(); + } + show + } + Poll::Gone => { + self.state_read = None; + false + } + } + } +} diff --git a/crates/accshift-app/src/actions/palette.rs b/crates/accshift-app/src/actions/palette.rs new file mode 100644 index 00000000..ba161918 --- /dev/null +++ b/crates/accshift-app/src/actions/palette.rs @@ -0,0 +1,585 @@ +//! The command palette, ported from +//! `src/lib/features/commandPalette/{fuzzy,registry}.ts` and the filtering, +//! grouping and selection of `CommandPalette.svelte`. + +use crate::display::fold_search_text; +use crate::folders::FolderInfo; +use crate::i18n::Locale; +use crate::platforms::{PlatformAccount, PlatformDef}; +use crate::stores::ViewMode; +use crate::text::Text; +use std::cmp::Ordering; +use std::collections::{BTreeSet, HashMap}; + +// ------------------------------------------------------------- fuzzy + +/// `foldText`: NFD, combining diacritics dropped, lowercased. The same fold +/// as the grid search. +pub fn fold_text(text: &str) -> String { + fold_search_text(text) +} + +/// `scoreFolded` over UTF-16 units, as JS indexes strings. +fn score_folded(query: &[u16], target: &[u16]) -> Option { + if query.is_empty() { + return Some(0); + } + let mut score: i64 = 0; + let mut ti = 0usize; + let mut last_match: i64 = -2; + for &ch in query { + let found = target + .get(ti..) + .and_then(|rest| rest.iter().position(|&unit| unit == ch)) + .map(|offset| ti + offset)?; + if found as i64 == last_match + 1 { + score += 6; + } + if found == 0 + || target[found - 1] == u16::from(b' ') + || target[found - 1] == u16::from(b'-') + { + score += 8; + } + score += 1; + // A small penalty for skipped distance keeps tight matches on top. + score -= ((found - ti) as i64).min(4); + last_match = found as i64; + ti = found + 1; + } + // Shorter targets win ties. + score += (12 - (target.len() / 4) as i64).max(0); + Some(score) +} + +fn utf16(text: &str) -> Vec { + text.encode_utf16().collect() +} + +/// `fuzzyScore`: every query character in order in the text, consecutive +/// matches and word starts scoring higher. `None` when it is not a +/// subsequence. +pub fn fuzzy_score(query: &str, text: &str) -> Option { + score_folded(&utf16(&fold_text(query)), &utf16(&fold_text(text))) +} + +/// The folded targets, memoised like `foldCache` (cleared at 4096 entries). +#[derive(Debug, Clone, Default)] +pub struct FoldCache { + entries: HashMap>, +} + +impl FoldCache { + pub const MAX: usize = 4096; + + fn get(&mut self, text: &str) -> Vec { + if let Some(hit) = self.entries.get(text) { + return hit.clone(); + } + if self.entries.len() >= Self::MAX { + self.entries.clear(); + } + let folded = utf16(&fold_text(text)); + self.entries.insert(text.to_string(), folded.clone()); + folded + } +} + +/// `fuzzyScoreAll`: the best score over a title and its keywords; a keyword +/// hit counts 2 less than the same title hit. +pub fn fuzzy_score_all( + cache: &mut FoldCache, + query: &str, + title: &str, + keywords: &[String], +) -> Option { + let q = utf16(&fold_text(query)); + let mut best = score_folded(&q, &cache.get(title)); + for keyword in keywords { + if let Some(score) = score_folded(&q, &cache.get(keyword)) { + if best.is_none_or(|current| score - 2 > current) { + best = Some(score - 2); + } + } + } + best +} + +// ---------------------------------------------------------- registry + +/// `PaletteSection`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PaletteSection { + Accounts, + Actions, + Navigation, +} + +impl PaletteSection { + /// `SECTION_ORDER`. + pub const ORDER: [PaletteSection; 3] = [ + PaletteSection::Accounts, + PaletteSection::Actions, + PaletteSection::Navigation, + ]; + + /// `SECTION_LABEL_KEYS`. + pub fn label_key(self) -> &'static str { + match self { + PaletteSection::Accounts => "palette.sectionAccounts", + PaletteSection::Actions => "palette.sectionActions", + PaletteSection::Navigation => "palette.sectionNavigation", + } + } +} + +/// What running a command does (the TS `run` closures). +#[derive(Debug, Clone, PartialEq)] +pub enum PaletteAction { + /// Cancel a conflicting add flow, then switch. + SwitchToAccount(PlatformAccount), + AddAccount, + RefreshAccounts, + NewFolder, + ToggleViewMode, + ZoomReset, + ToggleBulkEdit, + OpenPersonas, + ApplyUpdate, + /// Close personas, change tab. + ChangeTab(String), + OpenFolder(String), + NavigateToParent, + /// Close personas, toggle settings. + ToggleSettings, +} + +/// `PaletteCommand`. +#[derive(Debug, Clone, PartialEq)] +pub struct PaletteCommand { + pub id: String, + pub section: PaletteSection, + pub title: Text, + /// Extra matching terms, in both locales. + pub keywords: Vec, + /// The shortcut shown right-aligned. + pub hint: Option, + pub accent: Option, + pub active: bool, + pub disabled: bool, + pub action: PaletteAction, +} + +impl PaletteCommand { + fn new( + id: impl Into, + section: PaletteSection, + title: Text, + action: PaletteAction, + ) -> Self { + Self { + id: id.into(), + section, + title, + keywords: Vec::new(), + hint: None, + accent: None, + active: false, + disabled: false, + action, + } + } + + fn keywords(mut self, keywords: &[&str]) -> Self { + self.keywords = keywords.iter().map(|word| word.to_string()).collect(); + self + } + + fn hint(mut self, hint: String) -> Self { + self.hint = Some(hint); + self + } +} + +/// What the registry reads (`RegistryDeps` getters). +#[derive(Debug, Clone)] +pub struct RegistryInput<'a> { + pub accounts: &'a [PlatformAccount], + pub current_account_id: Option<&'a str>, + pub enabled_platforms: &'a [&'a PlatformDef], + pub unavailable_platform_ids: &'a BTreeSet, + pub active_tab: &'a str, + pub active_tab_usable: bool, + /// The folders shown in the current view. + pub current_folders: &'a [&'a FolderInfo], + pub current_folder_id: Option<&'a str>, + pub bulk_edit_available: bool, + pub personas_enabled: bool, + /// `getUpdateCtaLabel()` is non-empty. + pub update_ready: bool, + pub view_mode: ViewMode, + pub is_mac: bool, +} + +fn mod_label(is_mac: bool) -> &'static str { + if is_mac { + "Cmd" + } else { + "Ctrl" + } +} + +fn account_commands(input: &RegistryInput<'_>) -> Vec { + input + .accounts + .iter() + .map(|account| { + let title = [&account.display_name, &account.username, &account.id] + .into_iter() + .find(|value| !value.is_empty()) + .cloned() + .unwrap_or_default(); + let mut command = PaletteCommand::new( + format!("account:{}", account.id), + PaletteSection::Accounts, + Text::raw(title), + PaletteAction::SwitchToAccount(account.clone()), + ); + command.keywords = [&account.username, &account.id] + .into_iter() + .filter(|value| !value.is_empty()) + .cloned() + .collect(); + command.active = Some(account.id.as_str()) == input.current_account_id; + command + }) + .collect() +} + +fn action_commands(input: &RegistryInput<'_>) -> Vec { + use PaletteAction as A; + use PaletteSection::Actions; + let m = mod_label(input.is_mac); + let usable = input.active_tab_usable; + let mut add = PaletteCommand::new( + "action:add-account", + Actions, + Text::key("titlebar.addAccount"), + A::AddAccount, + ) + .keywords(&[ + "add account", + "ajouter un compte", + "nouveau compte", + "new account", + ]) + .hint(format!("{m}+N")); + add.disabled = !usable; + let mut refresh = PaletteCommand::new( + "action:refresh", + Actions, + Text::key("titlebar.refresh"), + A::RefreshAccounts, + ) + .keywords(&["refresh", "reload", "rafraichir", "actualiser"]) + .hint(format!("{m}+R")); + refresh.disabled = !usable; + let view_key = if input.view_mode == ViewMode::Grid { + "view.list" + } else { + "view.grid" + }; + let mut commands = vec![ + add, + refresh, + PaletteCommand::new( + "action:new-folder", + Actions, + Text::key("context.menu.newFolder"), + A::NewFolder, + ) + .keywords(&["new folder", "nouveau dossier", "creer dossier"]) + .hint(format!("{m}+Shift+N")), + PaletteCommand::new( + "action:toggle-view", + Actions, + Text::key(view_key), + A::ToggleViewMode, + ) + .keywords(&["view", "grid", "list", "vue", "grille", "liste"]) + .hint(format!("{m}+Shift+L")), + PaletteCommand::new( + "action:zoom-reset", + Actions, + Text::key("palette.zoomReset"), + A::ZoomReset, + ) + .keywords(&["zoom", "scale", "taille", "100%"]) + .hint(format!("{m}+0")), + ]; + if input.bulk_edit_available { + commands.push( + PaletteCommand::new( + "action:bulk-edit", + Actions, + Text::key("bulkEdit.title"), + A::ToggleBulkEdit, + ) + .keywords(&["bulk edit", "selection", "edition groupee", "multi"]) + .hint(format!("{m}+E")), + ); + } + if input.personas_enabled { + commands.push( + PaletteCommand::new( + "action:personas", + Actions, + Text::key("titlebar.personas"), + A::OpenPersonas, + ) + .keywords(&["personas", "profiles", "profils"]) + .hint(format!("{m}+Shift+P")), + ); + } + if input.update_ready { + commands.push( + PaletteCommand::new( + "action:apply-update", + Actions, + Text::key("update.restartToApply"), + A::ApplyUpdate, + ) + .keywords(&["update", "mise a jour", "restart", "redemarrer"]), + ); + } + commands +} + +fn navigation_commands(input: &RegistryInput<'_>) -> Vec { + use PaletteSection::Navigation; + let m = mod_label(input.is_mac); + let mut commands: Vec = input + .enabled_platforms + .iter() + .enumerate() + .map(|(index, platform)| { + let mut command = PaletteCommand::new( + format!("nav:tab:{}", platform.id), + Navigation, + Text::with("palette.openTab", [("platform", platform.name.as_str())]), + PaletteAction::ChangeTab(platform.id.clone()), + ); + command.keywords = vec![platform.name.clone(), platform.id.clone()]; + command.hint = (index < 9).then(|| format!("{m}+{}", index + 1)); + command.accent = Some(platform.accent.clone()); + command.active = platform.id == input.active_tab; + command.disabled = input.unavailable_platform_ids.contains(&platform.id); + command + }) + .collect(); + for folder in input.current_folders { + let mut command = PaletteCommand::new( + format!("nav:folder:{}", folder.id), + Navigation, + Text::with("palette.openFolder", [("name", folder.name.as_str())]), + PaletteAction::OpenFolder(folder.id.clone()), + ); + command.keywords = vec![folder.name.clone(), "folder".into(), "dossier".into()]; + commands.push(command); + } + if input.current_folder_id.is_some() { + commands.push( + PaletteCommand::new( + "nav:parent", + Navigation, + Text::key("palette.backToParent"), + PaletteAction::NavigateToParent, + ) + .keywords(&["back", "parent", "retour", "dossier parent"]) + .hint("Alt+\u{2190}".to_string()), + ); + } + commands.push( + PaletteCommand::new( + "nav:settings", + Navigation, + Text::key("titlebar.settings"), + PaletteAction::ToggleSettings, + ) + .keywords(&[ + "settings", + "preferences", + "parametres", + "options", + "reglages", + ]) + .hint(format!("{m}+,")), + ); + commands +} + +/// `getCommands`: accounts, then actions, then navigation. +pub fn palette_commands(input: &RegistryInput<'_>) -> Vec { + let mut commands = account_commands(input); + commands.extend(action_commands(input)); + commands.extend(navigation_commands(input)); + commands +} + +// -------------------------------------------------------------- view + +/// `EMPTY_QUERY_ACCOUNT_LIMIT`. +pub const EMPTY_QUERY_ACCOUNT_LIMIT: usize = 6; + +/// A row of the list: a section header or a command (its index in +/// [`CommandPalette::rows`]' flat order and in the command list). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaletteRow { + Header(PaletteSection), + Command { index: usize, command: usize }, +} + +/// Alphabetical tie-break. TS uses `localeCompare` in the UI locale; this +/// compares the folded titles, then the raw ones. +fn collate(a: &str, b: &str) -> Ordering { + fold_text(a).cmp(&fold_text(b)).then_with(|| a.cmp(b)) +} + +/// `CommandPalette.svelte` state: the query and the highlighted row. +#[derive(Debug, Clone, Default)] +pub struct CommandPalette { + query: String, + active_index: usize, + cache: FoldCache, +} + +impl CommandPalette { + pub fn new() -> Self { + Self::default() + } + + pub fn query(&self) -> &str { + &self.query + } + + /// Typing resets the highlight to the first row. + pub fn set_query(&mut self, query: impl Into) { + let query = query.into(); + if query != self.query { + self.query = query; + self.active_index = 0; + } + } + + pub fn active_index(&self) -> usize { + self.active_index + } + + /// `filtered`: idle, the first accounts then every other command; with a + /// query, the matches by score (accounts +4, actions +2), ties + /// alphabetical. Indices into `commands`. + pub fn filtered(&mut self, commands: &[PaletteCommand], locale: Locale) -> Vec { + let query = crate::js::trim(&self.query).to_string(); + if query.is_empty() { + let accounts = commands + .iter() + .enumerate() + .filter(|(_, command)| command.section == PaletteSection::Accounts) + .map(|(index, _)| index) + .take(EMPTY_QUERY_ACCOUNT_LIMIT); + let rest = commands + .iter() + .enumerate() + .filter(|(_, command)| command.section != PaletteSection::Accounts) + .map(|(index, _)| index); + return accounts.chain(rest).collect(); + } + let mut scored: Vec<(usize, i64, String)> = Vec::new(); + for (index, command) in commands.iter().enumerate() { + let title = command.title.resolve(locale); + let Some(score) = fuzzy_score_all(&mut self.cache, &query, &title, &command.keywords) + else { + continue; + }; + let bias = match command.section { + PaletteSection::Accounts => 4, + PaletteSection::Actions => 2, + PaletteSection::Navigation => 0, + }; + scored.push((index, score + bias, title)); + } + scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| collate(&a.2, &b.2))); + scored.into_iter().map(|(index, _, _)| index).collect() + } + + /// `rows`: section headers interleaved with their commands, in section + /// order. + pub fn rows(&mut self, commands: &[PaletteCommand], locale: Locale) -> Vec { + let filtered = self.filtered(commands, locale); + let mut rows = Vec::new(); + let mut index = 0; + for section in PaletteSection::ORDER { + let in_section: Vec = filtered + .iter() + .copied() + .filter(|&command| commands[command].section == section) + .collect(); + if in_section.is_empty() { + continue; + } + rows.push(PaletteRow::Header(section)); + for command in in_section { + rows.push(PaletteRow::Command { index, command }); + index += 1; + } + } + rows + } + + /// `flatCommands`: the commands in row order. + pub fn flat_commands(&mut self, commands: &[PaletteCommand], locale: Locale) -> Vec { + self.rows(commands, locale) + .into_iter() + .filter_map(|row| match row { + PaletteRow::Command { command, .. } => Some(command), + PaletteRow::Header(_) => None, + }) + .collect() + } + + /// `moveActive`: wraps around. + pub fn move_active(&mut self, delta: i64, count: usize) { + if count == 0 { + return; + } + let count = count as i64; + self.active_index = (((self.active_index as i64 + delta) % count + count) % count) as usize; + } + + /// Home with an empty query. + pub fn home(&mut self) { + if self.query.is_empty() { + self.active_index = 0; + } + } + + /// End with an empty query. + pub fn end(&mut self, count: usize) { + if self.query.is_empty() { + self.active_index = count.saturating_sub(1); + } + } + + /// Enter: the highlighted command's action, `None` when there is none or + /// it is disabled. The caller closes the palette first, then runs it. + pub fn enter(&mut self, commands: &[PaletteCommand], locale: Locale) -> Option { + let flat = self.flat_commands(commands, locale); + let command = &commands[*flat.get(self.active_index)?]; + run_command(command) + } +} + +/// `runCommand`: nothing for a disabled command. +pub fn run_command(command: &PaletteCommand) -> Option { + (!command.disabled).then(|| command.action.clone()) +} diff --git a/crates/accshift-app/src/actions/personas.rs b/crates/accshift-app/src/actions/personas.rs new file mode 100644 index 00000000..f1c92825 --- /dev/null +++ b/crates/accshift-app/src/actions/personas.rs @@ -0,0 +1,369 @@ +//! Personas: the saved list, the sequential switch over every assigned +//! platform, the panel and the confirmed switch with its toast. Ported from +//! `src/lib/app/usePersonas.svelte.ts` and +//! `src/lib/app/usePersonaSwitch.svelte.ts`. + +use super::dialogs::{AppDialogs, ConfirmConfig, ConfirmTicket}; +use super::AppEffect; +use crate::accounts::{Toast, ToastKind}; +use crate::backend::Backend; +use crate::i18n::TranslationValue; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::personas::{Persona, PersonaInput, PersonaPatch}; +use crate::platforms::{PlatformAdapter, PlatformRegistry, RuntimeOs}; +use crate::settings::AppSettings; +use crate::stores::ClientStores; +use crate::text::Text; +use std::sync::Arc; + +/// One platform a persona switch could not land. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersonaSwitchFailure { + pub platform_id: String, + pub error: String, +} + +/// `PersonaSwitchResult`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PersonaSwitchResult { + pub succeeded: Vec, + pub failed: Vec, +} + +/// One assignment with its adapter, resolved on the UI thread +/// (`ensurePlatformLoaded`). +#[derive(Clone)] +pub struct SwitchStep { + pub platform_id: String, + pub account_id: String, + pub adapter: Option>, +} + +/// The steps of a persona, in assignment order. +pub fn switch_steps(registry: &PlatformRegistry, persona: &Persona) -> Vec { + persona + .assignments + .iter() + .map(|assignment| SwitchStep { + platform_id: assignment.platform_id.clone(), + account_id: assignment.account_id.clone(), + adapter: registry.adapter(&assignment.platform_id), + }) + .collect() +} + +/// The loop of `switchToPersona`, blocking, for the job pool: one platform at +/// a time through its adapter (Steam switches by account name, not id). A +/// failure does not stop the others; a PIN lock stops everything, and `None` +/// when nothing had landed yet. +pub fn switch_to_persona_blocking( + backend: &dyn Backend, + steps: &[SwitchStep], + settings: &AppSettings, +) -> Option { + let mut result = PersonaSwitchResult::default(); + for step in steps { + let outcome = match &step.adapter { + None => Err(format!( + "Error: Platform adapter unavailable: {}", + step.platform_id + )), + Some(adapter) => switch_one(backend, adapter, step, settings), + }; + match outcome { + Ok(()) => result.succeeded.push(step.platform_id.clone()), + Err(error) => { + let pin_locked = error.starts_with("pin_locked:"); + if pin_locked && result.succeeded.is_empty() { + return None; + } + result.failed.push(PersonaSwitchFailure { + platform_id: step.platform_id.clone(), + error, + }); + if pin_locked { + break; + } + } + } + } + Some(result) +} + +fn switch_one( + backend: &dyn Backend, + adapter: &PlatformAdapter, + step: &SwitchStep, + settings: &AppSettings, +) -> Result<(), String> { + let accounts = adapter + .load_accounts(backend) + .map_err(|error| error.message)?; + let account = accounts + .iter() + .find(|account| account.id == step.account_id) + .ok_or_else(|| { + format!( + "Error: Persona account unavailable: {}/{}", + step.platform_id, step.account_id + ) + })?; + adapter + .switch_account(backend, account, settings) + .map_err(|error| error.message) +} + +/// `createPersonaController`. +pub struct PersonaController { + personas: Vec, + switching: Option<(Persona, Pending>)>, +} + +impl PersonaController { + pub fn new(stores: &ClientStores) -> Self { + Self { + personas: stores.personas().to_vec(), + switching: None, + } + } + + pub fn personas(&self) -> &[Persona] { + &self.personas + } + + /// `switchingPersonaId`. + pub fn switching_persona_id(&self) -> Option<&str> { + self.switching + .as_ref() + .map(|(persona, _)| persona.id.as_str()) + } + + /// `switching`: updates and regular switches wait for it. + pub fn switching(&self) -> bool { + self.switching.is_some() + } + + /// `refresh`. + pub fn refresh(&mut self, stores: &ClientStores) { + self.personas = stores.personas().to_vec(); + } + + /// `create`. + pub fn create(&mut self, stores: &mut ClientStores, input: PersonaInput) -> Persona { + let persona = stores.create_persona(input); + self.refresh(stores); + persona + } + + /// `update`. + pub fn update(&mut self, stores: &mut ClientStores, id: &str, patch: &PersonaPatch) { + stores.update_persona(id, patch); + self.refresh(stores); + } + + /// `remove`. + pub fn remove(&mut self, stores: &mut ClientStores, id: &str) { + stores.delete_persona(id); + self.refresh(stores); + } + + /// `switchToPersona`, started: false when a switch already runs or a + /// regular account switch holds the clients (`isBlocked`). + pub fn start_switch( + &mut self, + jobs: &Jobs, + registry: &PlatformRegistry, + settings: &AppSettings, + persona: Persona, + account_switching: bool, + ) -> bool { + if self.switching.is_some() || account_switching { + return false; + } + let steps = switch_steps(registry, &persona); + let settings = settings.clone(); + let pending = + jobs.run(move |backend| switch_to_persona_blocking(backend, &steps, &settings)); + self.switching = Some((persona, pending)); + true + } + + /// The finished switch, once: the persona and its result (`None` when the + /// PIN lock stopped it before anything landed). Clears the switching id. + pub fn poll_switch(&mut self) -> Option<(Persona, Option)> { + let (_, pending) = self.switching.as_mut()?; + match pending.poll() { + Poll::Waiting => None, + Poll::Ready(result) => { + let (persona, _) = self.switching.take()?; + Some((persona, result)) + } + Poll::Gone => { + let (persona, _) = self.switching.take()?; + Some((persona, None)) + } + } + } +} + +/// A persona slot of the panel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersonaPlatform { + pub id: String, + pub name: String, + pub accent: String, +} + +/// `personaPlatforms`: implemented, usable on this OS and enabled. +pub fn persona_platforms( + registry: &PlatformRegistry, + os: RuntimeOs, + settings: &AppSettings, +) -> Vec { + registry + .defs() + .iter() + .filter(|def| { + def.implemented + && def.supported_os.contains(&os) + && settings.enabled_platforms.contains(&def.id) + }) + .map(|def| PersonaPlatform { + id: def.id.clone(), + name: def.name.clone(), + accent: def.accent.clone(), + }) + .collect() +} + +/// `createPersonaSwitch`: the panel and the confirmed switch. +#[derive(Debug, Clone, Default)] +pub struct PersonaSwitch { + pub show_personas: bool, + awaiting_confirm: Option<(ConfirmTicket, Persona)>, +} + +impl PersonaSwitch { + pub fn new() -> Self { + Self::default() + } + + /// `openPersonas`. + pub fn open_personas(&mut self, personas_enabled: bool, settings_open: bool) -> Vec { + if !personas_enabled { + return Vec::new(); + } + let mut effects = Vec::new(); + if settings_open { + effects.push(AppEffect::CloseSettingsPanel); + } + effects.push(AppEffect::CloseBulkEdit); + self.show_personas = true; + effects + } + + /// The `$effect` that closes the panel when the feature is turned off. + pub fn sync_enabled(&mut self, personas_enabled: bool) { + if !personas_enabled && self.show_personas { + self.show_personas = false; + } + } + + /// `handleSwitchPersona`, first half: asks for confirmation (several game + /// clients relaunch). Nothing while any switch runs. + pub fn handle_switch_persona( + &mut self, + dialogs: &mut AppDialogs, + persona: &Persona, + personas_switching: bool, + account_switching: bool, + ) -> Vec { + let mut effects = Vec::new(); + if personas_switching || account_switching { + return effects; + } + let ticket = dialogs.request_confirm( + ConfirmConfig { + title: Text::key("personas.switchConfirmTitle"), + message: Text::with( + "personas.switchConfirmMessage", + [ + ("name", TranslationValue::from(persona.name.clone())), + ( + "count", + TranslationValue::from(persona.assignments.len() as f64), + ), + ], + ), + confirm_label: Some(Text::key("personas.switchConfirmAction")), + confirm_color: None, + }, + &mut effects, + ); + self.awaiting_confirm = Some((ticket, persona.clone())); + effects + } + + /// Second half, on [`AppEffect::ConfirmSettled`]: the persona to hand to + /// [`PersonaController::start_switch`] when the ticket is ours and was + /// accepted. + pub fn confirm_settled(&mut self, ticket: ConfirmTicket, allowed: bool) -> Option { + let (awaited, _) = self.awaiting_confirm.as_ref()?; + if *awaited != ticket { + return None; + } + let (_, persona) = self.awaiting_confirm.take()?; + allowed.then_some(persona) + } + + /// The end of `handleSwitchPersona`: the telemetry counters, then the + /// outcome toast (all landed, none, or which platforms failed). + pub fn finish_switch( + persona: &Persona, + result: Option<&PersonaSwitchResult>, + platforms: &[PersonaPlatform], + ) -> Vec { + let Some(result) = result else { + return Vec::new(); + }; + let mut effects = vec![AppEffect::TrackPersonaSwitch { + platforms: persona.assignments.len() as u64, + succeeded: result.succeeded.len() as u64, + }]; + let name = persona.name.clone(); + let toast = if result.failed.is_empty() { + Toast::new( + Text::with("personas.switched", [("name", name)]), + ToastKind::Success, + ) + } else if result.succeeded.is_empty() { + Toast::new( + Text::with("personas.switchFailed", [("name", name)]), + ToastKind::Error, + ) + } else { + let failed = result + .failed + .iter() + .map(|failure| { + platforms + .iter() + .find(|platform| platform.id == failure.platform_id) + .map_or(failure.platform_id.clone(), |platform| { + platform.name.clone() + }) + }) + .collect::>() + .join(", "); + Toast::new( + Text::with( + "personas.switchPartial", + [("name", name), ("failed", failed)], + ), + ToastKind::Default, + ) + }; + effects.push(AppEffect::Toast(toast)); + effects + } +} diff --git a/crates/accshift-app/src/actions/shell.rs b/crates/accshift-app/src/actions/shell.rs new file mode 100644 index 00000000..05dfe140 --- /dev/null +++ b/crates/accshift-app/src/actions/shell.rs @@ -0,0 +1,355 @@ +//! The handlers `src/App.svelte` defines inline over the action modules, and +//! the small derived values its markup reads. Each item cites its App.svelte +//! lines. +//! +//! The command palette and keyboard actions are dispatched here because their +//! TS closures live in App.svelte (`createCommandRegistry` deps, lines +//! 514-557; `createKeyboardBindings` deps, lines 578-631). + +use super::dialogs::AppDialogs; +use super::keyboard::{current_key_scope, KeyAction, KeyScope}; +use super::palette::PaletteAction; +use super::personas::PersonaSwitch; +use super::AppEffect; +use crate::accounts::{Toast, ToastKind}; +use crate::menu::account::color_label_key; +use crate::platforms::PlatformDef; +use crate::settings::AppSettings; +use crate::stores::{ClientStores, ViewMode}; +use crate::text::Text; +use crate::types::Cs2BridgeAccount; + +/// `copyToClipboard` (lines 674-683): the toast after the write, +/// `toast.copied` with the label, `toast.copyFailed` when it failed. +pub fn copy_result_toast(label: &str, write_ok: bool) -> Toast { + if write_ok { + Toast::new( + Text::with("toast.copied", [("label", label)]), + ToastKind::Success, + ) + } else { + Toast::new(Text::key("toast.copyFailed"), ToastKind::Error) + } +} + +/// `getColorLabel` of the dialogs deps (line 116). +pub fn color_label(preset_id: &str) -> Text { + Text::key(color_label_key(preset_id).unwrap_or(preset_id)) +} + +/// The other view mode (`viewMode === "grid" ? "list" : "grid"`, lines 554 +/// and 609). +pub fn toggled_view_mode(mode: ViewMode) -> ViewMode { + match mode { + ViewMode::Grid => ViewMode::List, + ViewMode::List => ViewMode::Grid, + } +} + +/// `handleViewModeChange` (lines 380-384): stores the mode; the grid +/// recomputes its padding. +pub fn view_mode_change(stores: &mut ClientStores, mode: ViewMode) -> Vec { + stores.set_view_mode(mode); + let mut effects = vec![AppEffect::ViewModeChanged(mode)]; + if mode == ViewMode::Grid { + effects.push(AppEffect::QueueGridPadding); + } + effects +} + +/// `handleNavigateToFolder` (lines 390-392). +pub fn navigate_to_folder(folder_id: Option) -> Vec { + vec![AppEffect::NavigateToFolder(folder_id)] +} + +/// `handleNavigateBack` (lines 394-397). +pub fn navigate_back(active_tab: &str) -> Vec { + vec![ + AppEffect::AddFlowCancelIfConflicting { + platform_id: active_tab.to_string(), + account_id: None, + }, + AppEffect::NavigateToParentFolder, + ] +} + +/// `isBulkEditToggleAllowed` (lines 570-576), also the palette's +/// `isBulkEditAvailable` (lines 527-530). +pub fn is_bulk_edit_toggle_allowed( + active_def: Option<&PlatformDef>, + active_tab_usable: bool, + settings_open: bool, +) -> bool { + active_def.is_some_and(|def| def.capabilities.bulk_edit) && active_tab_usable && !settings_open +} + +/// `handleApplyUpdate` (lines 654-657): never while a switch is mid-flight. +pub fn apply_update(account_switching: bool, persona_switching: bool) -> Vec { + if account_switching || persona_switching { + return Vec::new(); + } + vec![AppEffect::ApplyUpdate] +} + +/// What decides the key scope (`createKeyScopeResolver` deps, lines +/// 559-568). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ScopeFlags { + pub pin_locked: bool, + pub streamer_active: bool, + pub render_suspended: bool, + pub palette_open: bool, + pub onboarding_open: bool, + /// An input or confirm dialog. + pub has_dialog: bool, + pub has_context_menu: bool, + pub bulk_edit_mode: bool, + pub settings_open: bool, + pub personas_open: bool, +} + +/// `currentKeyScope` over the App.svelte state. +pub fn key_scope(flags: &ScopeFlags) -> KeyScope { + current_key_scope(|scope| match scope { + KeyScope::Locked => flags.pin_locked || flags.streamer_active || flags.render_suspended, + KeyScope::Palette => flags.palette_open, + KeyScope::Onboarding => flags.onboarding_open, + KeyScope::Dialog => flags.has_dialog, + KeyScope::ContextMenu => flags.has_context_menu, + KeyScope::BulkEdit => flags.bulk_edit_mode, + KeyScope::Settings => flags.settings_open, + KeyScope::Personas => flags.personas_open, + KeyScope::App => false, + }) +} + +/// `panelKey` (lines 293-299): remounts the workspace panel on a switch. +pub fn panel_key(settings_open: bool, personas_open: bool, active_tab: &str) -> String { + if settings_open { + "__settings__".to_string() + } else if personas_open { + "__personas__".to_string() + } else { + active_tab.to_string() + } +} + +/// `detectedPlatformDefs` (lines 302-307): `(id, name)` of the detected +/// platforms, in registry order. +pub fn detected_platform_defs(defs: &[PlatformDef], detected: &[String]) -> Vec<(String, String)> { + defs.iter() + .filter(|def| detected.contains(&def.id)) + .map(|def| (def.id.clone(), def.name.clone())) + .collect() +} + +/// `createCs2ExtensionSections` (lines 318-320): Steam's alone. +pub fn cs2_sections_for_tab( + active_tab: &str, + data: Option<&Cs2BridgeAccount>, +) -> Vec { + if active_tab == "steam" { + crate::platforms::steam::cs2::cs2_extension_sections(data) + } else { + Vec::new() + } +} + +/// `getCs2UsernameBadge` (lines 322-324). +pub fn cs2_badge_for_tab( + active_tab: &str, + data: Option<&Cs2BridgeAccount>, +) -> Option { + if active_tab == "steam" { + crate::platforms::steam::cs2::cs2_username_badge(data) + } else { + None + } +} + +/// `showUsernamesForActiveTab` (lines 664-666). +pub fn show_usernames_for_active_tab( + active_def: Option<&PlatformDef>, + settings: &AppSettings, +) -> bool { + active_def.is_some_and(|def| def.capabilities.account_usernames) + && settings.account_display.show_usernames +} + +/// `showLastLoginForActiveTab` (lines 667-669). +pub fn show_last_login_for_active_tab(active_tab: &str, settings: &AppSettings) -> bool { + settings + .account_display + .show_last_login_per_platform + .get(active_tab) + .copied() + .unwrap_or(false) +} + +/// `lastLoginUnknownKey` (lines 670-672). +pub fn last_login_unknown_key(active_def: Option<&PlatformDef>) -> &'static str { + active_def + .and_then(|def| def.capabilities.last_login_unknown_key) + .unwrap_or("time.unknown") +} + +/// `activePlatformName` (line 685). +pub fn active_platform_name(active_def: Option<&PlatformDef>, active_tab: &str) -> String { + active_def + .map(|def| def.name.as_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(active_tab) + .to_string() +} + +/// The title bar's enabled states (lines 757-765). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TitleBarInput { + pub active_tab_usable: bool, + pub adapter_loading: bool, + pub adding: bool, + pub add_flow_running: bool, + pub personas_open: bool, + pub settings_open: bool, + pub bulk_edit_capability: bool, + pub update_cta_disabled: bool, + pub account_switching: bool, + pub persona_switching: bool, +} + +/// The title bar flags App.svelte computes in its markup. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TitleBarFlags { + pub can_refresh: bool, + pub can_add_account: bool, + pub show_bulk_edit: bool, + pub update_cta_disabled: bool, +} + +pub fn title_bar_flags(input: &TitleBarInput) -> TitleBarFlags { + TitleBarFlags { + can_refresh: input.active_tab_usable && !input.adapter_loading && !input.personas_open, + can_add_account: input.active_tab_usable + && !input.adapter_loading + && !input.adding + && !input.add_flow_running + && !input.personas_open, + show_bulk_edit: input.bulk_edit_capability + && !input.settings_open + && !input.personas_open + && input.active_tab_usable, + update_cta_disabled: input.update_cta_disabled + || input.account_switching + || input.persona_switching, + } +} + +/// `onTabChange` of the title bar (line 759): personas close first. +pub fn title_bar_tab_change(persona_switch: &mut PersonaSwitch, tab: &str) -> Vec { + persona_switch.show_personas = false; + vec![AppEffect::ChangeTab(tab.to_string())] +} + +/// `onOpenSettings` of the title bar (line 749). +pub fn title_bar_open_settings(persona_switch: &mut PersonaSwitch) -> Vec { + persona_switch.show_personas = false; + vec![AppEffect::ToggleSettingsPanel] +} + +/// The App.svelte state the palette and key closures act on. +pub struct ShellActions<'a> { + pub dialogs: &'a mut AppDialogs, + pub persona_switch: &'a mut PersonaSwitch, + pub stores: &'a mut ClientStores, + pub active_tab: &'a str, + pub view_mode: ViewMode, + pub personas_enabled: bool, + pub settings_open: bool, +} + +impl ShellActions<'_> { + fn toggle_view_mode(&mut self) -> Vec { + view_mode_change(self.stores, toggled_view_mode(self.view_mode)) + } + + fn open_personas(&mut self) -> Vec { + self.persona_switch + .open_personas(self.personas_enabled, self.settings_open) + } + + /// A palette command's `run` (lines 534-557). + pub fn run_palette_action(&mut self, action: &PaletteAction) -> Vec { + match action { + PaletteAction::SwitchToAccount(account) => vec![ + AppEffect::AddFlowCancelIfConflicting { + platform_id: self.active_tab.to_string(), + account_id: Some(account.id.clone()), + }, + AppEffect::SwitchAccount(account.clone()), + ], + PaletteAction::AddAccount => vec![AppEffect::AddAccount], + PaletteAction::RefreshAccounts => vec![AppEffect::RefreshAccounts], + PaletteAction::NewFolder => { + self.dialogs.open_new_folder_dialog(); + Vec::new() + } + PaletteAction::OpenFolder(folder_id) => navigate_to_folder(Some(folder_id.clone())), + PaletteAction::NavigateToParent => navigate_back(self.active_tab), + PaletteAction::ChangeTab(tab) => title_bar_tab_change(self.persona_switch, tab), + PaletteAction::ToggleSettings => title_bar_open_settings(self.persona_switch), + PaletteAction::OpenPersonas => self.open_personas(), + PaletteAction::ToggleBulkEdit => vec![AppEffect::ToggleBulkEdit], + PaletteAction::ToggleViewMode => self.toggle_view_mode(), + PaletteAction::ZoomReset => vec![AppEffect::ResetZoom], + PaletteAction::ApplyUpdate => vec![AppEffect::ApplyUpdate], + } + } + + /// A key binding's action (lines 578-631). `None` for what the egui + /// shell owns itself: the palette flag, the search field, its query and + /// the card focus. + pub fn run_key_action(&mut self, action: &KeyAction) -> Option> { + let effects = match action { + KeyAction::CloseContextMenu => { + self.dialogs.close_context_menu(); + Vec::new() + } + KeyAction::CloseInputDialog => { + self.dialogs.close_input_dialog(); + Vec::new() + } + KeyAction::CloseConfirmDialog => self.dialogs.close_confirm_dialog(), + KeyAction::ClosePersonas => { + self.persona_switch.show_personas = false; + Vec::new() + } + KeyAction::AddAccount => vec![AppEffect::AddAccount], + KeyAction::NewFolder => { + self.dialogs.open_new_folder_dialog(); + Vec::new() + } + KeyAction::Refresh => vec![AppEffect::RefreshAccounts], + KeyAction::ToggleBulkEdit => vec![AppEffect::ToggleBulkEdit], + KeyAction::ToggleSettingsPanel => vec![AppEffect::ToggleSettingsPanel], + KeyAction::OpenPersonas => self.open_personas(), + KeyAction::ToggleViewMode => self.toggle_view_mode(), + KeyAction::ChangeTab(tab) => vec![AppEffect::ChangeTab(tab.clone())], + KeyAction::ZoomIn => vec![AppEffect::ZoomIn], + KeyAction::ZoomOut => vec![AppEffect::ZoomOut], + KeyAction::ResetZoom => vec![AppEffect::ResetZoom], + KeyAction::NavigateBack => navigate_back(self.active_tab), + KeyAction::ToggleBulkEditAccount(id) => { + vec![AppEffect::BulkEditToggleAccount(id.clone())] + } + KeyAction::BulkEditSelectAll => vec![AppEffect::BulkEditSelectAll], + KeyAction::BulkEditDeselectAll => vec![AppEffect::BulkEditDeselectAll], + KeyAction::SetPaletteOpen(_) + | KeyAction::ClearSearchQuery + | KeyAction::BlurSearch + | KeyAction::ClearCardFocus + | KeyAction::FocusSearch + | KeyAction::FocusSettingsSearch => return None, + }; + Some(effects) + } +} diff --git a/crates/accshift-app/src/actions/toasts.rs b/crates/accshift-app/src/actions/toasts.rs new file mode 100644 index 00000000..30ada296 --- /dev/null +++ b/crates/accshift-app/src/actions/toasts.rs @@ -0,0 +1,257 @@ +//! The toast queue, ported from `src/lib/features/notifications/store.svelte.ts` +//! plus the countdown of `src/lib/features/notifications/Toast.svelte`. +//! +//! TS keeps the countdown in each toast component (`setTimeout`, paused while +//! hovered, restarted when the store bumps `resetKey`). Here the queue owns +//! it as a deadline per toast: the UI reports hovers, calls [`ToastQueue::tick`] +//! each frame and repaints at [`ToastQueue::next_deadline`]. + +use crate::accounts::{Toast, ToastKind}; +use crate::text::Text; +use std::time::{Duration, Instant}; + +/// `MAX_TOASTS`. +pub const MAX_TOASTS: usize = 5; +/// `DEFAULT_DURATION_MS`. +pub const DEFAULT_DURATION_MS: u64 = 3000; + +/// `ToastType`. TS has no "default" type: no `type` means `info`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ToastType { + #[default] + Info, + Success, + Error, +} + +impl From for ToastType { + fn from(kind: ToastKind) -> Self { + match kind { + ToastKind::Default | ToastKind::Info => ToastType::Info, + ToastKind::Success => ToastType::Success, + ToastKind::Error => ToastType::Error, + } + } +} + +/// `AddToastOptions.durationMs`: left out, a number, or `null`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DurationRequest { + /// `undefined`: the default timer. + #[default] + Default, + /// An explicit duration. + Ms(u64), + /// `null`: until dismissed. + Sticky, +} + +/// `ToastAction`: a button on the toast. The TS closure becomes an id the +/// app maps to what the button does. +#[derive(Debug, Clone, PartialEq)] +pub struct ToastAction { + pub label: Text, + pub id: String, +} + +/// `AddToastOptions`. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AddToastOptions { + pub kind: ToastType, + pub duration: DurationRequest, + pub action: Option, +} + +impl AddToastOptions { + pub fn kind(kind: ToastType) -> Self { + Self { + kind, + ..Self::default() + } + } +} + +/// `resolveToastDuration`: how long a toast stays, `None` meaning until the +/// user dismisses it. An error never counts down, whatever was asked. +pub fn resolve_toast_duration(kind: ToastType, requested: DurationRequest) -> Option { + if kind == ToastType::Error { + return None; + } + match requested { + DurationRequest::Default => Some(DEFAULT_DURATION_MS), + DurationRequest::Ms(ms) => Some(ms), + DurationRequest::Sticky => None, + } +} + +/// The countdown state `Toast.svelte` keeps in its closure. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct Countdown { + remaining_ms: u64, + started_at: Option, + hovered: bool, +} + +/// `ToastMessage`. +#[derive(Debug, Clone, PartialEq)] +pub struct ToastEntry { + pub id: u64, + pub text: Text, + pub duration_ms: Option, + pub kind: ToastType, + pub action: Option, + /// Bumped when the same text is raised again; the countdown restarts. + pub reset_key: u32, + countdown: Countdown, +} + +impl ToastEntry { + /// `hasDuration`: a positive duration. `0` never counts down, as in TS. + fn has_duration(&self) -> bool { + self.duration_ms.is_some_and(|ms| ms > 0) + } + + /// The `$effect` on `resetKey` and `durationMs`: clear, then start again + /// unless hovered. + fn restart(&mut self, now: Instant) { + self.countdown.started_at = None; + if !self.has_duration() { + return; + } + self.countdown.remaining_ms = self.duration_ms.unwrap_or(0); + if !self.countdown.hovered { + self.countdown.started_at = Some(now); + } + } + + /// When the running countdown ends, `None` while paused or untimed. + pub fn deadline(&self) -> Option { + self.countdown + .started_at + .map(|start| start + Duration::from_millis(self.countdown.remaining_ms)) + } + + pub fn hovered(&self) -> bool { + self.countdown.hovered + } +} + +/// The toast store. +#[derive(Debug, Clone, Default)] +pub struct ToastQueue { + toasts: Vec, + next_id: u64, +} + +impl ToastQueue { + pub fn new() -> Self { + Self::default() + } + + /// `getToasts`, oldest first. + pub fn toasts(&self) -> &[ToastEntry] { + &self.toasts + } + + pub fn get(&self, id: u64) -> Option<&ToastEntry> { + self.toasts.iter().find(|toast| toast.id == id) + } + + /// `addToast`. The same text already on screen takes the new type, + /// duration and action (an absent action keeps the old one) and restarts + /// its countdown instead of stacking a duplicate. Beyond + /// [`MAX_TOASTS`] the oldest go. + pub fn add(&mut self, text: Text, options: AddToastOptions, now: Instant) -> u64 { + let duration_ms = resolve_toast_duration(options.kind, options.duration); + if let Some(existing) = self.toasts.iter_mut().find(|toast| toast.text == text) { + existing.kind = options.kind; + existing.duration_ms = duration_ms; + if options.action.is_some() { + existing.action = options.action; + } + existing.reset_key += 1; + existing.restart(now); + return existing.id; + } + self.next_id += 1; + let mut entry = ToastEntry { + id: self.next_id, + text, + duration_ms, + kind: options.kind, + action: options.action, + reset_key: 0, + countdown: Countdown::default(), + }; + entry.restart(now); + self.toasts.push(entry); + if self.toasts.len() > MAX_TOASTS { + let excess = self.toasts.len() - MAX_TOASTS; + self.toasts.drain(..excess); + } + self.next_id + } + + /// A toast the account or platform code asked for. + pub fn push(&mut self, toast: Toast, now: Instant) -> u64 { + self.add(toast.text, AddToastOptions::kind(toast.kind.into()), now) + } + + /// `removeToast`. + pub fn remove(&mut self, id: u64) { + self.toasts.retain(|toast| toast.id != id); + } + + /// `handleMouseEnter` / `handleMouseLeave`. Leaving with no time left + /// dismisses the toast; true when it did. + pub fn set_hovered(&mut self, id: u64, hovered: bool, now: Instant) -> bool { + let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id) else { + return false; + }; + if hovered { + toast.countdown.hovered = true; + if let Some(start) = toast.countdown.started_at.take() { + let elapsed = now.saturating_duration_since(start).as_millis() as u64; + toast.countdown.remaining_ms = toast.countdown.remaining_ms.saturating_sub(elapsed); + } + return false; + } + toast.countdown.hovered = false; + if !toast.has_duration() { + return false; + } + if toast.countdown.remaining_ms == 0 { + self.remove(id); + return true; + } + toast.countdown.started_at = Some(now); + false + } + + /// `handleAction`: the action id to run; the toast goes either way. + pub fn activate_action(&mut self, id: u64) -> Option { + let action = self + .get(id) + .and_then(|toast| toast.action.as_ref()) + .map(|action| action.id.clone()); + self.remove(id); + action + } + + /// Removes every toast whose countdown ended; their ids. + pub fn tick(&mut self, now: Instant) -> Vec { + let done: Vec = self + .toasts + .iter() + .filter(|toast| toast.deadline().is_some_and(|deadline| deadline <= now)) + .map(|toast| toast.id) + .collect(); + self.toasts.retain(|toast| !done.contains(&toast.id)); + done + } + + /// The next countdown end, for the repaint. + pub fn next_deadline(&self) -> Option { + self.toasts.iter().filter_map(ToastEntry::deadline).min() + } +} diff --git a/crates/accshift-app/src/backend.rs b/crates/accshift-app/src/backend.rs new file mode 100644 index 00000000..4aea8106 --- /dev/null +++ b/crates/accshift-app/src/backend.rs @@ -0,0 +1,298 @@ +//! The `Backend` trait: one blocking method per Tauri command the frontend +//! calls, minus the window controls the egui shell does itself. +//! +//! Method names are the command names. Arguments are the Rust parameters of +//! the command in `src-tauri/src/commands/*.rs` (the webview sends them in +//! camelCase, Tauri renames them). Return values serialize to the JSON the +//! webview receives. Every method blocks: call it from [`crate::jobs`], never +//! from the UI thread. + +use crate::error::BackendResult; +use crate::types::*; +use serde_json::Value; +use std::collections::BTreeMap; + +/// Every command of [`Backend`], in trait order. The window commands +/// (`minimize_window`, `toggle_maximize_window`, `close_window`, +/// `set_maximize_button_rect`, `set_keep_backdrop_active`) are not in it, +/// nor is `diagnostics`, which no frontend code calls. +pub const COMMANDS: &[&str] = &[ + "get_boot_payload", + "get_runtime_os", + "detect_streaming_software", + "migrate_legacy_config", + "load_client_storage_snapshot", + "save_client_storage_store", + "get_storage_manifest", + "log_app_event", + "finish_boot", + "pin_unlock", + "pin_lock", + "platform_get_accounts", + "platform_get_startup_snapshot", + "platform_get_current_account", + "platform_switch_account", + "platform_forget_account", + "platform_begin_setup", + "platform_get_setup_status", + "platform_cancel_setup", + "platform_get_path", + "platform_set_path", + "platform_select_path", + "platform_detect_installed", + "platform_set_account_label", + "platform_dry_run", + "reload_user_platforms", + "descriptor_select_file", + "descriptor_preview_file", + "descriptor_install_file", + "descriptor_remove", + "open_descriptors_folder", + "open_url", + "open_logs_folder", + "get_desktop_wallpaper", + "steam_set_api_key", + "steam_has_api_key", + "steam_open_api_key_page", + "steam_switch_account_and_launch_game", + "steam_get_profile_info", + "steam_get_profile_infos", + "steam_get_player_bans", + "steam_copy_game_settings", + "steam_get_copyable_games", + "steam_open_userdata", + "steam_clear_browser_cache", + "steam_bulk_edit", + "steam_get_account_games", + "cs2_bridge_get_settings", + "cs2_bridge_set_settings", + "cs2_bridge_fetch", + "cs2_bridge_check", + "cs2_bridge_test", + "riot_capture_profile", + "roblox_add_account_by_cookie", + "roblox_get_profile_info", + "roblox_check_sessions", + "list_custom_themes", + "save_custom_theme", + "delete_custom_theme", + "telemetry_get_state", + "telemetry_set_mode_a", + "telemetry_set_mode_b", + "telemetry_retry_forget", + "telemetry_complete_onboarding", + "telemetry_track_persona_switch", + "telemetry_track_account_added", + "telemetry_track_account_add_started", + "telemetry_track_account_add_cancelled", + "telemetry_track_operation_failed", + "telemetry_track_update", + "telemetry_track_settings_snapshot", + "telemetry_track_streamer_mode", + "telemetry_export", +]; + +/// The logic layer the UI talks to. `Send + Sync` so one instance serves the +/// whole job pool. +pub trait Backend: Send + Sync { + // ---------------------------------------------------------------- app + + /// Legacy migration, client storage snapshot, custom themes, runtime OS and + /// the user descriptor folder, in one call. + fn get_boot_payload(&self) -> BackendResult; + /// `windows`, `macos` or `linux`. + fn get_runtime_os(&self) -> BackendResult; + /// True when OBS, Streamlabs, XSplit and the like are running. + fn detect_streaming_software(&self) -> BackendResult; + /// `migrated`, `none` or `error:`. + fn migrate_legacy_config(&self) -> BackendResult; + fn load_client_storage_snapshot(&self) -> BackendResult; + /// The store's new fingerprint. `None` from the mock, which saves nothing. + fn save_client_storage_store( + &self, + store_id: &str, + value: &Value, + ) -> BackendResult>; + fn get_storage_manifest(&self) -> BackendResult; + fn log_app_event( + &self, + level: &str, + source: &str, + message: &str, + details: Option<&str>, + ) -> BackendResult<()>; + /// Boot milestones and first-run telemetry. The Tauri command also shows + /// the window; the egui shell does that part itself. + fn finish_boot(&self, source: &str, marks: Option<&Value>) -> BackendResult<()>; + /// Where downloaded avatars are kept between launches + /// ([`crate::accounts::avatars::AvatarDiskCache`]). `None` keeps them in + /// memory only: the mock, whose scenes must not depend on a past run. + fn avatar_cache_dir(&self) -> Option { + None + } + + /// Launch at system startup, minimized to the tray + /// ([`crate::lifecycle::autostart`]). Windows only; the mock and other + /// platforms do nothing. + fn set_autostart(&self, enabled: bool) -> BackendResult<()> { + let _ = enabled; + Ok(()) + } + + // ---------------------------------------------------------------- PIN + + fn pin_unlock(&self, code: &str) -> BackendResult; + fn pin_lock(&self) -> BackendResult<()>; + + // ---------------------------------------------------------------- platforms + + /// The platform's own account list (`SteamAccount[]`, Riot profiles, + /// `RobloxAccount[]`, descriptor accounts), as the command's `Value`. + fn platform_get_accounts(&self, platform_id: &str) -> BackendResult; + /// Accounts and current account in one call; the shape is the platform's. + fn platform_get_startup_snapshot(&self, platform_id: &str) -> BackendResult; + fn platform_get_current_account(&self, platform_id: &str) -> BackendResult; + /// Refused with the `pin_locked:` message while the PIN session is locked. + fn platform_switch_account( + &self, + platform_id: &str, + account_id: &str, + params: &Value, + ) -> BackendResult<()>; + fn platform_forget_account(&self, platform_id: &str, account_id: &str) -> BackendResult<()>; + fn platform_begin_setup(&self, platform_id: &str, params: &Value) + -> BackendResult; + fn platform_get_setup_status( + &self, + platform_id: &str, + setup_id: &str, + ) -> BackendResult; + fn platform_cancel_setup(&self, platform_id: &str, setup_id: &str) -> BackendResult<()>; + fn platform_get_path(&self, platform_id: &str) -> BackendResult; + fn platform_set_path(&self, platform_id: &str, path: &str) -> BackendResult<()>; + /// Opens a folder picker. Cancelling is an error. + fn platform_select_path(&self, platform_id: &str) -> BackendResult; + fn platform_detect_installed(&self) -> BackendResult>; + fn platform_set_account_label( + &self, + platform_id: &str, + account_id: &str, + label: &str, + ) -> BackendResult<()>; + fn platform_dry_run(&self, platform_id: &str, account_id: &str) -> BackendResult; + + // ---------------------------------------------------------------- descriptors + + fn reload_user_platforms(&self) -> BackendResult; + /// Opens a file picker. Cancelling is an error. + fn descriptor_select_file(&self) -> BackendResult; + fn descriptor_preview_file(&self, path: &str) -> BackendResult; + fn descriptor_install_file(&self, path: &str) -> BackendResult; + fn descriptor_remove(&self, platform_id: &str) -> BackendResult; + fn open_descriptors_folder(&self) -> BackendResult<()>; + + // ---------------------------------------------------------------- utility + + fn open_url(&self, url: &str) -> BackendResult<()>; + fn open_logs_folder(&self) -> BackendResult<()>; + /// `None` when there is nothing to capture (and always from the mock). + fn get_desktop_wallpaper(&self) -> BackendResult>; + + // ---------------------------------------------------------------- Steam + + fn steam_set_api_key(&self, key: &str) -> BackendResult<()>; + fn steam_has_api_key(&self) -> BackendResult; + fn steam_open_api_key_page(&self) -> BackendResult<()>; + fn steam_switch_account_and_launch_game( + &self, + username: &str, + app_id: &str, + run_as_admin: bool, + launch_options: &str, + shutdown_mode: &str, + ) -> BackendResult<()>; + fn steam_get_profile_info(&self, steam_id: &str) -> BackendResult>; + /// Ids without a result are absent from the map. + fn steam_get_profile_infos( + &self, + steam_ids: &[String], + ) -> BackendResult>; + fn steam_get_player_bans(&self, steam_ids: &[String]) -> BackendResult>; + fn steam_copy_game_settings( + &self, + from_steam_id: &str, + to_steam_id: &str, + app_id: &str, + ) -> BackendResult<()>; + fn steam_get_copyable_games( + &self, + from_steam_id: &str, + to_steam_id: &str, + ) -> BackendResult>; + fn steam_open_userdata(&self, steam_id: &str) -> BackendResult<()>; + fn steam_clear_browser_cache(&self) -> BackendResult<()>; + fn steam_bulk_edit(&self, request: BulkEditRequest) -> BackendResult; + fn steam_get_account_games(&self, steam_id: &str) -> BackendResult>; + + // ---------------------------------------------------------------- CS2 bridge + + fn cs2_bridge_get_settings(&self) -> BackendResult; + /// `token` `None` keeps the stored token, an empty one clears it. + fn cs2_bridge_set_settings( + &self, + enabled: bool, + url: &str, + token: Option<&str>, + ) -> BackendResult<()>; + fn cs2_bridge_fetch(&self) -> BackendResult>; + /// `None` when the bridge is off. + fn cs2_bridge_check(&self, steam_id: &str) -> BackendResult>; + fn cs2_bridge_test(&self) -> BackendResult; + + // ---------------------------------------------------------------- Riot, Roblox + + fn riot_capture_profile(&self, profile_id: &str) -> BackendResult<()>; + fn roblox_add_account_by_cookie(&self, cookie: &str) -> BackendResult; + fn roblox_get_profile_info(&self, user_id: &str) -> BackendResult; + /// User ids whose stored session is dead. + fn roblox_check_sessions(&self) -> BackendResult>; + + // ---------------------------------------------------------------- themes + + fn list_custom_themes(&self) -> BackendResult>; + fn save_custom_theme(&self, theme: &CustomTheme) -> BackendResult<()>; + fn delete_custom_theme(&self, theme_id: &str) -> BackendResult<()>; + + // ---------------------------------------------------------------- telemetry + + fn telemetry_get_state(&self) -> BackendResult; + fn telemetry_set_mode_a(&self, enabled: bool) -> BackendResult<()>; + fn telemetry_set_mode_b(&self, enabled: bool) -> BackendResult<()>; + fn telemetry_retry_forget(&self) -> BackendResult<()>; + fn telemetry_complete_onboarding( + &self, + mode_a_enabled: bool, + mode_b_enabled: bool, + ) -> BackendResult<()>; + fn telemetry_track_persona_switch(&self, platforms: u64, succeeded: u64) -> BackendResult<()>; + fn telemetry_track_account_added(&self, platform_id: &str) -> BackendResult<()>; + fn telemetry_track_account_add_started(&self, platform_id: &str) -> BackendResult<()>; + fn telemetry_track_account_add_cancelled(&self, platform_id: &str) -> BackendResult<()>; + fn telemetry_track_operation_failed( + &self, + operation: &str, + error_code: &str, + platform_id: Option<&str>, + ) -> BackendResult<()>; + /// `stage` is `available`, `downloaded`, `applied` or `failed`. + fn telemetry_track_update( + &self, + stage: &str, + target_version: Option<&str>, + error_code: Option<&str>, + ) -> BackendResult<()>; + fn telemetry_track_settings_snapshot(&self, snapshot: &SettingsSnapshot) -> BackendResult<()>; + fn telemetry_track_streamer_mode(&self) -> BackendResult<()>; + /// What the telemetry backend holds for this install (Mode B). + fn telemetry_export(&self) -> BackendResult; +} diff --git a/crates/accshift-app/src/bulk_edit.rs b/crates/accshift-app/src/bulk_edit.rs new file mode 100644 index 00000000..d144e882 --- /dev/null +++ b/crates/accshift-app/src/bulk_edit.rs @@ -0,0 +1,568 @@ +//! Bulk edit: the multi-select mode and the Steam bulk edit bar, ported from +//! `src/lib/app/useBulkEdit.svelte.ts` and the non-drawing parts of +//! `src/lib/platforms/steam/BulkEditBar.svelte` (selection rules, request +//! building, progress and result states). +//! +//! The TS bar is a lazily loaded component chunk; here it is a plain model +//! created when the mode opens, so the load race of `toggleBulkEdit` (the +//! pending open token) has nothing left to guard. The paint gesture reads +//! the card under the pointer from the UI instead of `elementFromPoint`. +//! +//! The toasts for the outcome (`handleBulkEditResult`), the clipboard write +//! of the URLs and the card color write belong to the dialog and card-action +//! modules: [`BulkEdit::poll`] hands the result over, [`BulkEditBar::copy_urls`] +//! and [`BulkEditBar::apply_color`] the values. + +use crate::error::BackendResult; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::platforms::steam::{api as steam_api, to_profile_url}; +use crate::platforms::PlatformDef; +use crate::text::Text; +use crate::types::{ + BulkEditFailure, BulkEditRequest, BulkEditResult, CopyableGame, LaunchOptionEdit, +}; + +/// `PAINT_THRESHOLD` (useBulkEdit line 34): manhattan pixels before a press +/// becomes a paint drag. +pub const PAINT_THRESHOLD: f32 = 5.0; + +/// `isBulkEditToggleAllowed` (App.svelte lines 571-577): the keyboard and +/// palette toggle. +pub fn toggle_allowed( + def: Option<&PlatformDef>, + active_tab_usable: bool, + settings_open: bool, +) -> bool { + def.is_some_and(|def| def.capabilities.bulk_edit) && active_tab_usable && !settings_open +} + +/// `showBulkEdit` of the titlebar (App.svelte line 765). +pub fn titlebar_button_visible( + def: Option<&PlatformDef>, + active_tab_usable: bool, + settings_open: bool, + personas_open: bool, +) -> bool { + toggle_allowed(def, active_tab_usable, settings_open) && !personas_open +} + +/// The paint gesture state (lines 30-41). +#[derive(Debug, Clone, Default)] +struct Paint { + active: bool, + /// Select (true) or deselect, locked by the start card for the gesture. + desired: bool, + moved: bool, + start: (f32, f32), + start_id: Option, +} + +/// `createBulkEditController`. +#[derive(Debug, Default)] +pub struct BulkEdit { + /// The platform whose bar is open; `Some` is `bulkEditMode`. + platform_id: Option, + /// `bulkEditSelectedIds`, in insertion order like a JS `Set`. + selected: Vec, + bar: Option, + paint: Paint, + eat_next_click: bool, +} + +impl BulkEdit { + pub fn new() -> Self { + Self::default() + } + + /// `bulkEditMode`. + pub fn is_open(&self) -> bool { + self.platform_id.is_some() + } + + /// The platform the open bar belongs to. + pub fn platform_id(&self) -> Option<&str> { + self.platform_id.as_deref() + } + + /// `bulkEditSelectedIds`. + pub fn selected_ids(&self) -> &[String] { + &self.selected + } + + pub fn is_selected(&self, account_id: &str) -> bool { + self.selected.iter().any(|id| id == account_id) + } + + /// The bar model while the mode is open. + pub fn bar(&self) -> Option<&BulkEditBar> { + self.bar.as_ref() + } + + pub fn bar_mut(&mut self) -> Option<&mut BulkEditBar> { + self.bar.as_mut() + } + + /// `bulkEditActiveAccountSelected` (lines 57-61). + pub fn active_account_selected(&self, current_account_id: Option<&str>) -> bool { + current_account_id.is_some_and(|id| !id.is_empty() && self.is_selected(id)) + } + + fn set_selected(&mut self, account_id: &str, selected: bool) { + if selected == self.is_selected(account_id) { + return; + } + if selected { + self.selected.push(account_id.to_string()); + } else { + self.selected.retain(|id| id != account_id); + } + } + + /// `toggleBulkEdit` (lines 63-94): closes an open mode, or opens the bar + /// of the active platform when it declares the capability. + pub fn toggle(&mut self, active_platform: Option<&PlatformDef>) { + if self.is_open() { + self.close(); + return; + } + let Some(def) = active_platform.filter(|def| def.capabilities.bulk_edit) else { + return; + }; + self.platform_id = Some(def.id.clone()); + self.bar = Some(BulkEditBar::new()); + } + + /// `toggleBulkEditAccount` (lines 96-101). + pub fn toggle_account(&mut self, account_id: &str) { + let selected = self.is_selected(account_id); + self.set_selected(account_id, !selected); + } + + /// `bulkEditSelectAll` (lines 103-110): additive, the selection spans the + /// whole platform. `visible_ids` is what the user can see (App.svelte + /// lines 151-154: never off-screen accounts). + pub fn select_all(&mut self, visible_ids: &[String]) { + for id in visible_ids { + self.set_selected(id, true); + } + } + + /// `bulkEditDeselectAll` (lines 112-114): the reset, across folders. + pub fn deselect_all(&mut self) { + self.selected.clear(); + } + + /// `closeBulkEdit` (lines 116-120): also on a tab change. + pub fn close(&mut self) { + self.platform_id = None; + self.bar = None; + self.selected.clear(); + self.paint = Paint::default(); + } + + // ------------------------------------------------------------ paint + + /// `handlePaintMouseDown` (lines 124-136): arms the gesture on a primary + /// press over a card. True when it armed, so the caller skips the + /// reorder drag and keeps the cards in place. + pub fn paint_mouse_down( + &mut self, + primary: bool, + card_id: Option<&str>, + pos: (f32, f32), + ) -> bool { + if !self.is_open() || !primary { + return false; + } + let Some(account_id) = card_id.filter(|id| !id.is_empty()) else { + return false; + }; + self.paint = Paint { + active: true, + desired: !self.is_selected(account_id), + moved: false, + start: pos, + start_id: Some(account_id.to_string()), + }; + true + } + + /// `handlePaintMouseMove` (lines 138-149). `card_id` is the card under + /// the pointer. + pub fn paint_mouse_move(&mut self, pos: (f32, f32), card_id: Option<&str>) { + if !self.paint.active { + return; + } + if !self.paint.moved { + let distance = (pos.0 - self.paint.start.0).abs() + (pos.1 - self.paint.start.1).abs(); + if distance < PAINT_THRESHOLD { + return; + } + self.paint.moved = true; + if let Some(start) = self.paint.start_id.clone() { + self.set_selected(&start, self.paint.desired); + } + } + if let Some(id) = card_id.filter(|id| !id.is_empty()) { + self.set_selected(id, self.paint.desired); + } + } + + /// `handlePaintMouseUp` (lines 151-166). After a real drag the click + /// that follows the release is swallowed for this frame. + pub fn paint_mouse_up(&mut self) { + if !self.paint.active { + return; + } + let moved = self.paint.moved; + self.paint = Paint::default(); + if moved { + self.eat_next_click = true; + } + } + + /// `handlePaintCaptureClick` (lines 168-174): true when the click must + /// not reach the card (no single-click toggle after a drag). + pub fn paint_capture_click(&mut self) -> bool { + std::mem::take(&mut self.eat_next_click) + } + + /// The `setTimeout(0)` that drops an unclaimed swallow: call at the end + /// of the frame that handled the release. + pub fn end_frame(&mut self) { + self.eat_next_click = false; + } + + pub fn is_painting(&self) -> bool { + self.paint.active + } + + // ------------------------------------------------------------ bar jobs + + /// The bar's "Next": [`BulkEditBar::go_to_settings`] with the selection. + pub fn go_to_settings(&mut self, jobs: &Jobs) { + let first = self.selected.first().cloned(); + if let Some(bar) = self.bar.as_mut() { + bar.go_to_settings(jobs, first); + } + } + + /// The bar's "Apply": [`BulkEditBar::apply`] with the selection. + pub fn apply(&mut self, jobs: &Jobs) { + let selected = self.selected.clone(); + if let Some(bar) = self.bar.as_mut() { + bar.apply(jobs, &selected); + } + } + + /// Escape (`handleOverlayKeydown`, bar lines 142-150): closes the color + /// menu, else steps back, else closes the mode. + pub fn escape(&mut self) { + if let Some(bar) = self.bar.as_mut() { + if bar.escape() { + self.close(); + } + } + } + + /// One frame: applies finished bar jobs. A finished apply closes the mode + /// (`onResult` then `onClose`) and hands the result over for the toast. + pub fn poll(&mut self) -> Option { + let result = self.bar.as_mut()?.poll()?; + self.close(); + Some(result) + } + + /// A games load or an apply is in flight. + pub fn is_busy(&self) -> bool { + self.bar.as_ref().is_some_and(BulkEditBar::is_busy) + } +} + +/// `TriState` of the bar (line 14). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TriState { + #[default] + Unchanged, + Enabled, + Disabled, +} + +impl TriState { + /// `cycleTriState` (lines 72-76). + pub fn cycle(self) -> Self { + match self { + TriState::Unchanged => TriState::Enabled, + TriState::Enabled => TriState::Disabled, + TriState::Disabled => TriState::Unchanged, + } + } + + /// `triStateLabel` (lines 78-82). + pub fn label(self) -> Text { + Text::key(match self { + TriState::Unchanged => "bulkEdit.unchanged", + TriState::Enabled => "common.enabled", + TriState::Disabled => "common.disabled", + }) + } + + /// `triStateToValue` (lines 84-88). + pub fn value(self) -> Option { + match self { + TriState::Unchanged => None, + TriState::Enabled => Some(true), + TriState::Disabled => Some(false), + } + } + + /// `class:active` on the button. + pub fn is_changed(self) -> bool { + self != TriState::Unchanged + } +} + +/// The bar's two steps (line 62). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BarStep { + /// The bottom bar with the selection tools. + #[default] + Select, + /// The settings dialog. + Settings, +} + +/// One launch option row (`LaunchOptionEdit` of steamApi.ts). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchOptionDraft { + pub app_id: String, + pub value: String, +} + +/// The state of `BulkEditBar.svelte`. +#[derive(Debug, Default)] +pub struct BulkEditBar { + step: BarStep, + color_menu_open: bool, + pub news_popup: TriState, + pub do_not_disturb: TriState, + launch_option_edits: Vec, + games: Vec, + games_pending: Option>>>, + /// `bind:value` of the game select; empty is "Select a game". + pub selected_game_id: String, + /// `bind:value` of the launch option field. + pub launch_option_input: String, + apply_pending: Option>>, +} + +impl BulkEditBar { + pub fn new() -> Self { + Self::default() + } + + pub fn step(&self) -> BarStep { + self.step + } + + pub fn color_menu_open(&self) -> bool { + self.color_menu_open + } + + /// `gamesLoading`. + pub fn games_loading(&self) -> bool { + self.games_pending.is_some() + } + + pub fn games(&self) -> &[CopyableGame] { + &self.games + } + + pub fn launch_option_edits(&self) -> &[LaunchOptionDraft] { + &self.launch_option_edits + } + + /// `applying`. + pub fn applying(&self) -> bool { + self.apply_pending.is_some() + } + + pub fn is_busy(&self) -> bool { + self.games_loading() || self.applying() + } + + /// `bulkEdit.selected { count }`, in the bar and the dialog header. + pub fn selected_label(count: usize) -> Text { + Text::with("bulkEdit.selected", [("count", count as u64)]) + } + + /// Copy URLs, color and Next are off without a selection (lines 160, + /// 167, 196). + pub fn selection_actions_enabled(selected_count: usize) -> bool { + selected_count > 0 + } + + /// The apply button (line 287). + pub fn apply_enabled(&self, selected_count: usize) -> bool { + !self.applying() && selected_count > 0 + } + + /// The apply button text (line 288): `...` while applying. + pub fn apply_label(&self) -> Text { + if self.applying() { + Text::raw("...") + } else { + Text::key("bulkEdit.apply") + } + } + + /// `copyUrls` (lines 38-40): the profile URLs to put on the clipboard. + pub fn copy_urls(selected_ids: &[String]) -> Vec { + selected_ids.iter().map(|id| to_profile_url(id)).collect() + } + + /// `toggleColorMenu` (lines 45-47). + pub fn toggle_color_menu(&mut self) { + self.color_menu_open = !self.color_menu_open; + } + + /// `applyColor` (lines 49-52): closes the menu and returns the color for + /// `onSetCardColor` (empty clears it). + pub fn apply_color(&mut self, color: &str) -> String { + self.color_menu_open = false; + color.to_string() + } + + /// `handleWindowPointerDown` (lines 56-60): a press outside the color + /// popover closes it, and the press still runs its own action. + pub fn pointer_down(&mut self, inside_color_menu: bool) { + if self.color_menu_open && !inside_color_menu { + self.color_menu_open = false; + } + } + + /// `gameName` (lines 90-92). + pub fn game_name<'a>(&'a self, app_id: &'a str) -> &'a str { + self.games + .iter() + .find(|game| game.app_id == app_id) + .map_or(app_id, |game| game.name.as_str()) + } + + /// `goToSettings` (lines 94-106): the games of the first selected + /// account fill the launch option picker, once. + pub fn go_to_settings(&mut self, jobs: &Jobs, first_selected: Option) { + self.step = BarStep::Settings; + if !self.games.is_empty() || self.games_pending.is_some() { + return; + } + if let Some(steam_id) = first_selected { + self.games_pending = + Some(jobs.run(move |backend| backend.steam_get_account_games(&steam_id))); + } + } + + /// Back to the selection (the overlay click and the Back button). + pub fn back(&mut self) { + self.step = BarStep::Select; + } + + /// The add button is enabled (line 256). + pub fn can_add_launch_option(&self) -> bool { + !self.selected_game_id.is_empty() && !crate::js::trim(&self.launch_option_input).is_empty() + } + + /// `addLaunchOption` (lines 108-116): one row per game, the last one wins. + pub fn add_launch_option(&mut self) { + if !self.can_add_launch_option() { + return; + } + let app_id = std::mem::take(&mut self.selected_game_id); + let value = crate::js::trim(&self.launch_option_input).to_string(); + self.launch_option_input.clear(); + self.launch_option_edits + .retain(|edit| edit.app_id != app_id); + self.launch_option_edits + .push(LaunchOptionDraft { app_id, value }); + } + + /// `removeLaunchOption` (lines 118-120). + pub fn remove_launch_option(&mut self, app_id: &str) { + self.launch_option_edits + .retain(|edit| edit.app_id != app_id); + } + + /// The request `applyEdits` sends (lines 126-131). + pub fn request(&self, selected_ids: &[String]) -> BulkEditRequest { + BulkEditRequest { + steam_ids: selected_ids.to_vec(), + news_popup: self.news_popup.value(), + do_not_disturb: self.do_not_disturb.value(), + launch_options: self + .launch_option_edits + .iter() + .map(|edit| LaunchOptionEdit { + app_id: edit.app_id.clone(), + value: edit.value.clone(), + }) + .collect(), + } + } + + /// `applyEdits` (lines 122-140), up to the backend call (logged by + /// `steamApi.bulkEdit`). + pub fn apply(&mut self, jobs: &Jobs, selected_ids: &[String]) { + if selected_ids.is_empty() || self.apply_pending.is_some() { + return; + } + let request = self.request(selected_ids); + self.apply_pending = Some(jobs.run(move |backend| steam_api::bulk_edit(backend, request))); + } + + /// `handleOverlayKeydown` (lines 142-150). True when the mode closes. + pub fn escape(&mut self) -> bool { + if self.color_menu_open { + self.color_menu_open = false; + return false; + } + if self.step == BarStep::Settings { + self.step = BarStep::Select; + return false; + } + true + } + + /// Applies finished jobs. `Some` once the apply ended: its result, or the + /// error as one `all` failure (line 135). + pub fn poll(&mut self) -> Option { + if let Some(pending) = self.games_pending.as_mut() { + match pending.poll() { + Poll::Waiting => {} + Poll::Ready(result) => { + self.games_pending = None; + self.games = result.unwrap_or_default(); + } + Poll::Gone => self.games_pending = None, + } + } + let pending = self.apply_pending.as_mut()?; + let result = match pending.poll() { + Poll::Waiting => return None, + Poll::Ready(Ok(result)) => result, + Poll::Ready(Err(error)) => failed_everywhere(error.message), + Poll::Gone => failed_everywhere("Bulk edit stopped".to_string()), + }; + self.apply_pending = None; + Some(result) + } +} + +fn failed_everywhere(error: String) -> BulkEditResult { + BulkEditResult { + succeeded: 0, + failed: vec![BulkEditFailure { + steam_id: "all".to_string(), + error, + }], + } +} diff --git a/crates/accshift-app/src/cs2_bridge.rs b/crates/accshift-app/src/cs2_bridge.rs new file mode 100644 index 00000000..66374907 --- /dev/null +++ b/crates/accshift-app/src/cs2_bridge.rs @@ -0,0 +1,208 @@ +//! The operation queue of `src/lib/platforms/steam/cs2Bridge.svelte.ts` +//! (`enqueueBridgeOperation`, `loadCs2BridgeData`'s in-flight dedup and +//! `triggerCs2BridgeCheck`) and the App.svelte effect that loads the bridge +//! when the Steam tab shows (lines 312-315). +//! +//! The cache itself is M1's [`Cs2Bridge`], in the Steam adapter's state; +//! this queue only orders the backend calls on [`Jobs`] and applies their +//! answers to that cache on the UI thread. + +use crate::backend::Backend; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::platforms::api::log_app_event; +use crate::platforms::steam::cs2::{Cs2Bridge, Cs2LoadPlan, Cs2LoadResult}; +use crate::platforms::PlatformAdapter; +use crate::types::Cs2BridgeAccount; +use serde_json::json; +use std::collections::VecDeque; +use std::sync::Arc; + +/// One queued bridge call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Cs2Operation { + /// `loadCs2BridgeData`. The TTL was checked when it was queued. + Load, + /// `triggerCs2BridgeCheck(steamId)`. + Check(String), +} + +/// What a check got. +#[derive(Debug, Clone)] +pub struct Cs2CheckResult { + /// The settings were read: the enabled flag. + pub enabled: Option, + pub account: Option, +} + +enum Running { + Load(Pending), + Check(Pending), +} + +/// The bridge exposes one shared server-side check queue. Every GET and POST +/// runs one at a time, in order, so a fetch never replaces a freshly checked +/// row and rapid switches never overlap checks. +#[derive(Default)] +pub struct Cs2BridgeQueue { + queue: VecDeque, + running: Option, + /// `inFlight`: a load is queued or running. + load_in_flight: bool, +} + +impl Cs2BridgeQueue { + pub fn new() -> Self { + Self::default() + } + + /// A call is queued or running. + pub fn is_busy(&self) -> bool { + self.running.is_some() || !self.queue.is_empty() + } + + /// `loadCs2BridgeData(force)` (lines 47-87): queues a load unless one is + /// already in flight or the cache is fresh. True when queued. + pub fn load(&mut self, cache: &Cs2Bridge, force: bool, now_ms: f64) -> bool { + if self.load_in_flight { + return false; + } + if cache.begin_load(force, now_ms).is_none() { + return false; + } + self.load_in_flight = true; + self.queue.push_back(Cs2Operation::Load); + true + } + + /// `triggerCs2BridgeCheck(steamId)` (lines 89-115): App.svelte reacts to + /// `LoadingEvent::Cs2Check` with this. + pub fn check(&mut self, steam_id: impl Into) { + self.queue.push_back(Cs2Operation::Check(steam_id.into())); + } + + /// App.svelte lines 312-315: the bridge loads when the Steam tab shows + /// accounts. True when a load was queued. + pub fn ensure_loaded( + &mut self, + cache: &Cs2Bridge, + active_tab: &str, + account_count: usize, + now_ms: f64, + ) -> bool { + if active_tab != "steam" || account_count == 0 { + return false; + } + self.load(cache, false, now_ms) + } + + /// One frame: applies the finished call to the Steam adapter's cache, + /// then starts the next queued one. `steam` is the Steam adapter. + pub fn tick(&mut self, jobs: &Jobs, steam: &Arc, now_ms: f64) { + if let Some(running) = self.running.as_mut() { + let done = match running { + Running::Load(pending) => match pending.poll() { + Poll::Waiting => false, + Poll::Ready(result) => { + steam.state().cs2.finish_load(result, now_ms); + self.load_in_flight = false; + true + } + Poll::Gone => { + self.load_in_flight = false; + true + } + }, + Running::Check(pending) => match pending.poll() { + Poll::Waiting => false, + Poll::Ready(result) => { + let mut state = steam.state(); + if let Some(enabled) = result.enabled { + state.cs2.note_enabled(enabled); + } + if let Some(account) = result.account { + state.cs2.apply_check(account); + } + true + } + Poll::Gone => true, + }, + }; + if !done { + return; + } + self.running = None; + } + let Some(next) = self.queue.pop_front() else { + return; + }; + // The enabled flag is read when the call runs, as the TS closure + // reads `enabledKnown` inside the queued operation. + let plan = steam + .state() + .cs2 + .begin_load(true, now_ms) + .unwrap_or(Cs2LoadPlan { + read_settings: true, + enabled: false, + }); + self.running = Some(match next { + Cs2Operation::Load => { + Running::Load(jobs.run(move |backend| Cs2Bridge::fetch(plan, backend))) + } + Cs2Operation::Check(steam_id) => { + Running::Check(jobs.run(move |backend| check(plan, backend, &steam_id))) + } + }); + } +} + +/// The backend half of `triggerCs2BridgeCheck`. Silent: on failure the +/// periodic read stays the fallback. +pub fn check(plan: Cs2LoadPlan, backend: &dyn Backend, steam_id: &str) -> Cs2CheckResult { + let mut result = Cs2CheckResult { + enabled: None, + account: None, + }; + let enabled = if plan.read_settings { + match backend.cs2_bridge_get_settings() { + Ok(settings) => { + result.enabled = Some(settings.enabled); + settings.enabled + } + Err(error) => { + warn(backend, &error.message); + return result; + } + } + } else { + plan.enabled + }; + if !enabled { + return result; + } + match backend.cs2_bridge_check(steam_id) { + Ok(Some(account)) => { + log_app_event( + backend, + "info", + "frontend.cs2_bridge", + "Bridge check applied", + Some(&json!({ "steamId": account.steam_id, "level": account.level })), + ); + result.account = Some(account); + } + Ok(None) => {} + Err(error) => warn(backend, &error.message), + } + result +} + +fn warn(backend: &dyn Backend, error: &str) { + log_app_event( + backend, + "warn", + "frontend.cs2_bridge", + "Bridge check failed", + Some(&json!({ "error": error })), + ); +} diff --git a/crates/accshift-app/src/dispatch.rs b/crates/accshift-app/src/dispatch.rs new file mode 100644 index 00000000..f551db50 --- /dev/null +++ b/crates/accshift-app/src/dispatch.rs @@ -0,0 +1,251 @@ +//! Dynamic entry into a [`Backend`]: a command name and its arguments as the +//! webview sends them (camelCase JSON), the result as the JSON it receives. +//! +//! The UI calls the typed methods. This exists for the places that only have +//! a name and a JSON object: the mock's parity tests, a scene harness, a debug +//! bridge. +//! +//! Arguments are read leniently, like the TS mock handlers read theirs: a +//! missing string is empty, a missing flag false, a missing number zero. The +//! real Tauri command would reject the call instead. + +use crate::backend::Backend; +use crate::error::{BackendError, BackendResult}; +use crate::types::{BulkEditRequest, CustomTheme, SettingsSnapshot}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::Value; + +/// Runs `command` with `args` on `backend`. `None` for a name that is not a +/// command of [`Backend`]. +pub fn dispatch( + backend: &dyn Backend, + command: &str, + args: &Value, +) -> Option> { + let a = Args(args); + let b = backend; + Some(match command { + "get_boot_payload" => json(b.get_boot_payload()), + "get_runtime_os" => json(b.get_runtime_os()), + "detect_streaming_software" => json(b.detect_streaming_software()), + "migrate_legacy_config" => json(b.migrate_legacy_config()), + "load_client_storage_snapshot" => json(b.load_client_storage_snapshot()), + "save_client_storage_store" => { + json(b.save_client_storage_store(&a.str("storeId"), &a.value("value"))) + } + "get_storage_manifest" => json(b.get_storage_manifest()), + "log_app_event" => json(b.log_app_event( + &a.str("level"), + &a.str("source"), + &a.str("message"), + a.opt_str("details").as_deref(), + )), + "finish_boot" => { + let marks = a.value("marks"); + json(b.finish_boot(&a.str("source"), (!marks.is_null()).then_some(&marks))) + } + "pin_unlock" => json(b.pin_unlock(&a.str("code"))), + "pin_lock" => json(b.pin_lock()), + "platform_get_accounts" => json(b.platform_get_accounts(&a.str("platformId"))), + "platform_get_startup_snapshot" => { + json(b.platform_get_startup_snapshot(&a.str("platformId"))) + } + "platform_get_current_account" => { + json(b.platform_get_current_account(&a.str("platformId"))) + } + "platform_switch_account" => json(b.platform_switch_account( + &a.str("platformId"), + &a.str("accountId"), + &a.value("params"), + )), + "platform_forget_account" => { + json(b.platform_forget_account(&a.str("platformId"), &a.str("accountId"))) + } + "platform_begin_setup" => { + json(b.platform_begin_setup(&a.str("platformId"), &a.value("params"))) + } + "platform_get_setup_status" => { + json(b.platform_get_setup_status(&a.str("platformId"), &a.str("setupId"))) + } + "platform_cancel_setup" => { + json(b.platform_cancel_setup(&a.str("platformId"), &a.str("setupId"))) + } + "platform_get_path" => json(b.platform_get_path(&a.str("platformId"))), + "platform_set_path" => json(b.platform_set_path(&a.str("platformId"), &a.str("path"))), + "platform_select_path" => json(b.platform_select_path(&a.str("platformId"))), + "platform_detect_installed" => json(b.platform_detect_installed()), + "platform_set_account_label" => json(b.platform_set_account_label( + &a.str("platformId"), + &a.str("accountId"), + &a.str("label"), + )), + "platform_dry_run" => json(b.platform_dry_run(&a.str("platformId"), &a.str("accountId"))), + "reload_user_platforms" => json(b.reload_user_platforms()), + "descriptor_select_file" => json(b.descriptor_select_file()), + "descriptor_preview_file" => json(b.descriptor_preview_file(&a.str("path"))), + "descriptor_install_file" => json(b.descriptor_install_file(&a.str("path"))), + "descriptor_remove" => json(b.descriptor_remove(&a.str("platformId"))), + "open_descriptors_folder" => json(b.open_descriptors_folder()), + "open_url" => json(b.open_url(&a.str("url"))), + "open_logs_folder" => json(b.open_logs_folder()), + "get_desktop_wallpaper" => json(b.get_desktop_wallpaper()), + "steam_set_api_key" => json(b.steam_set_api_key(&a.str("key"))), + "steam_has_api_key" => json(b.steam_has_api_key()), + "steam_open_api_key_page" => json(b.steam_open_api_key_page()), + "steam_switch_account_and_launch_game" => json(b.steam_switch_account_and_launch_game( + &a.str("username"), + &a.str("appId"), + a.bool("runAsAdmin"), + &a.str("launchOptions"), + &a.str("shutdownMode"), + )), + // The TS mock reads `accountId` and the frontend sends `steamId`. + "steam_get_profile_info" => { + json(b.steam_get_profile_info(&a.str_any(&["steamId", "accountId"]))) + } + "steam_get_profile_infos" => { + json(b.steam_get_profile_infos(&a.strings_any(&["steamIds", "accountIds"]))) + } + "steam_get_player_bans" => json(b.steam_get_player_bans(&a.strings_any(&["steamIds"]))), + "steam_copy_game_settings" => json(b.steam_copy_game_settings( + &a.str("fromSteamId"), + &a.str("toSteamId"), + &a.str("appId"), + )), + "steam_get_copyable_games" => { + json(b.steam_get_copyable_games(&a.str("fromSteamId"), &a.str("toSteamId"))) + } + "steam_open_userdata" => json(b.steam_open_userdata(&a.str("steamId"))), + "steam_clear_browser_cache" => json(b.steam_clear_browser_cache()), + "steam_bulk_edit" => match a.decode::("request") { + Ok(request) => json(b.steam_bulk_edit(request)), + Err(error) => Err(error), + }, + "steam_get_account_games" => json(b.steam_get_account_games(&a.str("steamId"))), + "cs2_bridge_get_settings" => json(b.cs2_bridge_get_settings()), + "cs2_bridge_set_settings" => json(b.cs2_bridge_set_settings( + a.bool("enabled"), + &a.str("url"), + a.opt_str("token").as_deref(), + )), + "cs2_bridge_fetch" => json(b.cs2_bridge_fetch()), + "cs2_bridge_check" => json(b.cs2_bridge_check(&a.str("steamId"))), + "cs2_bridge_test" => json(b.cs2_bridge_test()), + "riot_capture_profile" => json(b.riot_capture_profile(&a.str("profileId"))), + "roblox_add_account_by_cookie" => json(b.roblox_add_account_by_cookie(&a.str("cookie"))), + "roblox_get_profile_info" => { + json(b.roblox_get_profile_info(&a.str_any(&["userId", "accountId"]))) + } + "roblox_check_sessions" => json(b.roblox_check_sessions()), + "list_custom_themes" => json(b.list_custom_themes()), + "save_custom_theme" => match a.decode::("theme") { + Ok(theme) => json(b.save_custom_theme(&theme)), + Err(error) => Err(error), + }, + "delete_custom_theme" => json(b.delete_custom_theme(&a.str("themeId"))), + "telemetry_get_state" => json(b.telemetry_get_state()), + "telemetry_set_mode_a" => json(b.telemetry_set_mode_a(a.bool("enabled"))), + "telemetry_set_mode_b" => json(b.telemetry_set_mode_b(a.bool("enabled"))), + "telemetry_retry_forget" => json(b.telemetry_retry_forget()), + "telemetry_complete_onboarding" => { + json(b.telemetry_complete_onboarding(a.bool("modeAEnabled"), a.bool("modeBEnabled"))) + } + "telemetry_track_persona_switch" => { + json(b.telemetry_track_persona_switch(a.u64("platforms"), a.u64("succeeded"))) + } + "telemetry_track_account_added" => { + json(b.telemetry_track_account_added(&a.str("platformId"))) + } + "telemetry_track_account_add_started" => { + json(b.telemetry_track_account_add_started(&a.str("platformId"))) + } + "telemetry_track_account_add_cancelled" => { + json(b.telemetry_track_account_add_cancelled(&a.str("platformId"))) + } + "telemetry_track_operation_failed" => json(b.telemetry_track_operation_failed( + &a.str("operation"), + &a.str("errorCode"), + a.opt_str("platformId").as_deref(), + )), + "telemetry_track_update" => json(b.telemetry_track_update( + &a.str("stage"), + a.opt_str("targetVersion").as_deref(), + a.opt_str("errorCode").as_deref(), + )), + // The frontend spreads the snapshot's fields into the arguments. + "telemetry_track_settings_snapshot" => match a.decode_whole::() { + Ok(snapshot) => json(b.telemetry_track_settings_snapshot(&snapshot)), + Err(error) => Err(error), + }, + "telemetry_track_streamer_mode" => json(b.telemetry_track_streamer_mode()), + "telemetry_export" => json(b.telemetry_export()), + _ => return None, + }) +} + +fn json(result: BackendResult) -> BackendResult { + result.and_then(|value| { + serde_json::to_value(value).map_err(|e| BackendError::new(format!("serialize: {e}"))) + }) +} + +/// The argument object, read the way a JS handler reads `args.x`. +struct Args<'a>(&'a Value); + +impl Args<'_> { + fn get(&self, key: &str) -> Option<&Value> { + self.0.get(key).filter(|v| !v.is_null()) + } + + fn opt_str(&self, key: &str) -> Option { + self.get(key).map(|v| match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }) + } + + fn str(&self, key: &str) -> String { + self.opt_str(key).unwrap_or_default() + } + + fn str_any(&self, keys: &[&str]) -> String { + keys.iter() + .find_map(|key| self.opt_str(key)) + .unwrap_or_default() + } + + fn strings_any(&self, keys: &[&str]) -> Vec { + keys.iter() + .find_map(|key| self.get(key).and_then(Value::as_array)) + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + } + + fn bool(&self, key: &str) -> bool { + self.get(key).and_then(Value::as_bool).unwrap_or(false) + } + + fn u64(&self, key: &str) -> u64 { + self.get(key).and_then(Value::as_u64).unwrap_or(0) + } + + fn value(&self, key: &str) -> Value { + self.get(key).cloned().unwrap_or(Value::Null) + } + + fn decode(&self, key: &str) -> BackendResult { + serde_json::from_value(self.value(key)) + .map_err(|e| BackendError::new(format!("invalid args `{key}`: {e}"))) + } + + fn decode_whole(&self) -> BackendResult { + serde_json::from_value(self.0.clone()) + .map_err(|e| BackendError::new(format!("invalid args: {e}"))) + } +} diff --git a/crates/accshift-app/src/display/fold_table.rs b/crates/accshift-app/src/display/fold_table.rs new file mode 100644 index 00000000..5583662a --- /dev/null +++ b/crates/accshift-app/src/display/fold_table.rs @@ -0,0 +1,529 @@ +//! Generated by tests/data/display/gen_fold_table.mjs (Node 24.21.0, +//! Unicode 17.0). Do not edit by hand. +//! +//! Each code point whose `normalize("NFD")` minus U+0300..U+036F differs +//! from itself, with that result, sorted by code point. + +#[rustfmt::skip] +pub(super) static FOLD_TABLE: [(u32, &str); 2077] = [ + (0xc0, "\u{41}"), (0xc1, "\u{41}"), (0xc2, "\u{41}"), (0xc3, "\u{41}"), + (0xc4, "\u{41}"), (0xc5, "\u{41}"), (0xc7, "\u{43}"), (0xc8, "\u{45}"), + (0xc9, "\u{45}"), (0xca, "\u{45}"), (0xcb, "\u{45}"), (0xcc, "\u{49}"), + (0xcd, "\u{49}"), (0xce, "\u{49}"), (0xcf, "\u{49}"), (0xd1, "\u{4e}"), + (0xd2, "\u{4f}"), (0xd3, "\u{4f}"), (0xd4, "\u{4f}"), (0xd5, "\u{4f}"), + (0xd6, "\u{4f}"), (0xd9, "\u{55}"), (0xda, "\u{55}"), (0xdb, "\u{55}"), + (0xdc, "\u{55}"), (0xdd, "\u{59}"), (0xe0, "\u{61}"), (0xe1, "\u{61}"), + (0xe2, "\u{61}"), (0xe3, "\u{61}"), (0xe4, "\u{61}"), (0xe5, "\u{61}"), + (0xe7, "\u{63}"), (0xe8, "\u{65}"), (0xe9, "\u{65}"), (0xea, "\u{65}"), + (0xeb, "\u{65}"), (0xec, "\u{69}"), (0xed, "\u{69}"), (0xee, "\u{69}"), + (0xef, "\u{69}"), (0xf1, "\u{6e}"), (0xf2, "\u{6f}"), (0xf3, "\u{6f}"), + (0xf4, "\u{6f}"), (0xf5, "\u{6f}"), (0xf6, "\u{6f}"), (0xf9, "\u{75}"), + (0xfa, "\u{75}"), (0xfb, "\u{75}"), (0xfc, "\u{75}"), (0xfd, "\u{79}"), + (0xff, "\u{79}"), (0x100, "\u{41}"), (0x101, "\u{61}"), (0x102, "\u{41}"), + (0x103, "\u{61}"), (0x104, "\u{41}"), (0x105, "\u{61}"), (0x106, "\u{43}"), + (0x107, "\u{63}"), (0x108, "\u{43}"), (0x109, "\u{63}"), (0x10a, "\u{43}"), + (0x10b, "\u{63}"), (0x10c, "\u{43}"), (0x10d, "\u{63}"), (0x10e, "\u{44}"), + (0x10f, "\u{64}"), (0x112, "\u{45}"), (0x113, "\u{65}"), (0x114, "\u{45}"), + (0x115, "\u{65}"), (0x116, "\u{45}"), (0x117, "\u{65}"), (0x118, "\u{45}"), + (0x119, "\u{65}"), (0x11a, "\u{45}"), (0x11b, "\u{65}"), (0x11c, "\u{47}"), + (0x11d, "\u{67}"), (0x11e, "\u{47}"), (0x11f, "\u{67}"), (0x120, "\u{47}"), + (0x121, "\u{67}"), (0x122, "\u{47}"), (0x123, "\u{67}"), (0x124, "\u{48}"), + (0x125, "\u{68}"), (0x128, "\u{49}"), (0x129, "\u{69}"), (0x12a, "\u{49}"), + (0x12b, "\u{69}"), (0x12c, "\u{49}"), (0x12d, "\u{69}"), (0x12e, "\u{49}"), + (0x12f, "\u{69}"), (0x130, "\u{49}"), (0x134, "\u{4a}"), (0x135, "\u{6a}"), + (0x136, "\u{4b}"), (0x137, "\u{6b}"), (0x139, "\u{4c}"), (0x13a, "\u{6c}"), + (0x13b, "\u{4c}"), (0x13c, "\u{6c}"), (0x13d, "\u{4c}"), (0x13e, "\u{6c}"), + (0x143, "\u{4e}"), (0x144, "\u{6e}"), (0x145, "\u{4e}"), (0x146, "\u{6e}"), + (0x147, "\u{4e}"), (0x148, "\u{6e}"), (0x14c, "\u{4f}"), (0x14d, "\u{6f}"), + (0x14e, "\u{4f}"), (0x14f, "\u{6f}"), (0x150, "\u{4f}"), (0x151, "\u{6f}"), + (0x154, "\u{52}"), (0x155, "\u{72}"), (0x156, "\u{52}"), (0x157, "\u{72}"), + (0x158, "\u{52}"), (0x159, "\u{72}"), (0x15a, "\u{53}"), (0x15b, "\u{73}"), + (0x15c, "\u{53}"), (0x15d, "\u{73}"), (0x15e, "\u{53}"), (0x15f, "\u{73}"), + (0x160, "\u{53}"), (0x161, "\u{73}"), (0x162, "\u{54}"), (0x163, "\u{74}"), + (0x164, "\u{54}"), (0x165, "\u{74}"), (0x168, "\u{55}"), (0x169, "\u{75}"), + (0x16a, "\u{55}"), (0x16b, "\u{75}"), (0x16c, "\u{55}"), (0x16d, "\u{75}"), + (0x16e, "\u{55}"), (0x16f, "\u{75}"), (0x170, "\u{55}"), (0x171, "\u{75}"), + (0x172, "\u{55}"), (0x173, "\u{75}"), (0x174, "\u{57}"), (0x175, "\u{77}"), + (0x176, "\u{59}"), (0x177, "\u{79}"), (0x178, "\u{59}"), (0x179, "\u{5a}"), + (0x17a, "\u{7a}"), (0x17b, "\u{5a}"), (0x17c, "\u{7a}"), (0x17d, "\u{5a}"), + (0x17e, "\u{7a}"), (0x1a0, "\u{4f}"), (0x1a1, "\u{6f}"), (0x1af, "\u{55}"), + (0x1b0, "\u{75}"), (0x1cd, "\u{41}"), (0x1ce, "\u{61}"), (0x1cf, "\u{49}"), + (0x1d0, "\u{69}"), (0x1d1, "\u{4f}"), (0x1d2, "\u{6f}"), (0x1d3, "\u{55}"), + (0x1d4, "\u{75}"), (0x1d5, "\u{55}"), (0x1d6, "\u{75}"), (0x1d7, "\u{55}"), + (0x1d8, "\u{75}"), (0x1d9, "\u{55}"), (0x1da, "\u{75}"), (0x1db, "\u{55}"), + (0x1dc, "\u{75}"), (0x1de, "\u{41}"), (0x1df, "\u{61}"), (0x1e0, "\u{41}"), + (0x1e1, "\u{61}"), (0x1e2, "\u{c6}"), (0x1e3, "\u{e6}"), (0x1e6, "\u{47}"), + (0x1e7, "\u{67}"), (0x1e8, "\u{4b}"), (0x1e9, "\u{6b}"), (0x1ea, "\u{4f}"), + (0x1eb, "\u{6f}"), (0x1ec, "\u{4f}"), (0x1ed, "\u{6f}"), (0x1ee, "\u{1b7}"), + (0x1ef, "\u{292}"), (0x1f0, "\u{6a}"), (0x1f4, "\u{47}"), (0x1f5, "\u{67}"), + (0x1f8, "\u{4e}"), (0x1f9, "\u{6e}"), (0x1fa, "\u{41}"), (0x1fb, "\u{61}"), + (0x1fc, "\u{c6}"), (0x1fd, "\u{e6}"), (0x1fe, "\u{d8}"), (0x1ff, "\u{f8}"), + (0x200, "\u{41}"), (0x201, "\u{61}"), (0x202, "\u{41}"), (0x203, "\u{61}"), + (0x204, "\u{45}"), (0x205, "\u{65}"), (0x206, "\u{45}"), (0x207, "\u{65}"), + (0x208, "\u{49}"), (0x209, "\u{69}"), (0x20a, "\u{49}"), (0x20b, "\u{69}"), + (0x20c, "\u{4f}"), (0x20d, "\u{6f}"), (0x20e, "\u{4f}"), (0x20f, "\u{6f}"), + (0x210, "\u{52}"), (0x211, "\u{72}"), (0x212, "\u{52}"), (0x213, "\u{72}"), + (0x214, "\u{55}"), (0x215, "\u{75}"), (0x216, "\u{55}"), (0x217, "\u{75}"), + (0x218, "\u{53}"), (0x219, "\u{73}"), (0x21a, "\u{54}"), (0x21b, "\u{74}"), + (0x21e, "\u{48}"), (0x21f, "\u{68}"), (0x226, "\u{41}"), (0x227, "\u{61}"), + (0x228, "\u{45}"), (0x229, "\u{65}"), (0x22a, "\u{4f}"), (0x22b, "\u{6f}"), + (0x22c, "\u{4f}"), (0x22d, "\u{6f}"), (0x22e, "\u{4f}"), (0x22f, "\u{6f}"), + (0x230, "\u{4f}"), (0x231, "\u{6f}"), (0x232, "\u{59}"), (0x233, "\u{79}"), + (0x374, "\u{2b9}"), (0x37e, "\u{3b}"), (0x385, "\u{a8}"), (0x386, "\u{391}"), + (0x387, "\u{b7}"), (0x388, "\u{395}"), (0x389, "\u{397}"), (0x38a, "\u{399}"), + (0x38c, "\u{39f}"), (0x38e, "\u{3a5}"), (0x38f, "\u{3a9}"), (0x390, "\u{3b9}"), + (0x3aa, "\u{399}"), (0x3ab, "\u{3a5}"), (0x3ac, "\u{3b1}"), (0x3ad, "\u{3b5}"), + (0x3ae, "\u{3b7}"), (0x3af, "\u{3b9}"), (0x3b0, "\u{3c5}"), (0x3ca, "\u{3b9}"), + (0x3cb, "\u{3c5}"), (0x3cc, "\u{3bf}"), (0x3cd, "\u{3c5}"), (0x3ce, "\u{3c9}"), + (0x3d3, "\u{3d2}"), (0x3d4, "\u{3d2}"), (0x400, "\u{415}"), (0x401, "\u{415}"), + (0x403, "\u{413}"), (0x407, "\u{406}"), (0x40c, "\u{41a}"), (0x40d, "\u{418}"), + (0x40e, "\u{423}"), (0x419, "\u{418}"), (0x439, "\u{438}"), (0x450, "\u{435}"), + (0x451, "\u{435}"), (0x453, "\u{433}"), (0x457, "\u{456}"), (0x45c, "\u{43a}"), + (0x45d, "\u{438}"), (0x45e, "\u{443}"), (0x476, "\u{474}"), (0x477, "\u{475}"), + (0x4c1, "\u{416}"), (0x4c2, "\u{436}"), (0x4d0, "\u{410}"), (0x4d1, "\u{430}"), + (0x4d2, "\u{410}"), (0x4d3, "\u{430}"), (0x4d6, "\u{415}"), (0x4d7, "\u{435}"), + (0x4da, "\u{4d8}"), (0x4db, "\u{4d9}"), (0x4dc, "\u{416}"), (0x4dd, "\u{436}"), + (0x4de, "\u{417}"), (0x4df, "\u{437}"), (0x4e2, "\u{418}"), (0x4e3, "\u{438}"), + (0x4e4, "\u{418}"), (0x4e5, "\u{438}"), (0x4e6, "\u{41e}"), (0x4e7, "\u{43e}"), + (0x4ea, "\u{4e8}"), (0x4eb, "\u{4e9}"), (0x4ec, "\u{42d}"), (0x4ed, "\u{44d}"), + (0x4ee, "\u{423}"), (0x4ef, "\u{443}"), (0x4f0, "\u{423}"), (0x4f1, "\u{443}"), + (0x4f2, "\u{423}"), (0x4f3, "\u{443}"), (0x4f4, "\u{427}"), (0x4f5, "\u{447}"), + (0x4f8, "\u{42b}"), (0x4f9, "\u{44b}"), (0x622, "\u{627}\u{653}"), (0x623, "\u{627}\u{654}"), + (0x624, "\u{648}\u{654}"), (0x625, "\u{627}\u{655}"), (0x626, "\u{64a}\u{654}"), (0x6c0, "\u{6d5}\u{654}"), + (0x6c2, "\u{6c1}\u{654}"), (0x6d3, "\u{6d2}\u{654}"), (0x929, "\u{928}\u{93c}"), (0x931, "\u{930}\u{93c}"), + (0x934, "\u{933}\u{93c}"), (0x958, "\u{915}\u{93c}"), (0x959, "\u{916}\u{93c}"), (0x95a, "\u{917}\u{93c}"), + (0x95b, "\u{91c}\u{93c}"), (0x95c, "\u{921}\u{93c}"), (0x95d, "\u{922}\u{93c}"), (0x95e, "\u{92b}\u{93c}"), + (0x95f, "\u{92f}\u{93c}"), (0x9cb, "\u{9c7}\u{9be}"), (0x9cc, "\u{9c7}\u{9d7}"), (0x9dc, "\u{9a1}\u{9bc}"), + (0x9dd, "\u{9a2}\u{9bc}"), (0x9df, "\u{9af}\u{9bc}"), (0xa33, "\u{a32}\u{a3c}"), (0xa36, "\u{a38}\u{a3c}"), + (0xa59, "\u{a16}\u{a3c}"), (0xa5a, "\u{a17}\u{a3c}"), (0xa5b, "\u{a1c}\u{a3c}"), (0xa5e, "\u{a2b}\u{a3c}"), + (0xb48, "\u{b47}\u{b56}"), (0xb4b, "\u{b47}\u{b3e}"), (0xb4c, "\u{b47}\u{b57}"), (0xb5c, "\u{b21}\u{b3c}"), + (0xb5d, "\u{b22}\u{b3c}"), (0xb94, "\u{b92}\u{bd7}"), (0xbca, "\u{bc6}\u{bbe}"), (0xbcb, "\u{bc7}\u{bbe}"), + (0xbcc, "\u{bc6}\u{bd7}"), (0xc48, "\u{c46}\u{c56}"), (0xcc0, "\u{cbf}\u{cd5}"), (0xcc7, "\u{cc6}\u{cd5}"), + (0xcc8, "\u{cc6}\u{cd6}"), (0xcca, "\u{cc6}\u{cc2}"), (0xccb, "\u{cc6}\u{cc2}\u{cd5}"), (0xd4a, "\u{d46}\u{d3e}"), + (0xd4b, "\u{d47}\u{d3e}"), (0xd4c, "\u{d46}\u{d57}"), (0xdda, "\u{dd9}\u{dca}"), (0xddc, "\u{dd9}\u{dcf}"), + (0xddd, "\u{dd9}\u{dcf}\u{dca}"), (0xdde, "\u{dd9}\u{ddf}"), (0xf43, "\u{f42}\u{fb7}"), (0xf4d, "\u{f4c}\u{fb7}"), + (0xf52, "\u{f51}\u{fb7}"), (0xf57, "\u{f56}\u{fb7}"), (0xf5c, "\u{f5b}\u{fb7}"), (0xf69, "\u{f40}\u{fb5}"), + (0xf73, "\u{f71}\u{f72}"), (0xf75, "\u{f71}\u{f74}"), (0xf76, "\u{fb2}\u{f80}"), (0xf78, "\u{fb3}\u{f80}"), + (0xf81, "\u{f71}\u{f80}"), (0xf93, "\u{f92}\u{fb7}"), (0xf9d, "\u{f9c}\u{fb7}"), (0xfa2, "\u{fa1}\u{fb7}"), + (0xfa7, "\u{fa6}\u{fb7}"), (0xfac, "\u{fab}\u{fb7}"), (0xfb9, "\u{f90}\u{fb5}"), (0x1026, "\u{1025}\u{102e}"), + (0x1b06, "\u{1b05}\u{1b35}"), (0x1b08, "\u{1b07}\u{1b35}"), (0x1b0a, "\u{1b09}\u{1b35}"), (0x1b0c, "\u{1b0b}\u{1b35}"), + (0x1b0e, "\u{1b0d}\u{1b35}"), (0x1b12, "\u{1b11}\u{1b35}"), (0x1b3b, "\u{1b3a}\u{1b35}"), (0x1b3d, "\u{1b3c}\u{1b35}"), + (0x1b40, "\u{1b3e}\u{1b35}"), (0x1b41, "\u{1b3f}\u{1b35}"), (0x1b43, "\u{1b42}\u{1b35}"), (0x1e00, "\u{41}"), + (0x1e01, "\u{61}"), (0x1e02, "\u{42}"), (0x1e03, "\u{62}"), (0x1e04, "\u{42}"), + (0x1e05, "\u{62}"), (0x1e06, "\u{42}"), (0x1e07, "\u{62}"), (0x1e08, "\u{43}"), + (0x1e09, "\u{63}"), (0x1e0a, "\u{44}"), (0x1e0b, "\u{64}"), (0x1e0c, "\u{44}"), + (0x1e0d, "\u{64}"), (0x1e0e, "\u{44}"), (0x1e0f, "\u{64}"), (0x1e10, "\u{44}"), + (0x1e11, "\u{64}"), (0x1e12, "\u{44}"), (0x1e13, "\u{64}"), (0x1e14, "\u{45}"), + (0x1e15, "\u{65}"), (0x1e16, "\u{45}"), (0x1e17, "\u{65}"), (0x1e18, "\u{45}"), + (0x1e19, "\u{65}"), (0x1e1a, "\u{45}"), (0x1e1b, "\u{65}"), (0x1e1c, "\u{45}"), + (0x1e1d, "\u{65}"), (0x1e1e, "\u{46}"), (0x1e1f, "\u{66}"), (0x1e20, "\u{47}"), + (0x1e21, "\u{67}"), (0x1e22, "\u{48}"), (0x1e23, "\u{68}"), (0x1e24, "\u{48}"), + (0x1e25, "\u{68}"), (0x1e26, "\u{48}"), (0x1e27, "\u{68}"), (0x1e28, "\u{48}"), + (0x1e29, "\u{68}"), (0x1e2a, "\u{48}"), (0x1e2b, "\u{68}"), (0x1e2c, "\u{49}"), + (0x1e2d, "\u{69}"), (0x1e2e, "\u{49}"), (0x1e2f, "\u{69}"), (0x1e30, "\u{4b}"), + (0x1e31, "\u{6b}"), (0x1e32, "\u{4b}"), (0x1e33, "\u{6b}"), (0x1e34, "\u{4b}"), + (0x1e35, "\u{6b}"), (0x1e36, "\u{4c}"), (0x1e37, "\u{6c}"), (0x1e38, "\u{4c}"), + (0x1e39, "\u{6c}"), (0x1e3a, "\u{4c}"), (0x1e3b, "\u{6c}"), (0x1e3c, "\u{4c}"), + (0x1e3d, "\u{6c}"), (0x1e3e, "\u{4d}"), (0x1e3f, "\u{6d}"), (0x1e40, "\u{4d}"), + (0x1e41, "\u{6d}"), (0x1e42, "\u{4d}"), (0x1e43, "\u{6d}"), (0x1e44, "\u{4e}"), + (0x1e45, "\u{6e}"), (0x1e46, "\u{4e}"), (0x1e47, "\u{6e}"), (0x1e48, "\u{4e}"), + (0x1e49, "\u{6e}"), (0x1e4a, "\u{4e}"), (0x1e4b, "\u{6e}"), (0x1e4c, "\u{4f}"), + (0x1e4d, "\u{6f}"), (0x1e4e, "\u{4f}"), (0x1e4f, "\u{6f}"), (0x1e50, "\u{4f}"), + (0x1e51, "\u{6f}"), (0x1e52, "\u{4f}"), (0x1e53, "\u{6f}"), (0x1e54, "\u{50}"), + (0x1e55, "\u{70}"), (0x1e56, "\u{50}"), (0x1e57, "\u{70}"), (0x1e58, "\u{52}"), + (0x1e59, "\u{72}"), (0x1e5a, "\u{52}"), (0x1e5b, "\u{72}"), (0x1e5c, "\u{52}"), + (0x1e5d, "\u{72}"), (0x1e5e, "\u{52}"), (0x1e5f, "\u{72}"), (0x1e60, "\u{53}"), + (0x1e61, "\u{73}"), (0x1e62, "\u{53}"), (0x1e63, "\u{73}"), (0x1e64, "\u{53}"), + (0x1e65, "\u{73}"), (0x1e66, "\u{53}"), (0x1e67, "\u{73}"), (0x1e68, "\u{53}"), + (0x1e69, "\u{73}"), (0x1e6a, "\u{54}"), (0x1e6b, "\u{74}"), (0x1e6c, "\u{54}"), + (0x1e6d, "\u{74}"), (0x1e6e, "\u{54}"), (0x1e6f, "\u{74}"), (0x1e70, "\u{54}"), + (0x1e71, "\u{74}"), (0x1e72, "\u{55}"), (0x1e73, "\u{75}"), (0x1e74, "\u{55}"), + (0x1e75, "\u{75}"), (0x1e76, "\u{55}"), (0x1e77, "\u{75}"), (0x1e78, "\u{55}"), + (0x1e79, "\u{75}"), (0x1e7a, "\u{55}"), (0x1e7b, "\u{75}"), (0x1e7c, "\u{56}"), + (0x1e7d, "\u{76}"), (0x1e7e, "\u{56}"), (0x1e7f, "\u{76}"), (0x1e80, "\u{57}"), + (0x1e81, "\u{77}"), (0x1e82, "\u{57}"), (0x1e83, "\u{77}"), (0x1e84, "\u{57}"), + (0x1e85, "\u{77}"), (0x1e86, "\u{57}"), (0x1e87, "\u{77}"), (0x1e88, "\u{57}"), + (0x1e89, "\u{77}"), (0x1e8a, "\u{58}"), (0x1e8b, "\u{78}"), (0x1e8c, "\u{58}"), + (0x1e8d, "\u{78}"), (0x1e8e, "\u{59}"), (0x1e8f, "\u{79}"), (0x1e90, "\u{5a}"), + (0x1e91, "\u{7a}"), (0x1e92, "\u{5a}"), (0x1e93, "\u{7a}"), (0x1e94, "\u{5a}"), + (0x1e95, "\u{7a}"), (0x1e96, "\u{68}"), (0x1e97, "\u{74}"), (0x1e98, "\u{77}"), + (0x1e99, "\u{79}"), (0x1e9b, "\u{17f}"), (0x1ea0, "\u{41}"), (0x1ea1, "\u{61}"), + (0x1ea2, "\u{41}"), (0x1ea3, "\u{61}"), (0x1ea4, "\u{41}"), (0x1ea5, "\u{61}"), + (0x1ea6, "\u{41}"), (0x1ea7, "\u{61}"), (0x1ea8, "\u{41}"), (0x1ea9, "\u{61}"), + (0x1eaa, "\u{41}"), (0x1eab, "\u{61}"), (0x1eac, "\u{41}"), (0x1ead, "\u{61}"), + (0x1eae, "\u{41}"), (0x1eaf, "\u{61}"), (0x1eb0, "\u{41}"), (0x1eb1, "\u{61}"), + (0x1eb2, "\u{41}"), (0x1eb3, "\u{61}"), (0x1eb4, "\u{41}"), (0x1eb5, "\u{61}"), + (0x1eb6, "\u{41}"), (0x1eb7, "\u{61}"), (0x1eb8, "\u{45}"), (0x1eb9, "\u{65}"), + (0x1eba, "\u{45}"), (0x1ebb, "\u{65}"), (0x1ebc, "\u{45}"), (0x1ebd, "\u{65}"), + (0x1ebe, "\u{45}"), (0x1ebf, "\u{65}"), (0x1ec0, "\u{45}"), (0x1ec1, "\u{65}"), + (0x1ec2, "\u{45}"), (0x1ec3, "\u{65}"), (0x1ec4, "\u{45}"), (0x1ec5, "\u{65}"), + (0x1ec6, "\u{45}"), (0x1ec7, "\u{65}"), (0x1ec8, "\u{49}"), (0x1ec9, "\u{69}"), + (0x1eca, "\u{49}"), (0x1ecb, "\u{69}"), (0x1ecc, "\u{4f}"), (0x1ecd, "\u{6f}"), + (0x1ece, "\u{4f}"), (0x1ecf, "\u{6f}"), (0x1ed0, "\u{4f}"), (0x1ed1, "\u{6f}"), + (0x1ed2, "\u{4f}"), (0x1ed3, "\u{6f}"), (0x1ed4, "\u{4f}"), (0x1ed5, "\u{6f}"), + (0x1ed6, "\u{4f}"), (0x1ed7, "\u{6f}"), (0x1ed8, "\u{4f}"), (0x1ed9, "\u{6f}"), + (0x1eda, "\u{4f}"), (0x1edb, "\u{6f}"), (0x1edc, "\u{4f}"), (0x1edd, "\u{6f}"), + (0x1ede, "\u{4f}"), (0x1edf, "\u{6f}"), (0x1ee0, "\u{4f}"), (0x1ee1, "\u{6f}"), + (0x1ee2, "\u{4f}"), (0x1ee3, "\u{6f}"), (0x1ee4, "\u{55}"), (0x1ee5, "\u{75}"), + (0x1ee6, "\u{55}"), (0x1ee7, "\u{75}"), (0x1ee8, "\u{55}"), (0x1ee9, "\u{75}"), + (0x1eea, "\u{55}"), (0x1eeb, "\u{75}"), (0x1eec, "\u{55}"), (0x1eed, "\u{75}"), + (0x1eee, "\u{55}"), (0x1eef, "\u{75}"), (0x1ef0, "\u{55}"), (0x1ef1, "\u{75}"), + (0x1ef2, "\u{59}"), (0x1ef3, "\u{79}"), (0x1ef4, "\u{59}"), (0x1ef5, "\u{79}"), + (0x1ef6, "\u{59}"), (0x1ef7, "\u{79}"), (0x1ef8, "\u{59}"), (0x1ef9, "\u{79}"), + (0x1f00, "\u{3b1}"), (0x1f01, "\u{3b1}"), (0x1f02, "\u{3b1}"), (0x1f03, "\u{3b1}"), + (0x1f04, "\u{3b1}"), (0x1f05, "\u{3b1}"), (0x1f06, "\u{3b1}"), (0x1f07, "\u{3b1}"), + (0x1f08, "\u{391}"), (0x1f09, "\u{391}"), (0x1f0a, "\u{391}"), (0x1f0b, "\u{391}"), + (0x1f0c, "\u{391}"), (0x1f0d, "\u{391}"), (0x1f0e, "\u{391}"), (0x1f0f, "\u{391}"), + (0x1f10, "\u{3b5}"), (0x1f11, "\u{3b5}"), (0x1f12, "\u{3b5}"), (0x1f13, "\u{3b5}"), + (0x1f14, "\u{3b5}"), (0x1f15, "\u{3b5}"), (0x1f18, "\u{395}"), (0x1f19, "\u{395}"), + (0x1f1a, "\u{395}"), (0x1f1b, "\u{395}"), (0x1f1c, "\u{395}"), (0x1f1d, "\u{395}"), + (0x1f20, "\u{3b7}"), (0x1f21, "\u{3b7}"), (0x1f22, "\u{3b7}"), (0x1f23, "\u{3b7}"), + (0x1f24, "\u{3b7}"), (0x1f25, "\u{3b7}"), (0x1f26, "\u{3b7}"), (0x1f27, "\u{3b7}"), + (0x1f28, "\u{397}"), (0x1f29, "\u{397}"), (0x1f2a, "\u{397}"), (0x1f2b, "\u{397}"), + (0x1f2c, "\u{397}"), (0x1f2d, "\u{397}"), (0x1f2e, "\u{397}"), (0x1f2f, "\u{397}"), + (0x1f30, "\u{3b9}"), (0x1f31, "\u{3b9}"), (0x1f32, "\u{3b9}"), (0x1f33, "\u{3b9}"), + (0x1f34, "\u{3b9}"), (0x1f35, "\u{3b9}"), (0x1f36, "\u{3b9}"), (0x1f37, "\u{3b9}"), + (0x1f38, "\u{399}"), (0x1f39, "\u{399}"), (0x1f3a, "\u{399}"), (0x1f3b, "\u{399}"), + (0x1f3c, "\u{399}"), (0x1f3d, "\u{399}"), (0x1f3e, "\u{399}"), (0x1f3f, "\u{399}"), + (0x1f40, "\u{3bf}"), (0x1f41, "\u{3bf}"), (0x1f42, "\u{3bf}"), (0x1f43, "\u{3bf}"), + (0x1f44, "\u{3bf}"), (0x1f45, "\u{3bf}"), (0x1f48, "\u{39f}"), (0x1f49, "\u{39f}"), + (0x1f4a, "\u{39f}"), (0x1f4b, "\u{39f}"), (0x1f4c, "\u{39f}"), (0x1f4d, "\u{39f}"), + (0x1f50, "\u{3c5}"), (0x1f51, "\u{3c5}"), (0x1f52, "\u{3c5}"), (0x1f53, "\u{3c5}"), + (0x1f54, "\u{3c5}"), (0x1f55, "\u{3c5}"), (0x1f56, "\u{3c5}"), (0x1f57, "\u{3c5}"), + (0x1f59, "\u{3a5}"), (0x1f5b, "\u{3a5}"), (0x1f5d, "\u{3a5}"), (0x1f5f, "\u{3a5}"), + (0x1f60, "\u{3c9}"), (0x1f61, "\u{3c9}"), (0x1f62, "\u{3c9}"), (0x1f63, "\u{3c9}"), + (0x1f64, "\u{3c9}"), (0x1f65, "\u{3c9}"), (0x1f66, "\u{3c9}"), (0x1f67, "\u{3c9}"), + (0x1f68, "\u{3a9}"), (0x1f69, "\u{3a9}"), (0x1f6a, "\u{3a9}"), (0x1f6b, "\u{3a9}"), + (0x1f6c, "\u{3a9}"), (0x1f6d, "\u{3a9}"), (0x1f6e, "\u{3a9}"), (0x1f6f, "\u{3a9}"), + (0x1f70, "\u{3b1}"), (0x1f71, "\u{3b1}"), (0x1f72, "\u{3b5}"), (0x1f73, "\u{3b5}"), + (0x1f74, "\u{3b7}"), (0x1f75, "\u{3b7}"), (0x1f76, "\u{3b9}"), (0x1f77, "\u{3b9}"), + (0x1f78, "\u{3bf}"), (0x1f79, "\u{3bf}"), (0x1f7a, "\u{3c5}"), (0x1f7b, "\u{3c5}"), + (0x1f7c, "\u{3c9}"), (0x1f7d, "\u{3c9}"), (0x1f80, "\u{3b1}"), (0x1f81, "\u{3b1}"), + (0x1f82, "\u{3b1}"), (0x1f83, "\u{3b1}"), (0x1f84, "\u{3b1}"), (0x1f85, "\u{3b1}"), + (0x1f86, "\u{3b1}"), (0x1f87, "\u{3b1}"), (0x1f88, "\u{391}"), (0x1f89, "\u{391}"), + (0x1f8a, "\u{391}"), (0x1f8b, "\u{391}"), (0x1f8c, "\u{391}"), (0x1f8d, "\u{391}"), + (0x1f8e, "\u{391}"), (0x1f8f, "\u{391}"), (0x1f90, "\u{3b7}"), (0x1f91, "\u{3b7}"), + (0x1f92, "\u{3b7}"), (0x1f93, "\u{3b7}"), (0x1f94, "\u{3b7}"), (0x1f95, "\u{3b7}"), + (0x1f96, "\u{3b7}"), (0x1f97, "\u{3b7}"), (0x1f98, "\u{397}"), (0x1f99, "\u{397}"), + (0x1f9a, "\u{397}"), (0x1f9b, "\u{397}"), (0x1f9c, "\u{397}"), (0x1f9d, "\u{397}"), + (0x1f9e, "\u{397}"), (0x1f9f, "\u{397}"), (0x1fa0, "\u{3c9}"), (0x1fa1, "\u{3c9}"), + (0x1fa2, "\u{3c9}"), (0x1fa3, "\u{3c9}"), (0x1fa4, "\u{3c9}"), (0x1fa5, "\u{3c9}"), + (0x1fa6, "\u{3c9}"), (0x1fa7, "\u{3c9}"), (0x1fa8, "\u{3a9}"), (0x1fa9, "\u{3a9}"), + (0x1faa, "\u{3a9}"), (0x1fab, "\u{3a9}"), (0x1fac, "\u{3a9}"), (0x1fad, "\u{3a9}"), + (0x1fae, "\u{3a9}"), (0x1faf, "\u{3a9}"), (0x1fb0, "\u{3b1}"), (0x1fb1, "\u{3b1}"), + (0x1fb2, "\u{3b1}"), (0x1fb3, "\u{3b1}"), (0x1fb4, "\u{3b1}"), (0x1fb6, "\u{3b1}"), + (0x1fb7, "\u{3b1}"), (0x1fb8, "\u{391}"), (0x1fb9, "\u{391}"), (0x1fba, "\u{391}"), + (0x1fbb, "\u{391}"), (0x1fbc, "\u{391}"), (0x1fbe, "\u{3b9}"), (0x1fc1, "\u{a8}"), + (0x1fc2, "\u{3b7}"), (0x1fc3, "\u{3b7}"), (0x1fc4, "\u{3b7}"), (0x1fc6, "\u{3b7}"), + (0x1fc7, "\u{3b7}"), (0x1fc8, "\u{395}"), (0x1fc9, "\u{395}"), (0x1fca, "\u{397}"), + (0x1fcb, "\u{397}"), (0x1fcc, "\u{397}"), (0x1fcd, "\u{1fbf}"), (0x1fce, "\u{1fbf}"), + (0x1fcf, "\u{1fbf}"), (0x1fd0, "\u{3b9}"), (0x1fd1, "\u{3b9}"), (0x1fd2, "\u{3b9}"), + (0x1fd3, "\u{3b9}"), (0x1fd6, "\u{3b9}"), (0x1fd7, "\u{3b9}"), (0x1fd8, "\u{399}"), + (0x1fd9, "\u{399}"), (0x1fda, "\u{399}"), (0x1fdb, "\u{399}"), (0x1fdd, "\u{1ffe}"), + (0x1fde, "\u{1ffe}"), (0x1fdf, "\u{1ffe}"), (0x1fe0, "\u{3c5}"), (0x1fe1, "\u{3c5}"), + (0x1fe2, "\u{3c5}"), (0x1fe3, "\u{3c5}"), (0x1fe4, "\u{3c1}"), (0x1fe5, "\u{3c1}"), + (0x1fe6, "\u{3c5}"), (0x1fe7, "\u{3c5}"), (0x1fe8, "\u{3a5}"), (0x1fe9, "\u{3a5}"), + (0x1fea, "\u{3a5}"), (0x1feb, "\u{3a5}"), (0x1fec, "\u{3a1}"), (0x1fed, "\u{a8}"), + (0x1fee, "\u{a8}"), (0x1fef, "\u{60}"), (0x1ff2, "\u{3c9}"), (0x1ff3, "\u{3c9}"), + (0x1ff4, "\u{3c9}"), (0x1ff6, "\u{3c9}"), (0x1ff7, "\u{3c9}"), (0x1ff8, "\u{39f}"), + (0x1ff9, "\u{39f}"), (0x1ffa, "\u{3a9}"), (0x1ffb, "\u{3a9}"), (0x1ffc, "\u{3a9}"), + (0x1ffd, "\u{b4}"), (0x2000, "\u{2002}"), (0x2001, "\u{2003}"), (0x2126, "\u{3a9}"), + (0x212a, "\u{4b}"), (0x212b, "\u{41}"), (0x219a, "\u{2190}"), (0x219b, "\u{2192}"), + (0x21ae, "\u{2194}"), (0x21cd, "\u{21d0}"), (0x21ce, "\u{21d4}"), (0x21cf, "\u{21d2}"), + (0x2204, "\u{2203}"), (0x2209, "\u{2208}"), (0x220c, "\u{220b}"), (0x2224, "\u{2223}"), + (0x2226, "\u{2225}"), (0x2241, "\u{223c}"), (0x2244, "\u{2243}"), (0x2247, "\u{2245}"), + (0x2249, "\u{2248}"), (0x2260, "\u{3d}"), (0x2262, "\u{2261}"), (0x226d, "\u{224d}"), + (0x226e, "\u{3c}"), (0x226f, "\u{3e}"), (0x2270, "\u{2264}"), (0x2271, "\u{2265}"), + (0x2274, "\u{2272}"), (0x2275, "\u{2273}"), (0x2278, "\u{2276}"), (0x2279, "\u{2277}"), + (0x2280, "\u{227a}"), (0x2281, "\u{227b}"), (0x2284, "\u{2282}"), (0x2285, "\u{2283}"), + (0x2288, "\u{2286}"), (0x2289, "\u{2287}"), (0x22ac, "\u{22a2}"), (0x22ad, "\u{22a8}"), + (0x22ae, "\u{22a9}"), (0x22af, "\u{22ab}"), (0x22e0, "\u{227c}"), (0x22e1, "\u{227d}"), + (0x22e2, "\u{2291}"), (0x22e3, "\u{2292}"), (0x22ea, "\u{22b2}"), (0x22eb, "\u{22b3}"), + (0x22ec, "\u{22b4}"), (0x22ed, "\u{22b5}"), (0x2329, "\u{3008}"), (0x232a, "\u{3009}"), + (0x2adc, "\u{2add}"), (0x304c, "\u{304b}\u{3099}"), (0x304e, "\u{304d}\u{3099}"), (0x3050, "\u{304f}\u{3099}"), + (0x3052, "\u{3051}\u{3099}"), (0x3054, "\u{3053}\u{3099}"), (0x3056, "\u{3055}\u{3099}"), (0x3058, "\u{3057}\u{3099}"), + (0x305a, "\u{3059}\u{3099}"), (0x305c, "\u{305b}\u{3099}"), (0x305e, "\u{305d}\u{3099}"), (0x3060, "\u{305f}\u{3099}"), + (0x3062, "\u{3061}\u{3099}"), (0x3065, "\u{3064}\u{3099}"), (0x3067, "\u{3066}\u{3099}"), (0x3069, "\u{3068}\u{3099}"), + (0x3070, "\u{306f}\u{3099}"), (0x3071, "\u{306f}\u{309a}"), (0x3073, "\u{3072}\u{3099}"), (0x3074, "\u{3072}\u{309a}"), + (0x3076, "\u{3075}\u{3099}"), (0x3077, "\u{3075}\u{309a}"), (0x3079, "\u{3078}\u{3099}"), (0x307a, "\u{3078}\u{309a}"), + (0x307c, "\u{307b}\u{3099}"), (0x307d, "\u{307b}\u{309a}"), (0x3094, "\u{3046}\u{3099}"), (0x309e, "\u{309d}\u{3099}"), + (0x30ac, "\u{30ab}\u{3099}"), (0x30ae, "\u{30ad}\u{3099}"), (0x30b0, "\u{30af}\u{3099}"), (0x30b2, "\u{30b1}\u{3099}"), + (0x30b4, "\u{30b3}\u{3099}"), (0x30b6, "\u{30b5}\u{3099}"), (0x30b8, "\u{30b7}\u{3099}"), (0x30ba, "\u{30b9}\u{3099}"), + (0x30bc, "\u{30bb}\u{3099}"), (0x30be, "\u{30bd}\u{3099}"), (0x30c0, "\u{30bf}\u{3099}"), (0x30c2, "\u{30c1}\u{3099}"), + (0x30c5, "\u{30c4}\u{3099}"), (0x30c7, "\u{30c6}\u{3099}"), (0x30c9, "\u{30c8}\u{3099}"), (0x30d0, "\u{30cf}\u{3099}"), + (0x30d1, "\u{30cf}\u{309a}"), (0x30d3, "\u{30d2}\u{3099}"), (0x30d4, "\u{30d2}\u{309a}"), (0x30d6, "\u{30d5}\u{3099}"), + (0x30d7, "\u{30d5}\u{309a}"), (0x30d9, "\u{30d8}\u{3099}"), (0x30da, "\u{30d8}\u{309a}"), (0x30dc, "\u{30db}\u{3099}"), + (0x30dd, "\u{30db}\u{309a}"), (0x30f4, "\u{30a6}\u{3099}"), (0x30f7, "\u{30ef}\u{3099}"), (0x30f8, "\u{30f0}\u{3099}"), + (0x30f9, "\u{30f1}\u{3099}"), (0x30fa, "\u{30f2}\u{3099}"), (0x30fe, "\u{30fd}\u{3099}"), (0xf900, "\u{8c48}"), + (0xf901, "\u{66f4}"), (0xf902, "\u{8eca}"), (0xf903, "\u{8cc8}"), (0xf904, "\u{6ed1}"), + (0xf905, "\u{4e32}"), (0xf906, "\u{53e5}"), (0xf907, "\u{9f9c}"), (0xf908, "\u{9f9c}"), + (0xf909, "\u{5951}"), (0xf90a, "\u{91d1}"), (0xf90b, "\u{5587}"), (0xf90c, "\u{5948}"), + (0xf90d, "\u{61f6}"), (0xf90e, "\u{7669}"), (0xf90f, "\u{7f85}"), (0xf910, "\u{863f}"), + (0xf911, "\u{87ba}"), (0xf912, "\u{88f8}"), (0xf913, "\u{908f}"), (0xf914, "\u{6a02}"), + (0xf915, "\u{6d1b}"), (0xf916, "\u{70d9}"), (0xf917, "\u{73de}"), (0xf918, "\u{843d}"), + (0xf919, "\u{916a}"), (0xf91a, "\u{99f1}"), (0xf91b, "\u{4e82}"), (0xf91c, "\u{5375}"), + (0xf91d, "\u{6b04}"), (0xf91e, "\u{721b}"), (0xf91f, "\u{862d}"), (0xf920, "\u{9e1e}"), + (0xf921, "\u{5d50}"), (0xf922, "\u{6feb}"), (0xf923, "\u{85cd}"), (0xf924, "\u{8964}"), + (0xf925, "\u{62c9}"), (0xf926, "\u{81d8}"), (0xf927, "\u{881f}"), (0xf928, "\u{5eca}"), + (0xf929, "\u{6717}"), (0xf92a, "\u{6d6a}"), (0xf92b, "\u{72fc}"), (0xf92c, "\u{90ce}"), + (0xf92d, "\u{4f86}"), (0xf92e, "\u{51b7}"), (0xf92f, "\u{52de}"), (0xf930, "\u{64c4}"), + (0xf931, "\u{6ad3}"), (0xf932, "\u{7210}"), (0xf933, "\u{76e7}"), (0xf934, "\u{8001}"), + (0xf935, "\u{8606}"), (0xf936, "\u{865c}"), (0xf937, "\u{8def}"), (0xf938, "\u{9732}"), + (0xf939, "\u{9b6f}"), (0xf93a, "\u{9dfa}"), (0xf93b, "\u{788c}"), (0xf93c, "\u{797f}"), + (0xf93d, "\u{7da0}"), (0xf93e, "\u{83c9}"), (0xf93f, "\u{9304}"), (0xf940, "\u{9e7f}"), + (0xf941, "\u{8ad6}"), (0xf942, "\u{58df}"), (0xf943, "\u{5f04}"), (0xf944, "\u{7c60}"), + (0xf945, "\u{807e}"), (0xf946, "\u{7262}"), (0xf947, "\u{78ca}"), (0xf948, "\u{8cc2}"), + (0xf949, "\u{96f7}"), (0xf94a, "\u{58d8}"), (0xf94b, "\u{5c62}"), (0xf94c, "\u{6a13}"), + (0xf94d, "\u{6dda}"), (0xf94e, "\u{6f0f}"), (0xf94f, "\u{7d2f}"), (0xf950, "\u{7e37}"), + (0xf951, "\u{964b}"), (0xf952, "\u{52d2}"), (0xf953, "\u{808b}"), (0xf954, "\u{51dc}"), + (0xf955, "\u{51cc}"), (0xf956, "\u{7a1c}"), (0xf957, "\u{7dbe}"), (0xf958, "\u{83f1}"), + (0xf959, "\u{9675}"), (0xf95a, "\u{8b80}"), (0xf95b, "\u{62cf}"), (0xf95c, "\u{6a02}"), + (0xf95d, "\u{8afe}"), (0xf95e, "\u{4e39}"), (0xf95f, "\u{5be7}"), (0xf960, "\u{6012}"), + (0xf961, "\u{7387}"), (0xf962, "\u{7570}"), (0xf963, "\u{5317}"), (0xf964, "\u{78fb}"), + (0xf965, "\u{4fbf}"), (0xf966, "\u{5fa9}"), (0xf967, "\u{4e0d}"), (0xf968, "\u{6ccc}"), + (0xf969, "\u{6578}"), (0xf96a, "\u{7d22}"), (0xf96b, "\u{53c3}"), (0xf96c, "\u{585e}"), + (0xf96d, "\u{7701}"), (0xf96e, "\u{8449}"), (0xf96f, "\u{8aaa}"), (0xf970, "\u{6bba}"), + (0xf971, "\u{8fb0}"), (0xf972, "\u{6c88}"), (0xf973, "\u{62fe}"), (0xf974, "\u{82e5}"), + (0xf975, "\u{63a0}"), (0xf976, "\u{7565}"), (0xf977, "\u{4eae}"), (0xf978, "\u{5169}"), + (0xf979, "\u{51c9}"), (0xf97a, "\u{6881}"), (0xf97b, "\u{7ce7}"), (0xf97c, "\u{826f}"), + (0xf97d, "\u{8ad2}"), (0xf97e, "\u{91cf}"), (0xf97f, "\u{52f5}"), (0xf980, "\u{5442}"), + (0xf981, "\u{5973}"), (0xf982, "\u{5eec}"), (0xf983, "\u{65c5}"), (0xf984, "\u{6ffe}"), + (0xf985, "\u{792a}"), (0xf986, "\u{95ad}"), (0xf987, "\u{9a6a}"), (0xf988, "\u{9e97}"), + (0xf989, "\u{9ece}"), (0xf98a, "\u{529b}"), (0xf98b, "\u{66c6}"), (0xf98c, "\u{6b77}"), + (0xf98d, "\u{8f62}"), (0xf98e, "\u{5e74}"), (0xf98f, "\u{6190}"), (0xf990, "\u{6200}"), + (0xf991, "\u{649a}"), (0xf992, "\u{6f23}"), (0xf993, "\u{7149}"), (0xf994, "\u{7489}"), + (0xf995, "\u{79ca}"), (0xf996, "\u{7df4}"), (0xf997, "\u{806f}"), (0xf998, "\u{8f26}"), + (0xf999, "\u{84ee}"), (0xf99a, "\u{9023}"), (0xf99b, "\u{934a}"), (0xf99c, "\u{5217}"), + (0xf99d, "\u{52a3}"), (0xf99e, "\u{54bd}"), (0xf99f, "\u{70c8}"), (0xf9a0, "\u{88c2}"), + (0xf9a1, "\u{8aaa}"), (0xf9a2, "\u{5ec9}"), (0xf9a3, "\u{5ff5}"), (0xf9a4, "\u{637b}"), + (0xf9a5, "\u{6bae}"), (0xf9a6, "\u{7c3e}"), (0xf9a7, "\u{7375}"), (0xf9a8, "\u{4ee4}"), + (0xf9a9, "\u{56f9}"), (0xf9aa, "\u{5be7}"), (0xf9ab, "\u{5dba}"), (0xf9ac, "\u{601c}"), + (0xf9ad, "\u{73b2}"), (0xf9ae, "\u{7469}"), (0xf9af, "\u{7f9a}"), (0xf9b0, "\u{8046}"), + (0xf9b1, "\u{9234}"), (0xf9b2, "\u{96f6}"), (0xf9b3, "\u{9748}"), (0xf9b4, "\u{9818}"), + (0xf9b5, "\u{4f8b}"), (0xf9b6, "\u{79ae}"), (0xf9b7, "\u{91b4}"), (0xf9b8, "\u{96b8}"), + (0xf9b9, "\u{60e1}"), (0xf9ba, "\u{4e86}"), (0xf9bb, "\u{50da}"), (0xf9bc, "\u{5bee}"), + (0xf9bd, "\u{5c3f}"), (0xf9be, "\u{6599}"), (0xf9bf, "\u{6a02}"), (0xf9c0, "\u{71ce}"), + (0xf9c1, "\u{7642}"), (0xf9c2, "\u{84fc}"), (0xf9c3, "\u{907c}"), (0xf9c4, "\u{9f8d}"), + (0xf9c5, "\u{6688}"), (0xf9c6, "\u{962e}"), (0xf9c7, "\u{5289}"), (0xf9c8, "\u{677b}"), + (0xf9c9, "\u{67f3}"), (0xf9ca, "\u{6d41}"), (0xf9cb, "\u{6e9c}"), (0xf9cc, "\u{7409}"), + (0xf9cd, "\u{7559}"), (0xf9ce, "\u{786b}"), (0xf9cf, "\u{7d10}"), (0xf9d0, "\u{985e}"), + (0xf9d1, "\u{516d}"), (0xf9d2, "\u{622e}"), (0xf9d3, "\u{9678}"), (0xf9d4, "\u{502b}"), + (0xf9d5, "\u{5d19}"), (0xf9d6, "\u{6dea}"), (0xf9d7, "\u{8f2a}"), (0xf9d8, "\u{5f8b}"), + (0xf9d9, "\u{6144}"), (0xf9da, "\u{6817}"), (0xf9db, "\u{7387}"), (0xf9dc, "\u{9686}"), + (0xf9dd, "\u{5229}"), (0xf9de, "\u{540f}"), (0xf9df, "\u{5c65}"), (0xf9e0, "\u{6613}"), + (0xf9e1, "\u{674e}"), (0xf9e2, "\u{68a8}"), (0xf9e3, "\u{6ce5}"), (0xf9e4, "\u{7406}"), + (0xf9e5, "\u{75e2}"), (0xf9e6, "\u{7f79}"), (0xf9e7, "\u{88cf}"), (0xf9e8, "\u{88e1}"), + (0xf9e9, "\u{91cc}"), (0xf9ea, "\u{96e2}"), (0xf9eb, "\u{533f}"), (0xf9ec, "\u{6eba}"), + (0xf9ed, "\u{541d}"), (0xf9ee, "\u{71d0}"), (0xf9ef, "\u{7498}"), (0xf9f0, "\u{85fa}"), + (0xf9f1, "\u{96a3}"), (0xf9f2, "\u{9c57}"), (0xf9f3, "\u{9e9f}"), (0xf9f4, "\u{6797}"), + (0xf9f5, "\u{6dcb}"), (0xf9f6, "\u{81e8}"), (0xf9f7, "\u{7acb}"), (0xf9f8, "\u{7b20}"), + (0xf9f9, "\u{7c92}"), (0xf9fa, "\u{72c0}"), (0xf9fb, "\u{7099}"), (0xf9fc, "\u{8b58}"), + (0xf9fd, "\u{4ec0}"), (0xf9fe, "\u{8336}"), (0xf9ff, "\u{523a}"), (0xfa00, "\u{5207}"), + (0xfa01, "\u{5ea6}"), (0xfa02, "\u{62d3}"), (0xfa03, "\u{7cd6}"), (0xfa04, "\u{5b85}"), + (0xfa05, "\u{6d1e}"), (0xfa06, "\u{66b4}"), (0xfa07, "\u{8f3b}"), (0xfa08, "\u{884c}"), + (0xfa09, "\u{964d}"), (0xfa0a, "\u{898b}"), (0xfa0b, "\u{5ed3}"), (0xfa0c, "\u{5140}"), + (0xfa0d, "\u{55c0}"), (0xfa10, "\u{585a}"), (0xfa12, "\u{6674}"), (0xfa15, "\u{51de}"), + (0xfa16, "\u{732a}"), (0xfa17, "\u{76ca}"), (0xfa18, "\u{793c}"), (0xfa19, "\u{795e}"), + (0xfa1a, "\u{7965}"), (0xfa1b, "\u{798f}"), (0xfa1c, "\u{9756}"), (0xfa1d, "\u{7cbe}"), + (0xfa1e, "\u{7fbd}"), (0xfa20, "\u{8612}"), (0xfa22, "\u{8af8}"), (0xfa25, "\u{9038}"), + (0xfa26, "\u{90fd}"), (0xfa2a, "\u{98ef}"), (0xfa2b, "\u{98fc}"), (0xfa2c, "\u{9928}"), + (0xfa2d, "\u{9db4}"), (0xfa2e, "\u{90de}"), (0xfa2f, "\u{96b7}"), (0xfa30, "\u{4fae}"), + (0xfa31, "\u{50e7}"), (0xfa32, "\u{514d}"), (0xfa33, "\u{52c9}"), (0xfa34, "\u{52e4}"), + (0xfa35, "\u{5351}"), (0xfa36, "\u{559d}"), (0xfa37, "\u{5606}"), (0xfa38, "\u{5668}"), + (0xfa39, "\u{5840}"), (0xfa3a, "\u{58a8}"), (0xfa3b, "\u{5c64}"), (0xfa3c, "\u{5c6e}"), + (0xfa3d, "\u{6094}"), (0xfa3e, "\u{6168}"), (0xfa3f, "\u{618e}"), (0xfa40, "\u{61f2}"), + (0xfa41, "\u{654f}"), (0xfa42, "\u{65e2}"), (0xfa43, "\u{6691}"), (0xfa44, "\u{6885}"), + (0xfa45, "\u{6d77}"), (0xfa46, "\u{6e1a}"), (0xfa47, "\u{6f22}"), (0xfa48, "\u{716e}"), + (0xfa49, "\u{722b}"), (0xfa4a, "\u{7422}"), (0xfa4b, "\u{7891}"), (0xfa4c, "\u{793e}"), + (0xfa4d, "\u{7949}"), (0xfa4e, "\u{7948}"), (0xfa4f, "\u{7950}"), (0xfa50, "\u{7956}"), + (0xfa51, "\u{795d}"), (0xfa52, "\u{798d}"), (0xfa53, "\u{798e}"), (0xfa54, "\u{7a40}"), + (0xfa55, "\u{7a81}"), (0xfa56, "\u{7bc0}"), (0xfa57, "\u{7df4}"), (0xfa58, "\u{7e09}"), + (0xfa59, "\u{7e41}"), (0xfa5a, "\u{7f72}"), (0xfa5b, "\u{8005}"), (0xfa5c, "\u{81ed}"), + (0xfa5d, "\u{8279}"), (0xfa5e, "\u{8279}"), (0xfa5f, "\u{8457}"), (0xfa60, "\u{8910}"), + (0xfa61, "\u{8996}"), (0xfa62, "\u{8b01}"), (0xfa63, "\u{8b39}"), (0xfa64, "\u{8cd3}"), + (0xfa65, "\u{8d08}"), (0xfa66, "\u{8fb6}"), (0xfa67, "\u{9038}"), (0xfa68, "\u{96e3}"), + (0xfa69, "\u{97ff}"), (0xfa6a, "\u{983b}"), (0xfa6b, "\u{6075}"), (0xfa6c, "\u{242ee}"), + (0xfa6d, "\u{8218}"), (0xfa70, "\u{4e26}"), (0xfa71, "\u{51b5}"), (0xfa72, "\u{5168}"), + (0xfa73, "\u{4f80}"), (0xfa74, "\u{5145}"), (0xfa75, "\u{5180}"), (0xfa76, "\u{52c7}"), + (0xfa77, "\u{52fa}"), (0xfa78, "\u{559d}"), (0xfa79, "\u{5555}"), (0xfa7a, "\u{5599}"), + (0xfa7b, "\u{55e2}"), (0xfa7c, "\u{585a}"), (0xfa7d, "\u{58b3}"), (0xfa7e, "\u{5944}"), + (0xfa7f, "\u{5954}"), (0xfa80, "\u{5a62}"), (0xfa81, "\u{5b28}"), (0xfa82, "\u{5ed2}"), + (0xfa83, "\u{5ed9}"), (0xfa84, "\u{5f69}"), (0xfa85, "\u{5fad}"), (0xfa86, "\u{60d8}"), + (0xfa87, "\u{614e}"), (0xfa88, "\u{6108}"), (0xfa89, "\u{618e}"), (0xfa8a, "\u{6160}"), + (0xfa8b, "\u{61f2}"), (0xfa8c, "\u{6234}"), (0xfa8d, "\u{63c4}"), (0xfa8e, "\u{641c}"), + (0xfa8f, "\u{6452}"), (0xfa90, "\u{6556}"), (0xfa91, "\u{6674}"), (0xfa92, "\u{6717}"), + (0xfa93, "\u{671b}"), (0xfa94, "\u{6756}"), (0xfa95, "\u{6b79}"), (0xfa96, "\u{6bba}"), + (0xfa97, "\u{6d41}"), (0xfa98, "\u{6edb}"), (0xfa99, "\u{6ecb}"), (0xfa9a, "\u{6f22}"), + (0xfa9b, "\u{701e}"), (0xfa9c, "\u{716e}"), (0xfa9d, "\u{77a7}"), (0xfa9e, "\u{7235}"), + (0xfa9f, "\u{72af}"), (0xfaa0, "\u{732a}"), (0xfaa1, "\u{7471}"), (0xfaa2, "\u{7506}"), + (0xfaa3, "\u{753b}"), (0xfaa4, "\u{761d}"), (0xfaa5, "\u{761f}"), (0xfaa6, "\u{76ca}"), + (0xfaa7, "\u{76db}"), (0xfaa8, "\u{76f4}"), (0xfaa9, "\u{774a}"), (0xfaaa, "\u{7740}"), + (0xfaab, "\u{78cc}"), (0xfaac, "\u{7ab1}"), (0xfaad, "\u{7bc0}"), (0xfaae, "\u{7c7b}"), + (0xfaaf, "\u{7d5b}"), (0xfab0, "\u{7df4}"), (0xfab1, "\u{7f3e}"), (0xfab2, "\u{8005}"), + (0xfab3, "\u{8352}"), (0xfab4, "\u{83ef}"), (0xfab5, "\u{8779}"), (0xfab6, "\u{8941}"), + (0xfab7, "\u{8986}"), (0xfab8, "\u{8996}"), (0xfab9, "\u{8abf}"), (0xfaba, "\u{8af8}"), + (0xfabb, "\u{8acb}"), (0xfabc, "\u{8b01}"), (0xfabd, "\u{8afe}"), (0xfabe, "\u{8aed}"), + (0xfabf, "\u{8b39}"), (0xfac0, "\u{8b8a}"), (0xfac1, "\u{8d08}"), (0xfac2, "\u{8f38}"), + (0xfac3, "\u{9072}"), (0xfac4, "\u{9199}"), (0xfac5, "\u{9276}"), (0xfac6, "\u{967c}"), + (0xfac7, "\u{96e3}"), (0xfac8, "\u{9756}"), (0xfac9, "\u{97db}"), (0xfaca, "\u{97ff}"), + (0xfacb, "\u{980b}"), (0xfacc, "\u{983b}"), (0xfacd, "\u{9b12}"), (0xface, "\u{9f9c}"), + (0xfacf, "\u{2284a}"), (0xfad0, "\u{22844}"), (0xfad1, "\u{233d5}"), (0xfad2, "\u{3b9d}"), + (0xfad3, "\u{4018}"), (0xfad4, "\u{4039}"), (0xfad5, "\u{25249}"), (0xfad6, "\u{25cd0}"), + (0xfad7, "\u{27ed3}"), (0xfad8, "\u{9f43}"), (0xfad9, "\u{9f8e}"), (0xfb1d, "\u{5d9}\u{5b4}"), + (0xfb1f, "\u{5f2}\u{5b7}"), (0xfb2a, "\u{5e9}\u{5c1}"), (0xfb2b, "\u{5e9}\u{5c2}"), (0xfb2c, "\u{5e9}\u{5bc}\u{5c1}"), + (0xfb2d, "\u{5e9}\u{5bc}\u{5c2}"), (0xfb2e, "\u{5d0}\u{5b7}"), (0xfb2f, "\u{5d0}\u{5b8}"), (0xfb30, "\u{5d0}\u{5bc}"), + (0xfb31, "\u{5d1}\u{5bc}"), (0xfb32, "\u{5d2}\u{5bc}"), (0xfb33, "\u{5d3}\u{5bc}"), (0xfb34, "\u{5d4}\u{5bc}"), + (0xfb35, "\u{5d5}\u{5bc}"), (0xfb36, "\u{5d6}\u{5bc}"), (0xfb38, "\u{5d8}\u{5bc}"), (0xfb39, "\u{5d9}\u{5bc}"), + (0xfb3a, "\u{5da}\u{5bc}"), (0xfb3b, "\u{5db}\u{5bc}"), (0xfb3c, "\u{5dc}\u{5bc}"), (0xfb3e, "\u{5de}\u{5bc}"), + (0xfb40, "\u{5e0}\u{5bc}"), (0xfb41, "\u{5e1}\u{5bc}"), (0xfb43, "\u{5e3}\u{5bc}"), (0xfb44, "\u{5e4}\u{5bc}"), + (0xfb46, "\u{5e6}\u{5bc}"), (0xfb47, "\u{5e7}\u{5bc}"), (0xfb48, "\u{5e8}\u{5bc}"), (0xfb49, "\u{5e9}\u{5bc}"), + (0xfb4a, "\u{5ea}\u{5bc}"), (0xfb4b, "\u{5d5}\u{5b9}"), (0xfb4c, "\u{5d1}\u{5bf}"), (0xfb4d, "\u{5db}\u{5bf}"), + (0xfb4e, "\u{5e4}\u{5bf}"), (0x105c9, "\u{105d2}"), (0x105e4, "\u{105da}"), (0x1109a, "\u{11099}\u{110ba}"), + (0x1109c, "\u{1109b}\u{110ba}"), (0x110ab, "\u{110a5}\u{110ba}"), (0x1112e, "\u{11131}\u{11127}"), (0x1112f, "\u{11132}\u{11127}"), + (0x1134b, "\u{11347}\u{1133e}"), (0x1134c, "\u{11347}\u{11357}"), (0x11383, "\u{11382}\u{113c9}"), (0x11385, "\u{11384}\u{113bb}"), + (0x1138e, "\u{1138b}\u{113c2}"), (0x11391, "\u{11390}\u{113c9}"), (0x113c5, "\u{113c2}\u{113c2}"), (0x113c7, "\u{113c2}\u{113b8}"), + (0x113c8, "\u{113c2}\u{113c9}"), (0x114bb, "\u{114b9}\u{114ba}"), (0x114bc, "\u{114b9}\u{114b0}"), (0x114be, "\u{114b9}\u{114bd}"), + (0x115ba, "\u{115b8}\u{115af}"), (0x115bb, "\u{115b9}\u{115af}"), (0x11938, "\u{11935}\u{11930}"), (0x16121, "\u{1611e}\u{1611e}"), + (0x16122, "\u{1611e}\u{16129}"), (0x16123, "\u{1611e}\u{1611f}"), (0x16124, "\u{16129}\u{1611f}"), (0x16125, "\u{1611e}\u{16120}"), + (0x16126, "\u{1611e}\u{1611e}\u{1611f}"), (0x16127, "\u{1611e}\u{16129}\u{1611f}"), (0x16128, "\u{1611e}\u{1611e}\u{16120}"), (0x16d68, "\u{16d67}\u{16d67}"), + (0x16d69, "\u{16d63}\u{16d67}"), (0x16d6a, "\u{16d63}\u{16d67}\u{16d67}"), (0x1d15e, "\u{1d157}\u{1d165}"), (0x1d15f, "\u{1d158}\u{1d165}"), + (0x1d160, "\u{1d158}\u{1d165}\u{1d16e}"), (0x1d161, "\u{1d158}\u{1d165}\u{1d16f}"), (0x1d162, "\u{1d158}\u{1d165}\u{1d170}"), (0x1d163, "\u{1d158}\u{1d165}\u{1d171}"), + (0x1d164, "\u{1d158}\u{1d165}\u{1d172}"), (0x1d1bb, "\u{1d1b9}\u{1d165}"), (0x1d1bc, "\u{1d1ba}\u{1d165}"), (0x1d1bd, "\u{1d1b9}\u{1d165}\u{1d16e}"), + (0x1d1be, "\u{1d1ba}\u{1d165}\u{1d16e}"), (0x1d1bf, "\u{1d1b9}\u{1d165}\u{1d16f}"), (0x1d1c0, "\u{1d1ba}\u{1d165}\u{1d16f}"), (0x2f800, "\u{4e3d}"), + (0x2f801, "\u{4e38}"), (0x2f802, "\u{4e41}"), (0x2f803, "\u{20122}"), (0x2f804, "\u{4f60}"), + (0x2f805, "\u{4fae}"), (0x2f806, "\u{4fbb}"), (0x2f807, "\u{5002}"), (0x2f808, "\u{507a}"), + (0x2f809, "\u{5099}"), (0x2f80a, "\u{50e7}"), (0x2f80b, "\u{50cf}"), (0x2f80c, "\u{349e}"), + (0x2f80d, "\u{2063a}"), (0x2f80e, "\u{514d}"), (0x2f80f, "\u{5154}"), (0x2f810, "\u{5164}"), + (0x2f811, "\u{5177}"), (0x2f812, "\u{2051c}"), (0x2f813, "\u{34b9}"), (0x2f814, "\u{5167}"), + (0x2f815, "\u{518d}"), (0x2f816, "\u{2054b}"), (0x2f817, "\u{5197}"), (0x2f818, "\u{51a4}"), + (0x2f819, "\u{4ecc}"), (0x2f81a, "\u{51ac}"), (0x2f81b, "\u{51b5}"), (0x2f81c, "\u{291df}"), + (0x2f81d, "\u{51f5}"), (0x2f81e, "\u{5203}"), (0x2f81f, "\u{34df}"), (0x2f820, "\u{523b}"), + (0x2f821, "\u{5246}"), (0x2f822, "\u{5272}"), (0x2f823, "\u{5277}"), (0x2f824, "\u{3515}"), + (0x2f825, "\u{52c7}"), (0x2f826, "\u{52c9}"), (0x2f827, "\u{52e4}"), (0x2f828, "\u{52fa}"), + (0x2f829, "\u{5305}"), (0x2f82a, "\u{5306}"), (0x2f82b, "\u{5317}"), (0x2f82c, "\u{5349}"), + (0x2f82d, "\u{5351}"), (0x2f82e, "\u{535a}"), (0x2f82f, "\u{5373}"), (0x2f830, "\u{537d}"), + (0x2f831, "\u{537f}"), (0x2f832, "\u{537f}"), (0x2f833, "\u{537f}"), (0x2f834, "\u{20a2c}"), + (0x2f835, "\u{7070}"), (0x2f836, "\u{53ca}"), (0x2f837, "\u{53df}"), (0x2f838, "\u{20b63}"), + (0x2f839, "\u{53eb}"), (0x2f83a, "\u{53f1}"), (0x2f83b, "\u{5406}"), (0x2f83c, "\u{549e}"), + (0x2f83d, "\u{5438}"), (0x2f83e, "\u{5448}"), (0x2f83f, "\u{5468}"), (0x2f840, "\u{54a2}"), + (0x2f841, "\u{54f6}"), (0x2f842, "\u{5510}"), (0x2f843, "\u{5553}"), (0x2f844, "\u{5563}"), + (0x2f845, "\u{5584}"), (0x2f846, "\u{5584}"), (0x2f847, "\u{5599}"), (0x2f848, "\u{55ab}"), + (0x2f849, "\u{55b3}"), (0x2f84a, "\u{55c2}"), (0x2f84b, "\u{5716}"), (0x2f84c, "\u{5606}"), + (0x2f84d, "\u{5717}"), (0x2f84e, "\u{5651}"), (0x2f84f, "\u{5674}"), (0x2f850, "\u{5207}"), + (0x2f851, "\u{58ee}"), (0x2f852, "\u{57ce}"), (0x2f853, "\u{57f4}"), (0x2f854, "\u{580d}"), + (0x2f855, "\u{578b}"), (0x2f856, "\u{5832}"), (0x2f857, "\u{5831}"), (0x2f858, "\u{58ac}"), + (0x2f859, "\u{214e4}"), (0x2f85a, "\u{58f2}"), (0x2f85b, "\u{58f7}"), (0x2f85c, "\u{5906}"), + (0x2f85d, "\u{591a}"), (0x2f85e, "\u{5922}"), (0x2f85f, "\u{5962}"), (0x2f860, "\u{216a8}"), + (0x2f861, "\u{216ea}"), (0x2f862, "\u{59ec}"), (0x2f863, "\u{5a1b}"), (0x2f864, "\u{5a27}"), + (0x2f865, "\u{59d8}"), (0x2f866, "\u{5a66}"), (0x2f867, "\u{36ee}"), (0x2f868, "\u{36fc}"), + (0x2f869, "\u{5b08}"), (0x2f86a, "\u{5b3e}"), (0x2f86b, "\u{5b3e}"), (0x2f86c, "\u{219c8}"), + (0x2f86d, "\u{5bc3}"), (0x2f86e, "\u{5bd8}"), (0x2f86f, "\u{5be7}"), (0x2f870, "\u{5bf3}"), + (0x2f871, "\u{21b18}"), (0x2f872, "\u{5bff}"), (0x2f873, "\u{5c06}"), (0x2f874, "\u{5f53}"), + (0x2f875, "\u{5c22}"), (0x2f876, "\u{3781}"), (0x2f877, "\u{5c60}"), (0x2f878, "\u{5c6e}"), + (0x2f879, "\u{5cc0}"), (0x2f87a, "\u{5c8d}"), (0x2f87b, "\u{21de4}"), (0x2f87c, "\u{5d43}"), + (0x2f87d, "\u{21de6}"), (0x2f87e, "\u{5d6e}"), (0x2f87f, "\u{5d6b}"), (0x2f880, "\u{5d7c}"), + (0x2f881, "\u{5de1}"), (0x2f882, "\u{5de2}"), (0x2f883, "\u{382f}"), (0x2f884, "\u{5dfd}"), + (0x2f885, "\u{5e28}"), (0x2f886, "\u{5e3d}"), (0x2f887, "\u{5e69}"), (0x2f888, "\u{3862}"), + (0x2f889, "\u{22183}"), (0x2f88a, "\u{387c}"), (0x2f88b, "\u{5eb0}"), (0x2f88c, "\u{5eb3}"), + (0x2f88d, "\u{5eb6}"), (0x2f88e, "\u{5eca}"), (0x2f88f, "\u{2a392}"), (0x2f890, "\u{5efe}"), + (0x2f891, "\u{22331}"), (0x2f892, "\u{22331}"), (0x2f893, "\u{8201}"), (0x2f894, "\u{5f22}"), + (0x2f895, "\u{5f22}"), (0x2f896, "\u{38c7}"), (0x2f897, "\u{232b8}"), (0x2f898, "\u{261da}"), + (0x2f899, "\u{5f62}"), (0x2f89a, "\u{5f6b}"), (0x2f89b, "\u{38e3}"), (0x2f89c, "\u{5f9a}"), + (0x2f89d, "\u{5fcd}"), (0x2f89e, "\u{5fd7}"), (0x2f89f, "\u{5ff9}"), (0x2f8a0, "\u{6081}"), + (0x2f8a1, "\u{393a}"), (0x2f8a2, "\u{391c}"), (0x2f8a3, "\u{6094}"), (0x2f8a4, "\u{226d4}"), + (0x2f8a5, "\u{60c7}"), (0x2f8a6, "\u{6148}"), (0x2f8a7, "\u{614c}"), (0x2f8a8, "\u{614e}"), + (0x2f8a9, "\u{614c}"), (0x2f8aa, "\u{617a}"), (0x2f8ab, "\u{618e}"), (0x2f8ac, "\u{61b2}"), + (0x2f8ad, "\u{61a4}"), (0x2f8ae, "\u{61af}"), (0x2f8af, "\u{61de}"), (0x2f8b0, "\u{61f2}"), + (0x2f8b1, "\u{61f6}"), (0x2f8b2, "\u{6210}"), (0x2f8b3, "\u{621b}"), (0x2f8b4, "\u{625d}"), + (0x2f8b5, "\u{62b1}"), (0x2f8b6, "\u{62d4}"), (0x2f8b7, "\u{6350}"), (0x2f8b8, "\u{22b0c}"), + (0x2f8b9, "\u{633d}"), (0x2f8ba, "\u{62fc}"), (0x2f8bb, "\u{6368}"), (0x2f8bc, "\u{6383}"), + (0x2f8bd, "\u{63e4}"), (0x2f8be, "\u{22bf1}"), (0x2f8bf, "\u{6422}"), (0x2f8c0, "\u{63c5}"), + (0x2f8c1, "\u{63a9}"), (0x2f8c2, "\u{3a2e}"), (0x2f8c3, "\u{6469}"), (0x2f8c4, "\u{647e}"), + (0x2f8c5, "\u{649d}"), (0x2f8c6, "\u{6477}"), (0x2f8c7, "\u{3a6c}"), (0x2f8c8, "\u{654f}"), + (0x2f8c9, "\u{656c}"), (0x2f8ca, "\u{2300a}"), (0x2f8cb, "\u{65e3}"), (0x2f8cc, "\u{66f8}"), + (0x2f8cd, "\u{6649}"), (0x2f8ce, "\u{3b19}"), (0x2f8cf, "\u{6691}"), (0x2f8d0, "\u{3b08}"), + (0x2f8d1, "\u{3ae4}"), (0x2f8d2, "\u{5192}"), (0x2f8d3, "\u{5195}"), (0x2f8d4, "\u{6700}"), + (0x2f8d5, "\u{669c}"), (0x2f8d6, "\u{80ad}"), (0x2f8d7, "\u{43d9}"), (0x2f8d8, "\u{6717}"), + (0x2f8d9, "\u{671b}"), (0x2f8da, "\u{6721}"), (0x2f8db, "\u{675e}"), (0x2f8dc, "\u{6753}"), + (0x2f8dd, "\u{233c3}"), (0x2f8de, "\u{3b49}"), (0x2f8df, "\u{67fa}"), (0x2f8e0, "\u{6785}"), + (0x2f8e1, "\u{6852}"), (0x2f8e2, "\u{6885}"), (0x2f8e3, "\u{2346d}"), (0x2f8e4, "\u{688e}"), + (0x2f8e5, "\u{681f}"), (0x2f8e6, "\u{6914}"), (0x2f8e7, "\u{3b9d}"), (0x2f8e8, "\u{6942}"), + (0x2f8e9, "\u{69a3}"), (0x2f8ea, "\u{69ea}"), (0x2f8eb, "\u{6aa8}"), (0x2f8ec, "\u{236a3}"), + (0x2f8ed, "\u{6adb}"), (0x2f8ee, "\u{3c18}"), (0x2f8ef, "\u{6b21}"), (0x2f8f0, "\u{238a7}"), + (0x2f8f1, "\u{6b54}"), (0x2f8f2, "\u{3c4e}"), (0x2f8f3, "\u{6b72}"), (0x2f8f4, "\u{6b9f}"), + (0x2f8f5, "\u{6bba}"), (0x2f8f6, "\u{6bbb}"), (0x2f8f7, "\u{23a8d}"), (0x2f8f8, "\u{21d0b}"), + (0x2f8f9, "\u{23afa}"), (0x2f8fa, "\u{6c4e}"), (0x2f8fb, "\u{23cbc}"), (0x2f8fc, "\u{6cbf}"), + (0x2f8fd, "\u{6ccd}"), (0x2f8fe, "\u{6c67}"), (0x2f8ff, "\u{6d16}"), (0x2f900, "\u{6d3e}"), + (0x2f901, "\u{6d77}"), (0x2f902, "\u{6d41}"), (0x2f903, "\u{6d69}"), (0x2f904, "\u{6d78}"), + (0x2f905, "\u{6d85}"), (0x2f906, "\u{23d1e}"), (0x2f907, "\u{6d34}"), (0x2f908, "\u{6e2f}"), + (0x2f909, "\u{6e6e}"), (0x2f90a, "\u{3d33}"), (0x2f90b, "\u{6ecb}"), (0x2f90c, "\u{6ec7}"), + (0x2f90d, "\u{23ed1}"), (0x2f90e, "\u{6df9}"), (0x2f90f, "\u{6f6e}"), (0x2f910, "\u{23f5e}"), + (0x2f911, "\u{23f8e}"), (0x2f912, "\u{6fc6}"), (0x2f913, "\u{7039}"), (0x2f914, "\u{701e}"), + (0x2f915, "\u{701b}"), (0x2f916, "\u{3d96}"), (0x2f917, "\u{704a}"), (0x2f918, "\u{707d}"), + (0x2f919, "\u{7077}"), (0x2f91a, "\u{70ad}"), (0x2f91b, "\u{20525}"), (0x2f91c, "\u{7145}"), + (0x2f91d, "\u{24263}"), (0x2f91e, "\u{719c}"), (0x2f91f, "\u{243ab}"), (0x2f920, "\u{7228}"), + (0x2f921, "\u{7235}"), (0x2f922, "\u{7250}"), (0x2f923, "\u{24608}"), (0x2f924, "\u{7280}"), + (0x2f925, "\u{7295}"), (0x2f926, "\u{24735}"), (0x2f927, "\u{24814}"), (0x2f928, "\u{737a}"), + (0x2f929, "\u{738b}"), (0x2f92a, "\u{3eac}"), (0x2f92b, "\u{73a5}"), (0x2f92c, "\u{3eb8}"), + (0x2f92d, "\u{3eb8}"), (0x2f92e, "\u{7447}"), (0x2f92f, "\u{745c}"), (0x2f930, "\u{7471}"), + (0x2f931, "\u{7485}"), (0x2f932, "\u{74ca}"), (0x2f933, "\u{3f1b}"), (0x2f934, "\u{7524}"), + (0x2f935, "\u{24c36}"), (0x2f936, "\u{753e}"), (0x2f937, "\u{24c92}"), (0x2f938, "\u{7570}"), + (0x2f939, "\u{2219f}"), (0x2f93a, "\u{7610}"), (0x2f93b, "\u{24fa1}"), (0x2f93c, "\u{24fb8}"), + (0x2f93d, "\u{25044}"), (0x2f93e, "\u{3ffc}"), (0x2f93f, "\u{4008}"), (0x2f940, "\u{76f4}"), + (0x2f941, "\u{250f3}"), (0x2f942, "\u{250f2}"), (0x2f943, "\u{25119}"), (0x2f944, "\u{25133}"), + (0x2f945, "\u{771e}"), (0x2f946, "\u{771f}"), (0x2f947, "\u{771f}"), (0x2f948, "\u{774a}"), + (0x2f949, "\u{4039}"), (0x2f94a, "\u{778b}"), (0x2f94b, "\u{4046}"), (0x2f94c, "\u{4096}"), + (0x2f94d, "\u{2541d}"), (0x2f94e, "\u{784e}"), (0x2f94f, "\u{788c}"), (0x2f950, "\u{78cc}"), + (0x2f951, "\u{40e3}"), (0x2f952, "\u{25626}"), (0x2f953, "\u{7956}"), (0x2f954, "\u{2569a}"), + (0x2f955, "\u{256c5}"), (0x2f956, "\u{798f}"), (0x2f957, "\u{79eb}"), (0x2f958, "\u{412f}"), + (0x2f959, "\u{7a40}"), (0x2f95a, "\u{7a4a}"), (0x2f95b, "\u{7a4f}"), (0x2f95c, "\u{2597c}"), + (0x2f95d, "\u{25aa7}"), (0x2f95e, "\u{25aa7}"), (0x2f95f, "\u{7aee}"), (0x2f960, "\u{4202}"), + (0x2f961, "\u{25bab}"), (0x2f962, "\u{7bc6}"), (0x2f963, "\u{7bc9}"), (0x2f964, "\u{4227}"), + (0x2f965, "\u{25c80}"), (0x2f966, "\u{7cd2}"), (0x2f967, "\u{42a0}"), (0x2f968, "\u{7ce8}"), + (0x2f969, "\u{7ce3}"), (0x2f96a, "\u{7d00}"), (0x2f96b, "\u{25f86}"), (0x2f96c, "\u{7d63}"), + (0x2f96d, "\u{4301}"), (0x2f96e, "\u{7dc7}"), (0x2f96f, "\u{7e02}"), (0x2f970, "\u{7e45}"), + (0x2f971, "\u{4334}"), (0x2f972, "\u{26228}"), (0x2f973, "\u{26247}"), (0x2f974, "\u{4359}"), + (0x2f975, "\u{262d9}"), (0x2f976, "\u{7f7a}"), (0x2f977, "\u{2633e}"), (0x2f978, "\u{7f95}"), + (0x2f979, "\u{7ffa}"), (0x2f97a, "\u{8005}"), (0x2f97b, "\u{264da}"), (0x2f97c, "\u{26523}"), + (0x2f97d, "\u{8060}"), (0x2f97e, "\u{265a8}"), (0x2f97f, "\u{8070}"), (0x2f980, "\u{2335f}"), + (0x2f981, "\u{43d5}"), (0x2f982, "\u{80b2}"), (0x2f983, "\u{8103}"), (0x2f984, "\u{440b}"), + (0x2f985, "\u{813e}"), (0x2f986, "\u{5ab5}"), (0x2f987, "\u{267a7}"), (0x2f988, "\u{267b5}"), + (0x2f989, "\u{23393}"), (0x2f98a, "\u{2339c}"), (0x2f98b, "\u{8201}"), (0x2f98c, "\u{8204}"), + (0x2f98d, "\u{8f9e}"), (0x2f98e, "\u{446b}"), (0x2f98f, "\u{8291}"), (0x2f990, "\u{828b}"), + (0x2f991, "\u{829d}"), (0x2f992, "\u{52b3}"), (0x2f993, "\u{82b1}"), (0x2f994, "\u{82b3}"), + (0x2f995, "\u{82bd}"), (0x2f996, "\u{82e6}"), (0x2f997, "\u{26b3c}"), (0x2f998, "\u{82e5}"), + (0x2f999, "\u{831d}"), (0x2f99a, "\u{8363}"), (0x2f99b, "\u{83ad}"), (0x2f99c, "\u{8323}"), + (0x2f99d, "\u{83bd}"), (0x2f99e, "\u{83e7}"), (0x2f99f, "\u{8457}"), (0x2f9a0, "\u{8353}"), + (0x2f9a1, "\u{83ca}"), (0x2f9a2, "\u{83cc}"), (0x2f9a3, "\u{83dc}"), (0x2f9a4, "\u{26c36}"), + (0x2f9a5, "\u{26d6b}"), (0x2f9a6, "\u{26cd5}"), (0x2f9a7, "\u{452b}"), (0x2f9a8, "\u{84f1}"), + (0x2f9a9, "\u{84f3}"), (0x2f9aa, "\u{8516}"), (0x2f9ab, "\u{273ca}"), (0x2f9ac, "\u{8564}"), + (0x2f9ad, "\u{26f2c}"), (0x2f9ae, "\u{455d}"), (0x2f9af, "\u{4561}"), (0x2f9b0, "\u{26fb1}"), + (0x2f9b1, "\u{270d2}"), (0x2f9b2, "\u{456b}"), (0x2f9b3, "\u{8650}"), (0x2f9b4, "\u{865c}"), + (0x2f9b5, "\u{8667}"), (0x2f9b6, "\u{8669}"), (0x2f9b7, "\u{86a9}"), (0x2f9b8, "\u{8688}"), + (0x2f9b9, "\u{870e}"), (0x2f9ba, "\u{86e2}"), (0x2f9bb, "\u{8779}"), (0x2f9bc, "\u{8728}"), + (0x2f9bd, "\u{876b}"), (0x2f9be, "\u{8786}"), (0x2f9bf, "\u{45d7}"), (0x2f9c0, "\u{87e1}"), + (0x2f9c1, "\u{8801}"), (0x2f9c2, "\u{45f9}"), (0x2f9c3, "\u{8860}"), (0x2f9c4, "\u{8863}"), + (0x2f9c5, "\u{27667}"), (0x2f9c6, "\u{88d7}"), (0x2f9c7, "\u{88de}"), (0x2f9c8, "\u{4635}"), + (0x2f9c9, "\u{88fa}"), (0x2f9ca, "\u{34bb}"), (0x2f9cb, "\u{278ae}"), (0x2f9cc, "\u{27966}"), + (0x2f9cd, "\u{46be}"), (0x2f9ce, "\u{46c7}"), (0x2f9cf, "\u{8aa0}"), (0x2f9d0, "\u{8aed}"), + (0x2f9d1, "\u{8b8a}"), (0x2f9d2, "\u{8c55}"), (0x2f9d3, "\u{27ca8}"), (0x2f9d4, "\u{8cab}"), + (0x2f9d5, "\u{8cc1}"), (0x2f9d6, "\u{8d1b}"), (0x2f9d7, "\u{8d77}"), (0x2f9d8, "\u{27f2f}"), + (0x2f9d9, "\u{20804}"), (0x2f9da, "\u{8dcb}"), (0x2f9db, "\u{8dbc}"), (0x2f9dc, "\u{8df0}"), + (0x2f9dd, "\u{208de}"), (0x2f9de, "\u{8ed4}"), (0x2f9df, "\u{8f38}"), (0x2f9e0, "\u{285d2}"), + (0x2f9e1, "\u{285ed}"), (0x2f9e2, "\u{9094}"), (0x2f9e3, "\u{90f1}"), (0x2f9e4, "\u{9111}"), + (0x2f9e5, "\u{2872e}"), (0x2f9e6, "\u{911b}"), (0x2f9e7, "\u{9238}"), (0x2f9e8, "\u{92d7}"), + (0x2f9e9, "\u{92d8}"), (0x2f9ea, "\u{927c}"), (0x2f9eb, "\u{93f9}"), (0x2f9ec, "\u{9415}"), + (0x2f9ed, "\u{28bfa}"), (0x2f9ee, "\u{958b}"), (0x2f9ef, "\u{4995}"), (0x2f9f0, "\u{95b7}"), + (0x2f9f1, "\u{28d77}"), (0x2f9f2, "\u{49e6}"), (0x2f9f3, "\u{96c3}"), (0x2f9f4, "\u{5db2}"), + (0x2f9f5, "\u{9723}"), (0x2f9f6, "\u{29145}"), (0x2f9f7, "\u{2921a}"), (0x2f9f8, "\u{4a6e}"), + (0x2f9f9, "\u{4a76}"), (0x2f9fa, "\u{97e0}"), (0x2f9fb, "\u{2940a}"), (0x2f9fc, "\u{4ab2}"), + (0x2f9fd, "\u{29496}"), (0x2f9fe, "\u{980b}"), (0x2f9ff, "\u{980b}"), (0x2fa00, "\u{9829}"), + (0x2fa01, "\u{295b6}"), (0x2fa02, "\u{98e2}"), (0x2fa03, "\u{4b33}"), (0x2fa04, "\u{9929}"), + (0x2fa05, "\u{99a7}"), (0x2fa06, "\u{99c2}"), (0x2fa07, "\u{99fe}"), (0x2fa08, "\u{4bce}"), + (0x2fa09, "\u{29b30}"), (0x2fa0a, "\u{9b12}"), (0x2fa0b, "\u{9c40}"), (0x2fa0c, "\u{9cfd}"), + (0x2fa0d, "\u{4cce}"), (0x2fa0e, "\u{4ced}"), (0x2fa0f, "\u{9d67}"), (0x2fa10, "\u{2a0ce}"), + (0x2fa11, "\u{4cf8}"), (0x2fa12, "\u{2a105}"), (0x2fa13, "\u{2a20e}"), (0x2fa14, "\u{2a291}"), + (0x2fa15, "\u{9ebb}"), (0x2fa16, "\u{4d56}"), (0x2fa17, "\u{9ef9}"), (0x2fa18, "\u{9efe}"), + (0x2fa19, "\u{9f05}"), (0x2fa1a, "\u{9f0f}"), (0x2fa1b, "\u{9f16}"), (0x2fa1c, "\u{9f3b}"), + (0x2fa1d, "\u{2a600}"), +]; diff --git a/crates/accshift-app/src/display/mod.rs b/crates/accshift-app/src/display/mod.rs new file mode 100644 index 00000000..b2648ea4 --- /dev/null +++ b/crates/accshift-app/src/display/mod.rs @@ -0,0 +1,518 @@ +//! What the grid shows, ported from `src/lib/app/useDisplayPipeline.svelte.ts` +//! and `src/lib/app/folderNavigation.svelte.ts`. +//! +//! - [`FolderNavigation`]: the open folder, its items and the raw search +//! query (`createFolderNavigation`). +//! - [`DisplayPipeline`]: the debounced search, the collapsed sections and +//! [`DisplayPipeline::compute`], which turns the folder store, the loaded +//! accounts and the drag preview into [`DisplayLists`] (the TS deriveds). +//! - [`build_display_view`]: the lists plus every card's data as one flat +//! [`DisplayView`], so the grid only lays out and draws. That part is the +//! `{#if}` and `{#each}` logic of `AppWorkspace.svelte`. +//! +//! The Svelte deriveds become one explicit `compute` call. The 80 ms search +//! debounce is a deadline the UI checks each frame ([`DisplayPipeline::tick`]). +//! There is no sorting step: the order is the folder store's `itemOrder`, as +//! in TS, where `syncAccounts` appends new accounts at the end. + +mod fold_table; +mod view; + +pub use view::{ + build_display_view, build_mock_view, AccountCardItem, AvatarView, CardContext, DisplayBody, + DisplayItem, DisplayView, FolderCardItem, SectionHeaderItem, SectionLabel, +}; + +use crate::folders::{FolderInfo, FolderSection, ItemKind, ItemRef}; +use crate::platforms::PlatformAccount; +use crate::stores::ClientStores; +use std::collections::{BTreeSet, HashSet}; +use std::time::{Duration, Instant}; + +/// `SEARCH_DEBOUNCE_MS`. +pub const SEARCH_DEBOUNCE: Duration = Duration::from_millis(80); + +/// `ROOT_SECTION_KEY`. +pub const ROOT_SECTION_KEY: &str = "__root__"; + +// ------------------------------------------------------------ search + +fn fold_char(c: char, out: &mut String) { + let cp = c as u32; + if (0x300..=0x36f).contains(&cp) { + return; + } + // Hangul syllables decompose by rule (Unicode 3.12), never into a mark + // of the stripped range. + if (0xac00..=0xd7a3).contains(&cp) { + let index = cp - 0xac00; + let lead = 0x1100 + index / (21 * 28); + let vowel = 0x1161 + (index % (21 * 28)) / 28; + let trail = index % 28; + out.extend(char::from_u32(lead)); + out.extend(char::from_u32(vowel)); + if trail != 0 { + out.extend(char::from_u32(0x11a7 + trail)); + } + return; + } + match fold_table::FOLD_TABLE.binary_search_by_key(&cp, |(key, _)| *key) { + Ok(index) => out.push_str(fold_table::FOLD_TABLE[index].1), + Err(_) => out.push(c), + } +} + +/// `foldSearchText`: accent and case insensitive comparison base (NFD, the +/// combining diacritics U+0300..U+036F removed, lowercased). +/// +/// The NFD step is a per character table generated from Node's +/// `normalize("NFD")`; canonical reordering of the marks that remain (outside +/// U+0300..U+036F) across characters is not applied. +pub fn fold_search_text(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for c in value.chars() { + fold_char(c, &mut out); + } + out.to_lowercase() +} + +/// `matchesSearch`: the id, username, display name or card note contains the +/// query, folded. +pub fn matches_search(account: &PlatformAccount, query: &str, note: &str) -> bool { + let q = fold_search_text(query); + fold_search_text(&account.id).contains(&q) + || fold_search_text(&account.username).contains(&q) + || fold_search_text(&account.display_name).contains(&q) + || fold_search_text(note).contains(&q) +} + +/// `findMatchingFolderItems`: walks the platform's folder tree breadth first +/// and keeps the folders whose name matches. +pub fn find_matching_folder_items( + stores: &ClientStores, + platform: &str, + folded_query: &str, +) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + let mut queue: Vec = stores + .items_in_folder(None, platform) + .iter() + .filter(|item| item.kind == ItemKind::Folder) + .cloned() + .collect(); + let mut i = 0; + while i < queue.len() { + let id = queue[i].id.clone(); + i += 1; + if !seen.insert(id.clone()) { + continue; + } + let Some(folder) = stores.folders().folder(&id) else { + continue; + }; + for child in stores.items_in_folder(Some(&folder.id), platform) { + if child.kind == ItemKind::Folder { + queue.push(child.clone()); + } + } + if fold_search_text(&folder.name).contains(folded_query) { + out.push(ItemRef::folder(folder.id.clone())); + } + } + out +} + +// ------------------------------------------------------------ sections + +/// `sectionCollapseKey`: the folder id, or [`ROOT_SECTION_KEY`]. +pub fn section_collapse_key(section: &FolderSection) -> &str { + section + .folder + .as_ref() + .map_or(ROOT_SECTION_KEY, |folder| folder.id.as_str()) +} + +/// `collectVisibleAccountIds`: account ids on screen in display order. A +/// collapsed section shows only its header, so its accounts are left out. +pub fn collect_visible_account_ids( + sections: Option<&[FolderSection]>, + flat_items: &[ItemRef], + collapsed: &BTreeSet, +) -> Vec { + let mut ids = Vec::new(); + let mut seen = HashSet::new(); + let mut push = |items: &[ItemRef]| { + for item in items { + if item.kind != ItemKind::Account || !seen.insert(item.id.clone()) { + continue; + } + ids.push(item.id.clone()); + } + }; + match sections { + Some(sections) => { + for section in sections { + if !collapsed.contains(section_collapse_key(section)) { + push(§ion.account_items); + } + } + } + None => push(flat_items), + } + ids +} + +// ------------------------------------------------------------ navigation + +/// `createFolderNavigation`. +#[derive(Debug, Clone, Default)] +pub struct FolderNavigation { + current_folder_id: Option, + current_items: Vec, + search_query: String, + /// Bumped by each [`FolderNavigation::refresh_current_items`]: what the + /// TS deriveds subscribe to through `void navigation.currentItems`. + items_version: u64, +} + +impl FolderNavigation { + pub fn new() -> Self { + Self::default() + } + + pub fn current_folder_id(&self) -> Option<&str> { + self.current_folder_id.as_deref() + } + + /// Does not refresh the items; call + /// [`FolderNavigation::refresh_current_items`] after, as TS callers do. + pub fn set_current_folder_id(&mut self, folder_id: Option) { + self.current_folder_id = folder_id; + } + + pub fn current_items(&self) -> &[ItemRef] { + &self.current_items + } + + pub fn set_current_items(&mut self, items: Vec) { + self.current_items = items; + self.items_version += 1; + } + + pub fn items_version(&self) -> u64 { + self.items_version + } + + /// `folderPath`: the breadcrumb, root first. + pub fn folder_path<'a>(&self, stores: &'a ClientStores) -> Vec<&'a FolderInfo> { + stores + .folders() + .folder_path(self.current_folder_id.as_deref()) + } + + /// `folderItems`. + pub fn folder_items(&self) -> Vec { + self.items_of(ItemKind::Folder) + } + + /// `accountItems`. + pub fn account_items(&self) -> Vec { + self.items_of(ItemKind::Account) + } + + fn items_of(&self, kind: ItemKind) -> Vec { + self.current_items + .iter() + .filter(|item| item.kind == kind) + .cloned() + .collect() + } + + pub fn search_query(&self) -> &str { + &self.search_query + } + + /// Hand the same value to [`DisplayPipeline::set_query`]. + pub fn set_search_query(&mut self, query: impl Into) { + self.search_query = query.into(); + } + + /// `isSearching`: the raw query has a non-blank character. + pub fn is_searching(&self) -> bool { + !crate::js::trim(&self.search_query).is_empty() + } + + /// `refreshCurrentItems`. + pub fn refresh_current_items(&mut self, stores: &ClientStores, active_tab: &str) { + let items = stores + .items_in_folder(self.current_folder_id.as_deref(), active_tab) + .to_vec(); + self.set_current_items(items); + } +} + +// ------------------------------------------------------------ pipeline + +/// `drag`: the drag in progress, if any. +#[derive(Debug, Clone, Copy, Default)] +pub struct DragPreview<'a> { + pub is_dragging: bool, + pub item: Option<&'a ItemRef>, + pub preview_index: Option, +} + +impl DragPreview<'_> { + /// The dragged item of `kind` and its preview slot, when the order has to + /// move it. + fn moving(&self, kind: ItemKind) -> Option<(&ItemRef, usize)> { + if !self.is_dragging { + return None; + } + let item = self.item.filter(|item| item.kind == kind)?; + Some((item, self.preview_index?)) + } +} + +/// What [`DisplayPipeline::compute`] reads. +#[derive(Debug, Clone, Copy)] +pub struct PipelineInput<'a> { + pub navigation: &'a FolderNavigation, + pub drag: DragPreview<'a>, + /// `loader.accounts`. + pub accounts: &'a [PlatformAccount], + /// `addFlow.pendingSetupAccount`. + pub pending_setup_account: Option<&'a PlatformAccount>, + /// `settings.accountDisplay.expandedFolders`. + pub expanded_folders: bool, + pub active_tab: &'a str, + /// Folders and card notes. + pub stores: &'a ClientStores, +} + +/// The TS deriveds of one pass. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct DisplayLists { + /// `displayFolderItems`. + pub folder_items: Vec, + /// `displayAccountItemsWithPending`. + pub account_items: Vec, + /// `displaySections`: `Some` in expanded (sections) mode. + pub sections: Option>, + /// `visibleRenderedAccountIds`. + pub visible_account_ids: Vec, + /// `renderedAccountCount`. + pub rendered_account_count: usize, + /// `isSearchLayout`: the debounced query is not blank. + pub search_layout: bool, +} + +impl DisplayLists { + /// `renderedItemCount` of `AppWorkspace.svelte`. + pub fn rendered_item_count(&self) -> usize { + match &self.sections { + Some(sections) => sections + .iter() + .map(|section| section.folder_items.len() + section.account_items.len()) + .sum(), + None => self.folder_items.len() + self.account_items.len(), + } + } +} + +/// `createDisplayPipeline`: the state the deriveds keep between passes. +#[derive(Debug, Clone, Default)] +pub struct DisplayPipeline { + debounced_query: String, + /// The raw query and when it lands in `debounced_query`. + pending_query: Option<(String, Instant)>, + collapsed: BTreeSet, + last_visible_ids: Vec, +} + +fn move_to_preview(items: &[ItemRef], moving: Option<(&ItemRef, usize)>) -> Vec { + let Some((dragged, index)) = moving else { + return items.to_vec(); + }; + let mut out: Vec = items + .iter() + .filter(|item| item.id != dragged.id) + .cloned() + .collect(); + let at = index.min(out.len()); + out.insert(at, dragged.clone()); + out +} + +impl DisplayPipeline { + pub fn new() -> Self { + Self::default() + } + + /// The search `$effect`: a blank query applies at once, any other after + /// [`SEARCH_DEBOUNCE`], each new value restarting the wait. + pub fn set_query(&mut self, raw: &str, now: Instant) { + if crate::js::trim(raw).is_empty() { + self.debounced_query.clear(); + self.pending_query = None; + return; + } + self.pending_query = Some((raw.to_string(), now + SEARCH_DEBOUNCE)); + } + + /// Applies the pending query once its deadline passed. True when the + /// debounced query changed, so the caller recomputes. + pub fn tick(&mut self, now: Instant) -> bool { + if self + .pending_query + .as_ref() + .is_none_or(|(_, due)| *due > now) + { + return false; + } + let Some((query, _)) = self.pending_query.take() else { + return false; + }; + let changed = query != self.debounced_query; + self.debounced_query = query; + changed + } + + /// When to call [`DisplayPipeline::tick`] next (a repaint deadline). + pub fn next_deadline(&self) -> Option { + self.pending_query.as_ref().map(|(_, due)| *due) + } + + pub fn debounced_query(&self) -> &str { + &self.debounced_query + } + + /// `isSearchLayout`. + pub fn is_search_layout(&self) -> bool { + !crate::js::trim(&self.debounced_query).is_empty() + } + + /// `collapsedSections`. + pub fn collapsed_sections(&self) -> &BTreeSet { + &self.collapsed + } + + pub fn is_section_collapsed(&self, section: &FolderSection) -> bool { + self.collapsed.contains(section_collapse_key(section)) + } + + pub fn toggle_section_collapsed(&mut self, key: &str) { + if !self.collapsed.remove(key) { + self.collapsed.insert(key.to_string()); + } + } + + pub fn clear_collapsed_sections(&mut self) { + self.collapsed.clear(); + } + + /// Every derived of `createDisplayPipeline` for the current inputs. + pub fn compute(&mut self, input: &PipelineInput<'_>) -> DisplayLists { + let search_layout = self.is_search_layout(); + let expanded_mode = input.expanded_folders + && input.navigation.current_folder_id().is_none() + && !search_layout; + let raw_sections = expanded_mode.then(|| input.stores.root_sections(input.active_tab)); + + let folder_items = if raw_sections.is_some() { + Vec::new() + } else if search_layout { + let q = crate::js::trim(&self.debounced_query); + if q.is_empty() { + Vec::new() + } else { + find_matching_folder_items(input.stores, input.active_tab, &fold_search_text(q)) + } + } else { + move_to_preview( + &input.navigation.folder_items(), + input.drag.moving(ItemKind::Folder), + ) + }; + + // `filteredAccountItems`: the search flattens folders, so it walks + // every loaded account rather than the open folder. + let q = crate::js::trim(&self.debounced_query).to_lowercase(); + let filtered = if q.is_empty() { + input.navigation.account_items() + } else { + input + .accounts + .iter() + .filter(|account| { + matches_search(account, &q, input.stores.account_card_note(&account.id)) + }) + .map(|account| ItemRef::account(account.id.clone())) + .collect() + }; + + let display_accounts = if raw_sections.is_some() { + Vec::new() + } else if search_layout { + filtered + } else { + move_to_preview(&filtered, input.drag.moving(ItemKind::Account)) + }; + + let pending = input.pending_setup_account; + let mut account_items = display_accounts; + if let Some(pending) = pending { + if !account_items + .iter() + .any(|item| item.kind == ItemKind::Account && item.id == pending.id) + { + account_items.push(ItemRef::account(pending.id.clone())); + } + } + + let sections = raw_sections.map(|mut sections| { + let Some(pending) = pending else { + return sections; + }; + let placed = sections.iter().any(|section| { + section + .account_items + .iter() + .any(|item| item.id == pending.id) + }); + if !placed { + if let Some(root) = sections.iter_mut().find(|section| section.folder.is_none()) { + root.account_items + .push(ItemRef::account(pending.id.clone())); + } + } + sections + }); + + // A drag only churns the preview order, never the visible set: keep + // the previous list while one runs. + let visible_account_ids = if input.drag.is_dragging && !self.last_visible_ids.is_empty() { + self.last_visible_ids.clone() + } else { + let ids = + collect_visible_account_ids(sections.as_deref(), &account_items, &self.collapsed); + if ids != self.last_visible_ids { + self.last_visible_ids = ids; + } + self.last_visible_ids.clone() + }; + + let in_list = |id: &str| input.accounts.iter().any(|account| account.id == id); + let rendered_account_count = input.accounts.len() + + usize::from(pending.is_some_and(|pending| !in_list(&pending.id))); + + DisplayLists { + folder_items, + account_items, + sections, + visible_account_ids, + rendered_account_count, + search_layout, + } + } +} diff --git a/crates/accshift-app/src/display/view.rs b/crates/accshift-app/src/display/view.rs new file mode 100644 index 00000000..0b918436 --- /dev/null +++ b/crates/accshift-app/src/display/view.rs @@ -0,0 +1,435 @@ +//! The flat list of display items a grid draws, built from the pipeline's +//! [`DisplayLists`] and the props `AppWorkspace.svelte` hands each card. +//! +//! Every item carries what its card needs, so the egui grid reads nothing +//! else: names, avatar key, colours, notes, warnings, the active and armed +//! states. The order is the Tauri grid's: the back card, then folders, then +//! accounts; in sections mode, a header before each section's cards. + +use super::{section_collapse_key, DisplayLists, FolderNavigation, ROOT_SECTION_KEY}; +use crate::accounts::loader::AccountLoader; +use crate::folders::{FolderInfo, ItemKind, ItemRef}; +use crate::platforms::warnings::WarningPresentation; +use crate::platforms::{PlatformAccount, PlatformAdapter, PlatformDef}; +use crate::stores::ClientStores; +use crate::text::Text; +use std::collections::{BTreeSet, HashMap}; + +/// What the cards read beyond the pipeline output. +pub struct CardContext<'a> { + pub loader: &'a AccountLoader, + pub stores: &'a ClientStores, + pub navigation: &'a FolderNavigation, + /// The active tab's definition (name, capabilities). + pub platform: Option<&'a PlatformDef>, + pub adapter: Option<&'a PlatformAdapter>, + pub active_tab: &'a str, + /// The add flow's placeholder card, drawn last with a spinner. + pub pending_setup_account: Option<&'a PlatformAccount>, + /// `Some` while the bulk edit bar is open, with its selection. + pub bulk_selection: Option<&'a BTreeSet>, + /// The item being dragged, if any. + pub drag_item: Option<&'a ItemRef>, + /// Collapsed section keys from the pipeline. + pub collapsed_sections: &'a BTreeSet, +} + +impl CardContext<'_> { + fn bulk_edit(&self) -> bool { + self.bulk_selection.is_some() + } + + fn platform_name(&self) -> String { + self.platform + .map_or_else(|| self.active_tab.to_string(), |def| def.name.clone()) + } + + /// `showUsernamesForActiveTab`. + pub fn show_usernames(&self) -> bool { + self.platform + .is_some_and(|def| def.capabilities.account_usernames) + && self.stores.settings().account_display.show_usernames + } + + /// `showLastLoginForActiveTab`. + pub fn show_last_login(&self) -> bool { + self.stores + .settings() + .account_display + .show_last_login_per_platform + .get(self.active_tab) + .copied() + .unwrap_or(false) + } + + /// `lastLoginUnknownKey`. + pub fn last_login_unknown_key(&self) -> &'static str { + self.platform + .and_then(|def| def.capabilities.last_login_unknown_key) + .unwrap_or("time.unknown") + } + + fn is_pending_setup(&self, account_id: &str) -> bool { + self.pending_setup_account + .is_some_and(|account| account.id == account_id) + } + + fn is_dragged(&self, kind: ItemKind, id: &str) -> bool { + self.drag_item + .is_some_and(|item| item.kind == kind && item.id == id) + } +} + +/// The avatar of a card. `url` is the stable key: hand it to +/// [`crate::accounts::avatars::AvatarImages::request`] for the bytes or the +/// file path egui decodes. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AvatarView { + pub url: Option, + pub loading: bool, + pub refreshing: bool, + /// No avatar state yet (`avatarPending`). + pub pending: bool, +} + +/// An account card (`AccountCard` props). +#[derive(Debug, Clone, PartialEq)] +pub struct AccountCardItem { + pub account: PlatformAccount, + /// `displayName`, or the username when it is empty. + pub name: String, + /// Shown under the name when `show_username` is set. + pub username: String, + /// `#rrggbb`, `"accent"` for a bulk selected card, or empty. + pub card_color: String, + pub note: String, + pub show_note_inline: bool, + /// `showUsernames` and `showLastLogin` as the list and the preview take + /// them; the grid card drops both on the pending setup card. + pub show_username: bool, + pub show_last_login: bool, + pub last_login_at_sec: Option, + pub last_login_unknown_key: &'static str, + pub active: bool, + pub switching: bool, + pub avatar: AvatarView, + pub warning: Option, + pub dragged: bool, + /// The add flow's placeholder: no interaction, spinner avatar. + pub pending_setup: bool, + /// Bulk edit: a single click selects instead of switching. + pub single_click_switch: bool, + pub bulk_selected: bool, +} + +/// A folder card (`FolderCard` props). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FolderCardItem { + pub folder: FolderInfo, + pub card_color: String, + pub dragged: bool, +} + +/// The label of a section header. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SectionLabel { + /// `list.rootSection`. + Root, + Folder(FolderInfo), +} + +/// A section header in the expanded folders layout. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SectionHeaderItem { + pub label: SectionLabel, + pub collapse_key: String, + pub count: usize, + pub card_color: String, + pub collapsed: bool, + pub dragged: bool, +} + +/// One entry of the grid, in draw order. +#[derive(Debug, Clone, PartialEq)] +pub enum DisplayItem { + /// The back card shown inside a folder. + Back, + SectionHeader(SectionHeaderItem), + Folder(FolderCardItem), + Account(Box), +} + +impl DisplayItem { + /// The name a card shows (`Back` for the back card), for golden tests + /// and accessibility labels. + pub fn label(&self) -> String { + match self { + DisplayItem::Back => "Back".to_string(), + DisplayItem::SectionHeader(header) => match &header.label { + SectionLabel::Root => ROOT_SECTION_KEY.to_string(), + SectionLabel::Folder(folder) => folder.name.clone(), + }, + DisplayItem::Folder(folder) => folder.folder.name.clone(), + DisplayItem::Account(card) => card.name.clone(), + } + } +} + +/// What the workspace shows under the toolbar. +#[derive(Debug, Clone, PartialEq)] +pub enum DisplayBody { + /// The first load runs and nothing is on screen yet. + Loading, + /// `app.noAccountsFound` with its hint. + NoAccounts { title: Text, hint: Text }, + /// `app.searchNoResults`. + NoSearchResults(Text), + /// The cards. `folder_empty` adds `app.folderEmpty` under the back card. + Items { + items: Vec, + folder_empty: bool, + }, +} + +/// The workspace content: the error banner and the body. +#[derive(Debug, Clone, PartialEq)] +pub struct DisplayView { + /// `loaderErrorDisplay`. + pub error: Option, + pub body: DisplayBody, +} + +impl DisplayView { + /// The cards, empty for the other bodies. + pub fn items(&self) -> &[DisplayItem] { + match &self.body { + DisplayBody::Items { items, .. } => items, + _ => &[], + } + } + + /// Card labels in draw order. + pub fn labels(&self) -> Vec { + self.items().iter().map(DisplayItem::label).collect() + } +} + +fn account_card(ctx: &CardContext<'_>, account: &PlatformAccount) -> AccountCardItem { + let bulk = ctx.bulk_edit(); + let pending_setup = ctx.is_pending_setup(&account.id); + let bulk_selected = ctx + .bulk_selection + .is_some_and(|selection| selection.contains(&account.id)); + let card_color = if bulk { + if bulk_selected { + "accent".to_string() + } else { + String::new() + } + } else { + ctx.stores.account_card_color(&account.id).to_string() + }; + let note = if bulk { + String::new() + } else { + ctx.stores.account_card_note(&account.id).to_string() + }; + let state = ctx.loader.avatars().get(&account.id); + let avatar = AvatarView { + url: state.and_then(|state| state.url.clone()), + loading: pending_setup || state.is_some_and(|state| state.loading), + refreshing: state.is_some_and(|state| state.refreshing), + pending: state.is_none(), + }; + let warning = if bulk || pending_setup { + None + } else { + ctx.loader.warning_states().get(&account.id).cloned() + }; + let name = if account.display_name.is_empty() { + account.username.clone() + } else { + account.display_name.clone() + }; + AccountCardItem { + name, + username: account.username.clone(), + card_color, + note, + show_note_inline: !bulk && ctx.stores.settings().account_display.show_card_notes_inline, + show_username: ctx.show_usernames(), + show_last_login: ctx.show_last_login(), + last_login_at_sec: account.last_login_at_sec, + last_login_unknown_key: ctx.last_login_unknown_key(), + active: !bulk && ctx.loader.current_account_id() == Some(account.id.as_str()), + switching: ctx.loader.switching_account_id() == Some(account.id.as_str()), + avatar, + warning, + dragged: !bulk && ctx.is_dragged(ItemKind::Account, &account.id), + pending_setup, + single_click_switch: bulk, + bulk_selected, + account: account.clone(), + } +} + +fn folder_card(ctx: &CardContext<'_>, folder: &FolderInfo) -> FolderCardItem { + FolderCardItem { + card_color: ctx.stores.folder_card_color(&folder.id).to_string(), + dragged: ctx.is_dragged(ItemKind::Folder, &folder.id), + folder: folder.clone(), + } +} + +fn push_items( + out: &mut Vec, + ctx: &CardContext<'_>, + accounts: &HashMap<&str, &PlatformAccount>, + folder_items: &[ItemRef], + account_items: &[ItemRef], +) { + for item in folder_items { + if let Some(folder) = ctx.stores.folders().folder(&item.id) { + out.push(DisplayItem::Folder(folder_card(ctx, folder))); + } + } + for item in account_items { + if let Some(account) = accounts.get(item.id.as_str()) { + out.push(DisplayItem::Account(Box::new(account_card(ctx, account)))); + } + } +} + +/// `renderedAccountMap`: the loaded accounts plus the pending setup card. +fn rendered_accounts<'a>(ctx: &CardContext<'a>) -> HashMap<&'a str, &'a PlatformAccount> { + let mut map: HashMap<&str, &PlatformAccount> = ctx + .loader + .accounts() + .iter() + .map(|account| (account.id.as_str(), account)) + .collect(); + if let Some(account) = ctx.pending_setup_account { + map.entry(account.id.as_str()).or_insert(account); + } + map +} + +/// The onboarding tour's grid (`onboarding.mockActive` in `App.svelte`): +/// `mockItems` over `mockMap`, no folder and no section, the rest of +/// `AppWorkspace.svelte`'s branches unchanged (the back card inside a +/// folder, the loader's error banner). +pub fn build_mock_view(ctx: &CardContext<'_>, accounts: &[PlatformAccount]) -> DisplayView { + let error = ctx.loader.error().map(|message| { + ctx.adapter + .and_then(|adapter| adapter.load_error_toast(message)) + .unwrap_or_else(|| Text::key("app.loadErrorGeneric")) + }); + let mut items = Vec::new(); + let mut folder_empty = false; + if ctx.navigation.current_folder_id().is_some() && !ctx.navigation.is_searching() { + items.push(DisplayItem::Back); + folder_empty = accounts.is_empty(); + } + for account in accounts { + items.push(DisplayItem::Account(Box::new(account_card(ctx, account)))); + } + DisplayView { + error, + body: DisplayBody::Items { + items, + folder_empty, + }, + } +} + +/// Builds the workspace content from the pipeline output, following the +/// branches of `AppWorkspace.svelte` (loading, empty, no search results, +/// sections or flat grid). +pub fn build_display_view(ctx: &CardContext<'_>, lists: &DisplayLists) -> DisplayView { + let platform_name = ctx.platform_name(); + let error = ctx.loader.error().map(|message| { + ctx.adapter + .and_then(|adapter| adapter.load_error_toast(message)) + .unwrap_or_else(|| Text::key("app.loadErrorGeneric")) + }); + let body = if ctx.loader.loading() + && lists.rendered_account_count == 0 + && ctx.pending_setup_account.is_none() + { + DisplayBody::Loading + } else if lists.rendered_account_count == 0 { + DisplayBody::NoAccounts { + title: Text::with("app.noAccountsFound", [("platform", platform_name.clone())]), + hint: ctx + .adapter + .and_then(PlatformAdapter::no_accounts_hint) + .unwrap_or_else(|| { + Text::with("app.noAccountsHint", [("platform", platform_name.clone())]) + }), + } + } else if ctx.navigation.is_searching() && lists.rendered_item_count() == 0 { + DisplayBody::NoSearchResults(Text::with( + "app.searchNoResults", + [("query", ctx.navigation.search_query().trim().to_string())], + )) + } else { + let accounts = rendered_accounts(ctx); + let mut items = Vec::new(); + let mut folder_empty = false; + if let Some(sections) = &lists.sections { + for section in sections { + let count = section.folder_items.len() + section.account_items.len(); + let collapse_key = section_collapse_key(section).to_string(); + let collapsed = ctx.collapsed_sections.contains(&collapse_key); + let header = match §ion.folder { + Some(folder) => Some(SectionHeaderItem { + label: SectionLabel::Folder(folder.clone()), + collapse_key, + count, + card_color: ctx.stores.folder_card_color(&folder.id).to_string(), + collapsed, + dragged: ctx.is_dragged(ItemKind::Folder, &folder.id), + }), + None if count > 0 => Some(SectionHeaderItem { + label: SectionLabel::Root, + collapse_key, + count, + card_color: String::new(), + collapsed, + dragged: false, + }), + None => None, + }; + if let Some(header) = header { + items.push(DisplayItem::SectionHeader(header)); + } + if count > 0 && !collapsed { + push_items( + &mut items, + ctx, + &accounts, + §ion.folder_items, + §ion.account_items, + ); + } + } + } else { + if ctx.navigation.current_folder_id().is_some() && !ctx.navigation.is_searching() { + items.push(DisplayItem::Back); + folder_empty = lists.rendered_item_count() == 0; + } + push_items( + &mut items, + ctx, + &accounts, + &lists.folder_items, + &lists.account_items, + ); + } + DisplayBody::Items { + items, + folder_empty, + } + }; + DisplayView { error, body } +} diff --git a/crates/accshift-app/src/drag.rs b/crates/accshift-app/src/drag.rs new file mode 100644 index 00000000..7d224010 --- /dev/null +++ b/crates/accshift-app/src/drag.rs @@ -0,0 +1,826 @@ +//! Card drag and drop: `createDragManager` (`dragAndDrop.svelte.ts`) and +//! `previewIndexAt` (`dropGeometry.ts`). +//! +//! What the DOM answers in TS (the card under a press, the slot rects at +//! drag start, `elementFromPoint` while dragging) comes from the UI as +//! values; the manager keeps the state machine and decides the drop. + +use crate::display::FolderNavigation; +use crate::folders::{ItemKind, ItemRef}; +use crate::stores::ClientStores; + +/// `DRAG_THRESHOLD`: Manhattan distance before a press becomes a drag. +pub const DRAG_THRESHOLD: f32 = 5.0; + +/// The part of a `DOMRect` the drop calculation reads. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SlotRect { + pub left: f32, + pub top: f32, + pub width: f32, + pub height: f32, +} + +/// `previewIndexAt`: the index the dragged card would take if dropped at +/// `(x, y)`, `None` when the layout offers nowhere to drop (no slots, or +/// the card's own index was never resolved). +/// +/// The pointer picks the nearest slot centre, then the side it fell on +/// decides before or after that slot. Dropping past the old position +/// shifts the target back by one: removing the card first closes its gap. +pub fn preview_index_at( + x: f32, + y: f32, + slots: &[SlotRect], + old_index: Option, + list_mode: bool, +) -> Option { + let old_index = old_index?; + if slots.is_empty() { + return None; + } + let mut nearest = 0; + let mut nearest_distance = f32::INFINITY; + for (i, slot) in slots.iter().enumerate() { + let dx = x - (slot.left + slot.width / 2.0); + let dy = y - (slot.top + slot.height / 2.0); + let distance = dx * dx + dy * dy; + if distance < nearest_distance { + nearest_distance = distance; + nearest = i; + } + } + let slot = slots[nearest]; + // A list stacks vertically, a grid flows horizontally: the axis the + // cards advance along decides before or after. + let past = if list_mode { + y > slot.top + slot.height / 2.0 + } else { + x > slot.left + slot.width / 2.0 + }; + let drop_index = if past { nearest + 1 } else { nearest }; + let insert_at = if drop_index > old_index { + drop_index - 1 + } else { + drop_index + }; + Some(insert_at.min(slots.len() - 1)) +} + +/// The card a press landed on (`closest("[data-account-id], +/// [data-folder-id]")`). +#[derive(Debug, Clone, PartialEq)] +pub struct PressedCard { + pub item: ItemRef, + /// A section wrapper (`data-section-card="true"`), sections mode only. + pub section_card: bool, + /// Sections mode: the folder of the section holding the card, `None` + /// for the root section (the source folder of an account drag). + pub section_folder: Option, +} + +/// What `elementFromPoint` found under the pointer, read through +/// `closest("[data-folder-id], [data-back-card], [data-account-id]")`. +#[derive(Debug, Clone, PartialEq)] +pub enum PointHit { + /// Outside the document. + Nothing, + /// The document, but no card: the grid itself. + Grid, + Back, + Folder { + id: String, + section_card: bool, + }, + Account, +} + +/// The slots captured when a drag starts (`refreshSlotRects`). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Slots { + pub rects: Vec, + /// `.list-container`, or the sections when a folder moves among them. + pub list_mode: bool, +} + +/// What the pointer is over. +#[derive(Debug, Clone, PartialEq)] +enum DropTarget { + None, + Back, + Folder(String), + Reorder, +} + +/// The app state the drag reads (the `DragManagerOptions` getters and the +/// folder store). +pub trait DragHost { + fn current_folder_id(&self) -> Option; + /// `navigation.folderItems`. + fn folder_items(&self) -> Vec; + /// `navigation.accountItems`. + fn account_items(&self) -> Vec; + /// `isFolderDescendant(folderId, ancestorId)`. + fn is_folder_descendant(&self, folder_id: &str, ancestor_id: &str) -> bool; + /// `getFolder(id)?.parentId`. + fn folder_parent(&self, folder_id: &str) -> Option; +} + +/// The folder navigation and the folder store, as the app wires them. +pub struct StoreHost<'a> { + pub navigation: &'a FolderNavigation, + pub stores: &'a ClientStores, +} + +impl DragHost for StoreHost<'_> { + fn current_folder_id(&self) -> Option { + self.navigation.current_folder_id().map(str::to_owned) + } + fn folder_items(&self) -> Vec { + self.navigation.folder_items() + } + fn account_items(&self) -> Vec { + self.navigation.account_items() + } + fn is_folder_descendant(&self, folder_id: &str, ancestor_id: &str) -> bool { + self.stores + .folders() + .is_folder_descendant(Some(folder_id), ancestor_id) + } + fn folder_parent(&self, folder_id: &str) -> Option { + self.stores.folders().folder(folder_id)?.parent_id.clone() + } +} + +/// The store change a drop asks for. +#[derive(Debug, Clone, PartialEq)] +pub enum DropCommit { + /// `moveItem(item, from, to, activeTab)`. + Move { + item: ItemRef, + from: Option, + to: Option, + }, + /// `reorderItems(folder, activeTab, items)`. + Reorder { + folder: Option, + items: Vec, + }, +} + +impl DropCommit { + /// `moveItem` or `reorderItems` on the active tab's folders. + pub fn apply(self, stores: &mut ClientStores, active_tab: &str) { + match self { + Self::Move { item, from, to } => { + stores.move_item(&item, from.as_deref(), to.as_deref(), active_tab, None) + } + Self::Reorder { folder, items } => { + stores.reorder_items(folder.as_deref(), active_tab, items) + } + } + } +} + +/// What a release did. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DropOutcome { + pub commit: Option, + /// `options.onRefresh()`. + pub refresh: bool, +} + +#[derive(Debug, Clone, PartialEq)] +struct Pending { + card: PressedCard, + start: (f32, f32), +} + +/// `createDragManager`. +#[derive(Debug, Default)] +pub struct DragManager { + drag_item: Option, + drag_over_folder_id: Option, + drag_over_back: bool, + is_dragging: bool, + preview_index: Option, + pending: Option, + /// `eatNextClick`: the click right after a drag release. + eat_next_click: bool, + last: (f32, f32), + has_pointer: bool, + /// Escape cancelled a drag: its release and click are swallowed. + drag_cancelled: bool, + sections_mode: bool, + /// A scroll made the slots stale: the next frame measures again. + rects_stale: bool, + slots: Slots, + old_index: Option, +} + +impl DragManager { + pub fn drag_item(&self) -> Option<&ItemRef> { + self.drag_item.as_ref() + } + pub fn drag_over_folder_id(&self) -> Option<&str> { + self.drag_over_folder_id.as_deref() + } + pub fn drag_over_back(&self) -> bool { + self.drag_over_back + } + pub fn is_dragging(&self) -> bool { + self.is_dragging + } + pub fn preview_index(&self) -> Option { + self.preview_index + } + /// A card is pressed or moving. + pub fn in_flight(&self) -> bool { + self.pending.is_some() || self.is_dragging + } + /// The press that started the drag, in client px. + pub fn press_start(&self) -> Option<(f32, f32)> { + self.pending.as_ref().map(|p| p.start) + } + /// The pressed card. + pub fn pressed(&self) -> Option<&PressedCard> { + self.pending.as_ref().map(|p| &p.card) + } + /// The slots need measuring again (after a scroll). + pub fn rects_stale(&self) -> bool { + self.is_dragging && self.rects_stale + } + + /// `handleGridMouseDown`: a primary press on the workspace. `card` is + /// the card under it; `sections_mode` whether the grid shows sections. + pub fn mouse_down( + &mut self, + primary: bool, + card: Option, + sections_mode: bool, + at: (f32, f32), + ) { + if !primary { + return; + } + let Some(card) = card else { + return; + }; + // In sections mode only the section wrappers drag as folders; + // subfolder cards inside sections stay click-to-navigate. + if sections_mode && card.item.kind == ItemKind::Folder && !card.section_card { + return; + } + self.last = at; + self.has_pointer = true; + self.drag_cancelled = false; + self.sections_mode = sections_mode; + self.pending = Some(Pending { card, start: at }); + } + + /// `handleDocMouseMove`. `buttons_down` is `e.buttons !== 0`: a move + /// with no button held means the release was missed outside the + /// window. Returns true when this move started the drag: the caller + /// then measures the slots ([`Self::set_slots`]) and the ghost. + pub fn mouse_move(&mut self, at: (f32, f32), buttons_down: bool) -> bool { + let Some(pending) = &self.pending else { + return false; + }; + if !buttons_down { + self.cancel(); + return false; + } + self.last = at; + self.has_pointer = true; + if self.is_dragging { + return false; + } + let dx = at.0 - pending.start.0; + let dy = at.1 - pending.start.1; + if dx.abs() + dy.abs() < DRAG_THRESHOLD { + return false; + } + self.is_dragging = true; + self.drag_item = Some(pending.card.item.clone()); + true + } + + /// `refreshSlotRects`: the slots of the dragged card's kind and its + /// index among them. In sections mode an account has no slot: it only + /// moves into a folder. + pub fn set_slots(&mut self, slots: Slots, host: &dyn DragHost) { + self.rects_stale = false; + let Some(item) = &self.drag_item else { + return; + }; + if self.sections_mode && item.kind == ItemKind::Account { + self.slots = Slots::default(); + self.old_index = None; + return; + } + let items = match item.kind { + ItemKind::Folder => host.folder_items(), + ItemKind::Account => host.account_items(), + }; + self.old_index = items.iter().position(|i| i.id == item.id); + self.slots = slots; + } + + /// The sections layout is what `set_slots` measures for a folder. + pub fn sections_mode(&self) -> bool { + self.sections_mode + } + + /// `handleDocScroll`: the slots are stale. + pub fn scrolled(&mut self) { + if self.is_dragging && self.has_pointer { + self.rects_stale = true; + } + } + + /// The pointer, as the last move left it. + pub fn pointer(&self) -> (f32, f32) { + self.last + } + + fn drop_target(&self, hit: &PointHit, host: &dyn DragHost) -> DropTarget { + match hit { + PointHit::Nothing => DropTarget::None, + PointHit::Grid | PointHit::Account => DropTarget::Reorder, + PointHit::Back => DropTarget::Back, + PointHit::Folder { id, section_card } => { + if let Some(item) = self + .drag_item + .as_ref() + .filter(|i| i.kind == ItemKind::Folder) + { + // A folder never swallows itself or its own subtree. + if host.is_folder_descendant(id, &item.id) { + return DropTarget::Reorder; + } + // A section takes accounts, never another folder: + // folders reorder among the sections. + if self.sections_mode && *section_card { + return DropTarget::Reorder; + } + } + DropTarget::Folder(id.clone()) + } + } + } + + /// `updateDragAt`, once per frame while dragging: `hit` is what lies + /// under [`Self::pointer`]. + pub fn update(&mut self, hit: &PointHit, host: &dyn DragHost) { + if !self.is_dragging { + return; + } + let target = self.drop_target(hit, host); + self.drag_over_back = target == DropTarget::Back; + self.drag_over_folder_id = match &target { + DropTarget::Folder(id) => Some(id.clone()), + _ => None, + }; + if target != DropTarget::Reorder { + self.preview_index = None; + return; + } + // Left alone when the layout offers no slot. + let (x, y) = self.last; + if let Some(at) = preview_index_at( + x, + y, + &self.slots.rects, + self.old_index, + self.slots.list_mode, + ) { + self.preview_index = Some(at); + } + } + + fn cancel(&mut self) { + let eat = self.eat_next_click; + let cancelled = self.drag_cancelled; + *self = Self::default(); + self.eat_next_click = eat; + self.drag_cancelled = cancelled; + } + + /// `cancelFromEscape`: false when no card is pressed or moving. + pub fn cancel_from_escape(&mut self) -> bool { + if !self.in_flight() { + return false; + } + if self.is_dragging { + self.drag_cancelled = true; + } + self.cancel(); + true + } + + /// `handleDocMouseUp`. + pub fn mouse_up(&mut self, host: &dyn DragHost) -> DropOutcome { + if self.drag_cancelled { + // The release after an Escape: swallow it and its click. + self.drag_cancelled = false; + self.eat_next_click = true; + return DropOutcome::default(); + } + let Some(pending) = self.pending.clone() else { + return DropOutcome::default(); + }; + if !self.is_dragging { + self.cancel(); + return DropOutcome::default(); + } + let current = host.current_folder_id(); + // In sections mode an account leaves the folder of its section. + let source = if self.sections_mode + && self + .drag_item + .as_ref() + .is_some_and(|i| i.kind == ItemKind::Account) + { + pending.card.section_folder.clone() + } else { + current.clone() + }; + let mut out = DropOutcome::default(); + if let Some(item) = self.drag_item.clone() { + if self.drag_over_back && current.is_some() { + let parent = current.as_deref().and_then(|c| host.folder_parent(c)); + out.commit = Some(DropCommit::Move { + item, + from: current, + to: parent, + }); + } else if let Some(over) = self.drag_over_folder_id.clone() { + let into_itself = item.kind == ItemKind::Folder && item.id == over; + if !into_itself && source.as_deref() != Some(over.as_str()) { + out.commit = Some(DropCommit::Move { + item, + from: source, + to: Some(over), + }); + } + } else if let Some(preview) = self.preview_index { + // Commit the order the preview shows. + let folders = host.folder_items(); + let accounts = host.account_items(); + let items = match item.kind { + ItemKind::Folder => { + let mut moved: Vec = + folders.into_iter().filter(|i| i.id != item.id).collect(); + moved.insert(preview.min(moved.len()), item); + moved.into_iter().chain(accounts).collect() + } + ItemKind::Account => { + let mut moved: Vec = + accounts.into_iter().filter(|i| i.id != item.id).collect(); + moved.insert(preview.min(moved.len()), item); + folders.into_iter().chain(moved).collect() + } + }; + out.commit = Some(DropCommit::Reorder { + folder: current, + items, + }); + } + out.refresh = true; + } + self.cancel(); + self.eat_next_click = true; + out + } + + /// `handleCaptureClick`: whether the click of this release is eaten. + /// The guard only lasts the release's own turn (`setTimeout(0)`): + /// call [`Self::end_frame`] after the frame's clicks. + pub fn eats_click(&self) -> bool { + self.eat_next_click + } + + /// Clears the click guard once the release's frame is over. + pub fn end_frame(&mut self) { + self.eat_next_click = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row() -> Vec { + (0..4) + .map(|i| SlotRect { + left: i as f32 * 100.0, + top: 0.0, + width: 100.0, + height: 100.0, + }) + .collect() + } + + fn column() -> Vec { + (0..4) + .map(|i| SlotRect { + left: 0.0, + top: i as f32 * 100.0, + width: 100.0, + height: 100.0, + }) + .collect() + } + + #[test] + fn has_nowhere_to_drop_when_the_layout_was_never_measured() { + assert_eq!(preview_index_at(50.0, 50.0, &[], Some(0), false), None); + assert_eq!(preview_index_at(50.0, 50.0, &row(), None, false), None); + } + + #[test] + fn drops_before_or_after_the_nearest_card() { + assert_eq!( + preview_index_at(210.0, 50.0, &row(), Some(0), false), + Some(1) + ); + assert_eq!( + preview_index_at(260.0, 50.0, &row(), Some(0), false), + Some(2) + ); + } + + #[test] + fn reads_the_vertical_axis_in_list_mode() { + assert_eq!( + preview_index_at(60.0, 240.0, &column(), Some(3), true), + Some(2) + ); + assert_eq!( + preview_index_at(60.0, 260.0, &column(), Some(3), true), + Some(3) + ); + } + + #[test] + fn closes_the_gap_the_card_left_behind() { + assert_eq!( + preview_index_at(260.0, 50.0, &row(), Some(0), false), + Some(2) + ); + assert_eq!( + preview_index_at(30.0, 50.0, &row(), Some(3), false), + Some(0) + ); + } + + #[test] + fn never_points_outside_the_list() { + assert_eq!( + preview_index_at(-5000.0, 50.0, &row(), Some(2), false), + Some(0) + ); + assert_eq!( + preview_index_at(5000.0, 50.0, &row(), Some(0), false), + Some(3) + ); + } + + #[test] + fn resolves_a_pointer_inside_no_card() { + let gapped: Vec = (0..4) + .map(|i| SlotRect { + left: i as f32 * 120.0, + top: 0.0, + width: 100.0, + height: 100.0, + }) + .collect(); + assert_eq!( + preview_index_at(115.0, 50.0, &gapped, Some(3), false), + Some(1) + ); + } + + struct Host { + current: Option, + folders: Vec, + accounts: Vec, + /// (folder, parent). + tree: Vec<(String, Option)>, + } + + impl DragHost for Host { + fn current_folder_id(&self) -> Option { + self.current.clone() + } + fn folder_items(&self) -> Vec { + self.folders.clone() + } + fn account_items(&self) -> Vec { + self.accounts.clone() + } + fn is_folder_descendant(&self, folder_id: &str, ancestor_id: &str) -> bool { + let mut at = Some(folder_id.to_string()); + while let Some(id) = at { + if id == ancestor_id { + return true; + } + at = self.folder_parent(&id); + } + false + } + fn folder_parent(&self, folder_id: &str) -> Option { + self.tree + .iter() + .find(|(id, _)| id == folder_id) + .and_then(|(_, parent)| parent.clone()) + } + } + + fn host() -> Host { + Host { + current: None, + folders: vec![ItemRef::folder("f1"), ItemRef::folder("f2")], + accounts: ["a", "b", "c", "d"].map(ItemRef::account).to_vec(), + tree: vec![("f1".into(), None), ("f2".into(), Some("f1".into()))], + } + } + + fn pressed(item: ItemRef) -> Option { + Some(PressedCard { + item, + section_card: false, + section_folder: None, + }) + } + + #[test] + fn a_press_drags_only_past_the_threshold() { + let h = host(); + let mut d = DragManager::default(); + d.mouse_down(true, pressed(ItemRef::account("a")), false, (50.0, 50.0)); + assert!(!d.mouse_move((52.0, 52.0), true)); + assert!(!d.is_dragging()); + assert!(d.mouse_move((53.0, 52.0), true)); + assert!(d.is_dragging()); + d.set_slots( + Slots { + rects: row(), + list_mode: false, + }, + &h, + ); + assert_eq!(d.mouse_up(&h).commit, None); + assert!(d.eats_click()); + d.end_frame(); + assert!(!d.eats_click()); + } + + #[test] + fn a_reorder_commits_the_previewed_order() { + let h = host(); + let mut d = DragManager::default(); + d.mouse_down(true, pressed(ItemRef::account("a")), false, (50.0, 50.0)); + d.mouse_move((260.0, 50.0), true); + d.set_slots( + Slots { + rects: row(), + list_mode: false, + }, + &h, + ); + d.update(&PointHit::Account, &h); + assert_eq!(d.preview_index(), Some(2)); + let out = d.mouse_up(&h); + assert!(out.refresh); + let order: Vec<&str> = match &out.commit { + Some(DropCommit::Reorder { + folder: None, + items, + }) => items.iter().map(|i| i.id.as_str()).collect(), + other => panic!("{other:?}"), + }; + assert_eq!(order, ["f1", "f2", "b", "c", "a", "d"]); + assert!(!d.in_flight()); + } + + #[test] + fn a_drop_on_a_folder_moves_into_it_and_the_back_card_moves_up() { + let mut h = host(); + let mut d = DragManager::default(); + d.mouse_down(true, pressed(ItemRef::account("b")), false, (0.0, 0.0)); + d.mouse_move((10.0, 0.0), true); + d.update( + &PointHit::Folder { + id: "f1".into(), + section_card: false, + }, + &h, + ); + assert_eq!(d.drag_over_folder_id(), Some("f1")); + assert_eq!( + d.mouse_up(&h).commit, + Some(DropCommit::Move { + item: ItemRef::account("b"), + from: None, + to: Some("f1".into()), + }) + ); + + h.current = Some("f2".into()); + d.mouse_down(true, pressed(ItemRef::account("c")), false, (0.0, 0.0)); + d.mouse_move((0.0, 10.0), true); + d.update(&PointHit::Back, &h); + assert!(d.drag_over_back()); + assert_eq!( + d.mouse_up(&h).commit, + Some(DropCommit::Move { + item: ItemRef::account("c"), + from: Some("f2".into()), + to: Some("f1".into()), + }) + ); + } + + #[test] + fn a_folder_never_drops_into_its_own_subtree() { + let h = host(); + let mut d = DragManager::default(); + d.mouse_down(true, pressed(ItemRef::folder("f1")), false, (0.0, 0.0)); + d.mouse_move((10.0, 0.0), true); + d.update( + &PointHit::Folder { + id: "f2".into(), + section_card: false, + }, + &h, + ); + assert_eq!(d.drag_over_folder_id(), None); + } + + #[test] + fn escape_cancels_and_eats_the_release() { + let h = host(); + let mut d = DragManager::default(); + assert!(!d.cancel_from_escape()); + d.mouse_down(true, pressed(ItemRef::account("a")), false, (0.0, 0.0)); + d.mouse_move((10.0, 0.0), true); + assert!(d.cancel_from_escape()); + assert!(!d.is_dragging()); + assert_eq!(d.mouse_up(&h), DropOutcome::default()); + assert!(d.eats_click()); + } + + #[test] + fn a_move_without_a_button_aborts() { + let mut d = DragManager::default(); + d.mouse_down(true, pressed(ItemRef::account("a")), false, (0.0, 0.0)); + assert!(!d.mouse_move((20.0, 0.0), false)); + assert!(!d.in_flight()); + } + + #[test] + fn sections_mode_drags_section_wrappers_and_moves_accounts_from_their_section() { + let h = host(); + let mut d = DragManager::default(); + d.mouse_down(true, pressed(ItemRef::folder("f2")), true, (0.0, 0.0)); + assert!(!d.in_flight()); + + d.mouse_down( + true, + Some(PressedCard { + item: ItemRef::account("a"), + section_card: false, + section_folder: Some("f2".into()), + }), + true, + (0.0, 0.0), + ); + d.mouse_move((10.0, 0.0), true); + d.set_slots( + Slots { + rects: row(), + list_mode: false, + }, + &h, + ); + d.update(&PointHit::Grid, &h); + assert_eq!(d.preview_index(), None); + d.update( + &PointHit::Folder { + id: "f1".into(), + section_card: true, + }, + &h, + ); + assert_eq!( + d.mouse_up(&h).commit, + Some(DropCommit::Move { + item: ItemRef::account("a"), + from: Some("f2".into()), + to: Some("f1".into()), + }) + ); + } +} diff --git a/crates/accshift-app/src/error.rs b/crates/accshift-app/src/error.rs new file mode 100644 index 00000000..770f16e7 --- /dev/null +++ b/crates/accshift-app/src/error.rs @@ -0,0 +1,73 @@ +//! The one error type every backend call returns. + +use accshift_core::error::PlatformError; +use serde::Serialize; + +/// A failed backend call, carrying the exact message the webview receives +/// today: Tauri sends a rejected command to the frontend as a bare string +/// (`PlatformError` serializes as its message, `Result<_, String>` as the +/// string), and the UI shows that string. Keeping only the message keeps the +/// egui side showing the same text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendError { + pub message: String, +} + +impl BackendError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + /// The refusal the mock answers for a guarded command it does not fake. + /// Same text as `mockInvoke` in `src/lib/mock/runtime.ts`. + pub fn mock_refused(command: &str) -> Self { + Self::new(format!("mock mode: {command} is not available")) + } + + /// What the real backend answers until its job fills a command in. + pub fn not_ported(command: &str) -> Self { + Self::new(format!("{command} is not ported to the egui backend yet")) + } + + /// True for the PIN refusal (`isPinLockedError` in the frontend). + pub fn is_pin_locked(&self) -> bool { + self.message.starts_with("pin_locked:") + } +} + +impl std::fmt::Display for BackendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for BackendError {} + +/// Serialized as the bare message, like the rejection the webview gets. +impl Serialize for BackendError { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.message) + } +} + +impl From for BackendError { + fn from(error: PlatformError) -> Self { + Self::new(error.message) + } +} + +impl From for BackendError { + fn from(message: String) -> Self { + Self::new(message) + } +} + +impl From<&str> for BackendError { + fn from(message: &str) -> Self { + Self::new(message) + } +} + +pub type BackendResult = Result; diff --git a/crates/accshift-app/src/folders.rs b/crates/accshift-app/src/folders.rs new file mode 100644 index 00000000..c030d2b0 --- /dev/null +++ b/crates/accshift-app/src/folders.rs @@ -0,0 +1,506 @@ +//! The folder store, ported from `src/lib/features/folders/{types,store}.ts`. +//! +//! [`FolderStore`] is the store's JSON as a struct. Its methods are the TS +//! functions; the mutating ones return whether the store has to be written, +//! and [`crate::stores::ClientStores`] writes it. + +use crate::js; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +pub const CURRENT_VERSION: u32 = 1; +/// Every `itemOrder` key is this prefix plus a platform id, or a folder id. +pub const ROOT_KEY_PREFIX: &str = "root:"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FolderInfo { + pub id: String, + pub name: String, + pub parent_id: Option, + pub platform: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ItemKind { + Account, + Folder, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ItemRef { + #[serde(rename = "type")] + pub kind: ItemKind, + pub id: String, +} + +impl ItemRef { + pub fn account(id: impl Into) -> Self { + Self { + kind: ItemKind::Account, + id: id.into(), + } + } + pub fn folder(id: impl Into) -> Self { + Self { + kind: ItemKind::Folder, + id: id.into(), + } + } + fn same(&self, other: &ItemRef) -> bool { + self.kind == other.kind && self.id == other.id + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FolderStore { + pub version: u32, + pub folders: Vec, + pub item_order: BTreeMap>, +} + +impl Default for FolderStore { + fn default() -> Self { + Self { + version: CURRENT_VERSION, + folders: Vec::new(), + item_order: BTreeMap::new(), + } + } +} + +/// One folder of [`FolderStore::root_sections`] (or the root, `folder: None`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FolderSection { + pub folder: Option, + pub folder_items: Vec, + pub account_items: Vec, +} + +/// `getRootKey`. +pub fn root_key(platform: &str) -> String { + format!("{ROOT_KEY_PREFIX}{platform}") +} + +/// `folderId || getRootKey(platform)`: an empty id is the root too. +fn bucket_key(folder_id: Option<&str>, platform: &str) -> String { + match folder_id { + Some(id) if !id.is_empty() => id.to_string(), + _ => root_key(platform), + } +} + +fn non_empty(folder_id: Option<&str>) -> Option<&str> { + folder_id.filter(|id| !id.is_empty()) +} + +fn trimmed_string(record: &serde_json::Map, key: &str) -> String { + js::as_str(record.get(key)) + .map(|text| js::trim(text).to_string()) + .unwrap_or_default() +} + +fn sanitize_folder(value: &Value) -> Option { + let empty = serde_json::Map::new(); + let raw = js::as_record(Some(value)).unwrap_or(&empty); + let id = trimmed_string(raw, "id"); + let name = trimmed_string(raw, "name"); + let platform = trimmed_string(raw, "platform"); + let parent_id = match raw.get("parentId") { + Some(Value::String(parent)) if !js::trim(parent).is_empty() => { + Some(js::trim(parent).to_string()) + } + _ => None, + }; + if id.is_empty() || name.is_empty() || platform.is_empty() { + return None; + } + Some(FolderInfo { + id, + name, + parent_id, + platform, + }) +} + +fn sanitize_item_ref(value: &Value) -> Option { + let raw = js::as_record(Some(value))?; + let id = trimmed_string(raw, "id"); + if id.is_empty() { + return None; + } + let kind = match js::as_str(raw.get("type")) { + Some("account") => ItemKind::Account, + Some("folder") => ItemKind::Folder, + _ => return None, + }; + Some(ItemRef { kind, id }) +} + +/// `sanitizeStore` then `migrateStore`. Drops folders missing an id, name or +/// platform, and every `itemOrder` bucket that is neither a platform root nor +/// a live folder (a dead folder's bucket would keep its accounts forever). +pub fn sanitize_folder_store(value: &Value) -> FolderStore { + let raw = js::as_record(Some(value)); + let folders: Vec = match raw.and_then(|map| map.get("folders")) { + Some(Value::Array(items)) => items.iter().filter_map(sanitize_folder).collect(), + _ => Vec::new(), + }; + let valid: BTreeSet<&str> = folders.iter().map(|folder| folder.id.as_str()).collect(); + let mut item_order = BTreeMap::new(); + if let Some(order) = js::as_record(raw.and_then(|map| map.get("itemOrder"))) { + for (key, entry) in order { + if js::trim(key).is_empty() { + continue; + } + let Value::Array(items) = entry else { + continue; + }; + let is_root = key.starts_with(ROOT_KEY_PREFIX) && key.len() > ROOT_KEY_PREFIX.len(); + if !is_root && !valid.contains(key.as_str()) { + continue; + } + let refs: Vec = items + .iter() + .filter_map(sanitize_item_ref) + .filter(|item| item.kind != ItemKind::Folder || valid.contains(item.id.as_str())) + .collect(); + item_order.insert(key.clone(), refs); + } + } + FolderStore { + version: CURRENT_VERSION, + folders, + item_order, + } +} + +/// `getStore` on a raw store value: `None` (missing) is the empty store. +pub fn load_folder_store(value: Option<&Value>) -> FolderStore { + value.map(sanitize_folder_store).unwrap_or_default() +} + +/// `generateId`: base-36 milliseconds plus four random base-36 characters. +pub fn generate_folder_id() -> String { + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0); + let random = js::to_base36(js::random_u64()); + let suffix: String = random.chars().rev().take(4).collect(); + format!("{}{suffix}", js::to_base36(millis)) +} + +impl FolderStore { + pub fn to_value(&self) -> Value { + serde_json::to_value(self).unwrap_or(Value::Null) + } + + /// `getItemsInFolder`. + pub fn items_in_folder(&self, folder_id: Option<&str>, platform: &str) -> &[ItemRef] { + self.item_order + .get(&bucket_key(folder_id, platform)) + .map_or(&[], Vec::as_slice) + } + + /// `getRootSections`: each root folder with its items, then the root's own + /// accounts. + pub fn root_sections(&self, platform: &str) -> Vec { + let root_items = self.items_in_folder(None, platform); + let mut sections = Vec::new(); + for folder_ref in root_items + .iter() + .filter(|item| item.kind == ItemKind::Folder) + { + let Some(folder) = self.folder(&folder_ref.id) else { + continue; + }; + let items = self + .item_order + .get(&folder.id) + .map_or(&[][..], Vec::as_slice); + sections.push(FolderSection { + folder: Some(folder.clone()), + folder_items: items + .iter() + .filter(|item| item.kind == ItemKind::Folder) + .cloned() + .collect(), + account_items: items + .iter() + .filter(|item| item.kind == ItemKind::Account) + .cloned() + .collect(), + }); + } + sections.push(FolderSection { + folder: None, + folder_items: Vec::new(), + account_items: root_items + .iter() + .filter(|item| item.kind == ItemKind::Account) + .cloned() + .collect(), + }); + sections + } + + /// `createFolder` with a given id (see [`generate_folder_id`]). + pub fn create_folder( + &mut self, + id: String, + name: &str, + parent_id: Option<&str>, + platform: &str, + ) -> FolderInfo { + let folder = FolderInfo { + id, + name: name.to_string(), + parent_id: parent_id.map(str::to_string), + platform: platform.to_string(), + }; + self.folders.push(folder.clone()); + self.item_order + .entry(bucket_key(parent_id, platform)) + .or_default() + .push(ItemRef::folder(folder.id.clone())); + self.item_order.insert(folder.id.clone(), Vec::new()); + folder + } + + /// `getFolder`. + pub fn folder(&self, id: &str) -> Option<&FolderInfo> { + self.folders.iter().find(|folder| folder.id == id) + } + + /// `listFolders`: every folder of one platform, in creation order. + pub fn list_folders(&self, platform: &str) -> Vec<&FolderInfo> { + self.folders + .iter() + .filter(|folder| folder.platform == platform) + .collect() + } + + /// `findItemFolderId`: the folder holding this item, `None` at the root. + pub fn find_item_folder_id(&self, item: &ItemRef, platform: &str) -> Option<&str> { + self.folders + .iter() + .filter(|folder| folder.platform == platform) + .find(|folder| { + self.item_order + .get(&folder.id) + .is_some_and(|items| items.iter().any(|entry| entry.same(item))) + }) + .map(|folder| folder.id.as_str()) + } + + /// `getFolderPath`: from the outermost folder down to `folder_id`. + pub fn folder_path(&self, folder_id: Option<&str>) -> Vec<&FolderInfo> { + let mut path = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = non_empty(folder_id); + while let Some(id) = current { + // TS loops forever on a parent cycle; stop at the first repeat. + if !seen.insert(id) { + break; + } + let Some(folder) = self.folder(id) else { + break; + }; + path.insert(0, folder); + current = non_empty(folder.parent_id.as_deref()); + } + path + } + + /// `isFolderDescendant`: `folder_id` is `ancestor_id` or sits under it. + pub fn is_folder_descendant(&self, folder_id: Option<&str>, ancestor_id: &str) -> bool { + let mut seen = BTreeSet::new(); + let mut current = non_empty(folder_id); + while let Some(id) = current { + if id == ancestor_id { + return true; + } + if !seen.insert(id) { + break; + } + current = self + .folder(id) + .and_then(|folder| non_empty(folder.parent_id.as_deref())); + } + false + } + + /// `syncAccounts`: new accounts join the root, gone ones leave every + /// bucket of the platform. Returns whether anything changed. + pub fn sync_accounts(&mut self, account_ids: &[String], platform: &str) -> bool { + let root = root_key(platform); + let mut changed = false; + if !self.item_order.contains_key(&root) { + self.item_order.insert(root.clone(), Vec::new()); + changed = true; + } + let mut keys = vec![root.clone()]; + keys.extend( + self.folders + .iter() + .filter(|folder| folder.platform == platform) + .map(|folder| folder.id.clone()), + ); + let placed: BTreeSet = keys + .iter() + .filter_map(|key| self.item_order.get(key)) + .flatten() + .filter(|item| item.kind == ItemKind::Account) + .map(|item| item.id.clone()) + .collect(); + for id in account_ids { + if !placed.contains(id) { + if let Some(bucket) = self.item_order.get_mut(&root) { + bucket.push(ItemRef::account(id.clone())); + } + changed = true; + } + } + let valid: BTreeSet<&str> = account_ids.iter().map(String::as_str).collect(); + for key in &keys { + if let Some(items) = self.item_order.get_mut(key) { + let before = items.len(); + items.retain(|item| { + item.kind == ItemKind::Folder || valid.contains(item.id.as_str()) + }); + if items.len() != before { + changed = true; + } + } + } + changed + } + + /// `moveItem`. A folder never moves into its own subtree. `insert_index` + /// past the end appends, like `splice`. Returns whether the store changed + /// (always, once the move is allowed: TS saves even a no-op move). + pub fn move_item( + &mut self, + item: &ItemRef, + from_folder_id: Option<&str>, + to_folder_id: Option<&str>, + platform: &str, + insert_index: Option, + ) -> bool { + if item.kind == ItemKind::Folder && self.is_folder_descendant(to_folder_id, &item.id) { + return false; + } + let from_key = bucket_key(from_folder_id, platform); + let to_key = bucket_key(to_folder_id, platform); + if let Some(items) = self.item_order.get_mut(&from_key) { + items.retain(|entry| !entry.same(item)); + } + let target = self.item_order.entry(to_key).or_default(); + match insert_index { + Some(index) => target.insert(index.min(target.len()), item.clone()), + None => target.push(item.clone()), + } + if item.kind == ItemKind::Folder { + if let Some(folder) = self.folders.iter_mut().find(|folder| folder.id == item.id) { + folder.parent_id = to_folder_id.map(str::to_string); + } + } + true + } + + /// `reorderItems`: replaces one bucket. + pub fn reorder_items(&mut self, folder_id: Option<&str>, platform: &str, items: Vec) { + self.item_order + .insert(bucket_key(folder_id, platform), items); + } + + fn folder_tree_ids(&self, root_folder_id: &str) -> BTreeSet { + let mut ids = BTreeSet::new(); + let mut stack = vec![root_folder_id.to_string()]; + while let Some(current) = stack.pop() { + if current.is_empty() || ids.contains(¤t) { + continue; + } + for folder in &self.folders { + if folder.parent_id.as_deref() == Some(current.as_str()) { + stack.push(folder.id.clone()); + } + } + ids.insert(current); + } + ids + } + + fn nested_account_items( + &self, + folder_id: &str, + to_delete: &BTreeSet, + visited: &mut BTreeSet, + out: &mut Vec, + ) { + if !visited.insert(folder_id.to_string()) { + return; + } + for item in self.item_order.get(folder_id).into_iter().flatten() { + match item.kind { + ItemKind::Account => out.push(item.clone()), + ItemKind::Folder if to_delete.contains(&item.id) => { + self.nested_account_items(&item.id, to_delete, visited, out) + } + ItemKind::Folder => {} + } + } + } + + /// `deleteFolder`: the folder and its subtree go; their accounts take the + /// folder's place in its parent. Returns whether the folder existed. + pub fn delete_folder(&mut self, folder_id: &str) -> bool { + let Some(folder) = self.folder(folder_id).cloned() else { + return false; + }; + let parent_key = bucket_key(folder.parent_id.as_deref(), &folder.platform); + self.item_order.entry(parent_key.clone()).or_default(); + let to_delete = self.folder_tree_ids(folder_id); + let mut moved = Vec::new(); + self.nested_account_items(folder_id, &to_delete, &mut BTreeSet::new(), &mut moved); + if let Some(parent_items) = self.item_order.get_mut(&parent_key) { + match parent_items + .iter() + .position(|item| item.kind == ItemKind::Folder && item.id == folder_id) + { + Some(index) => { + parent_items.splice(index..=index, moved); + } + None => parent_items.extend(moved), + } + } + for items in self.item_order.values_mut() { + items.retain(|item| item.kind != ItemKind::Folder || !to_delete.contains(&item.id)); + } + for id in &to_delete { + self.item_order.remove(id); + } + self.folders + .retain(|folder| !to_delete.contains(&folder.id)); + true + } + + /// `renameFolder`. Returns whether the folder existed. + pub fn rename_folder(&mut self, folder_id: &str, new_name: &str) -> bool { + match self + .folders + .iter_mut() + .find(|folder| folder.id == folder_id) + { + Some(folder) => { + folder.name = new_name.to_string(); + true + } + None => false, + } + } +} diff --git a/crates/accshift-app/src/i18n.rs b/crates/accshift-app/src/i18n.rs new file mode 100644 index 00000000..9478471e --- /dev/null +++ b/crates/accshift-app/src/i18n.rs @@ -0,0 +1,337 @@ +//! The i18n runtime, ported from `src/lib/i18n/{index,dictionaries.svelte}.ts` +//! and the boot locale choice of `src/main.ts` / `src/lib/app/bootLocale.ts`. +//! +//! The dictionaries are `i18n/.json`, embedded here. A key missing from a locale falls back to English, then to the key itself, +//! like `translate`. + +use crate::js; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::OnceLock; + +/// A shipped locale. The code is a lowercase BCP 47 tag. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] +pub enum Locale { + #[default] + En, + Es, + Fr, + Pt, + PtBr, + Ru, + Zh, +} + +pub const DEFAULT_LOCALE: Locale = Locale::En; + +/// Every locale, in picker order. +pub const LOCALES: [Locale; 7] = [ + Locale::En, + Locale::Es, + Locale::Fr, + Locale::Pt, + Locale::PtBr, + Locale::Ru, + Locale::Zh, +]; + +/// One entry of the language picker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LanguageOption { + pub code: Locale, + pub label_key: &'static str, +} + +/// Default first, then alphabetical by code: the settings picker order. +pub const LANGUAGE_OPTIONS: [LanguageOption; 7] = [ + LanguageOption { + code: Locale::En, + label_key: "language.english", + }, + LanguageOption { + code: Locale::Es, + label_key: "language.spanish", + }, + LanguageOption { + code: Locale::Fr, + label_key: "language.french", + }, + LanguageOption { + code: Locale::Pt, + label_key: "language.portuguese", + }, + LanguageOption { + code: Locale::PtBr, + label_key: "language.portugueseBrazil", + }, + LanguageOption { + code: Locale::Ru, + label_key: "language.russian", + }, + LanguageOption { + code: Locale::Zh, + label_key: "language.chinese", + }, +]; + +impl Locale { + pub fn code(self) -> &'static str { + match self { + Locale::En => "en", + Locale::Es => "es", + Locale::Fr => "fr", + Locale::Pt => "pt", + Locale::PtBr => "pt-br", + Locale::Ru => "ru", + Locale::Zh => "zh", + } + } + + /// The locale spelled exactly `code`. + pub fn from_code(code: &str) -> Option { + LOCALES.into_iter().find(|locale| locale.code() == code) + } + + /// The embedded dictionary JSON. + pub fn dictionary_json(self) -> &'static str { + match self { + Locale::En => include_str!("../i18n/en.json"), + Locale::Es => include_str!("../i18n/es.json"), + Locale::Fr => include_str!("../i18n/fr.json"), + Locale::Pt => include_str!("../i18n/pt.json"), + Locale::PtBr => include_str!("../i18n/pt-br.json"), + Locale::Ru => include_str!("../i18n/ru.json"), + Locale::Zh => include_str!("../i18n/zh.json"), + } + } +} + +impl std::fmt::Display for Locale { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.code()) + } +} + +impl Serialize for Locale { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.code()) + } +} + +impl<'de> Deserialize<'de> for Locale { + /// Lenient like `normalizeLocale`: anything unknown reads as English. + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + Ok(normalize_locale(Some(&value))) + } +} + +/// `isLocale`. +pub fn is_locale(value: Option<&Value>) -> bool { + js::as_str(value).and_then(Locale::from_code).is_some() +} + +/// `normalizeLocale`: the locale, or English for anything else. +pub fn normalize_locale(value: Option<&Value>) -> Locale { + js::as_str(value) + .and_then(Locale::from_code) + .unwrap_or(DEFAULT_LOCALE) +} + +/// `detectPreferredLocale` over the OS's preferred languages, most preferred +/// first (what `navigator.languages` then `navigator.language` list): the +/// exact tag first, then its base subtag, so "pt-BR" is `pt-br` while +/// "pt-PT", "es-419" and "zh-CN" land on `pt`, `es` and `zh`. +pub fn detect_preferred_locale>(candidates: &[S]) -> Locale { + for raw in candidates { + let normalized = js::trim(raw.as_ref()).to_lowercase(); + if normalized.is_empty() { + continue; + } + if let Some(locale) = Locale::from_code(&normalized) { + return locale; + } + let base = normalized.split('-').next().unwrap_or_default(); + if let Some(locale) = Locale::from_code(base) { + return locale; + } + } + DEFAULT_LOCALE +} + +/// The locale the first frame renders in: the stored settings' language, or +/// the OS's preferred one when no settings exist yet (`getSettings().language` +/// at boot). The TS app also remembers it in `localStorage` so the next start +/// prefetches that dictionary; every dictionary is embedded here, so that +/// part has nothing to do. +pub fn boot_locale>(stored_settings: Option<&Value>, preferred: &[S]) -> Locale { + let raw = js::as_record(stored_settings); + match raw.and_then(|record| record.get("language").map(Some)) { + Some(language) => normalize_locale(language), + None => detect_preferred_locale(preferred), + } +} + +/// A dictionary: message key to template. +pub type Dictionary = HashMap; + +/// The parsed dictionary of `locale`, built once. +pub fn dictionary(locale: Locale) -> &'static Dictionary { + static CACHE: [OnceLock; 7] = [ + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + ]; + let index = LOCALES + .iter() + .position(|candidate| *candidate == locale) + .unwrap_or(0); + CACHE[index].get_or_init(|| { + // A dictionary that fails to parse is an empty one: every key then + // falls back to English. The tests parse each file, so this only + // guards a broken export. + serde_json::from_str(locale.dictionary_json()).unwrap_or_default() + }) +} + +/// A value for a `{param}` placeholder. +#[derive(Debug, Clone, PartialEq)] +pub enum TranslationValue { + Str(String), + Num(f64), + Bool(bool), + /// `null` or `undefined`: prints nothing. + Null, +} + +impl TranslationValue { + /// `String(value)`, empty for `Null`. + pub fn render(&self) -> String { + match self { + TranslationValue::Str(text) => text.clone(), + TranslationValue::Num(number) => js::number_to_string(*number), + TranslationValue::Bool(flag) => flag.to_string(), + TranslationValue::Null => String::new(), + } + } +} + +impl From<&str> for TranslationValue { + fn from(value: &str) -> Self { + TranslationValue::Str(value.to_string()) + } +} +impl From for TranslationValue { + fn from(value: String) -> Self { + TranslationValue::Str(value) + } +} +impl From<&String> for TranslationValue { + fn from(value: &String) -> Self { + TranslationValue::Str(value.clone()) + } +} +impl From for TranslationValue { + fn from(value: bool) -> Self { + TranslationValue::Bool(value) + } +} +impl From for TranslationValue { + fn from(value: f64) -> Self { + TranslationValue::Num(value) + } +} +macro_rules! from_int { + ($($ty:ty),*) => {$( + impl From<$ty> for TranslationValue { + fn from(value: $ty) -> Self { + TranslationValue::Num(value as f64) + } + } + )*}; +} +from_int!(i32, i64, u32, u64, usize); +impl> From> for TranslationValue { + fn from(value: Option) -> Self { + value.map_or(TranslationValue::Null, Into::into) + } +} + +/// Named parameters for [`translate_with`]. +pub type TranslationParams<'a> = [(&'a str, TranslationValue)]; + +/// `formatTemplate`: every `{word}` (ASCII word characters) becomes its +/// parameter, or nothing when the parameter is absent or null. Without +/// parameters the template is returned as is. +pub fn format_template(template: &str, params: Option<&TranslationParams<'_>>) -> String { + let Some(params) = params else { + return template.to_string(); + }; + let mut out = String::with_capacity(template.len()); + let mut rest = template; + while let Some(open) = rest.find('{') { + out.push_str(&rest[..open]); + let after = &rest[open + 1..]; + let word_len = after + .bytes() + .take_while(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') + .count(); + if word_len > 0 && after.as_bytes().get(word_len) == Some(&b'}') { + let key = &after[..word_len]; + if let Some((_, value)) = params.iter().find(|(name, _)| *name == key) { + out.push_str(&value.render()); + } + rest = &after[word_len + 1..]; + } else { + out.push('{'); + rest = after; + } + } + out.push_str(rest); + out +} + +/// The template of `key`: the locale's, else English, else the key. +pub fn template(locale: Locale, key: &str) -> &str { + lookup(dictionary(locale), key) +} + +fn lookup<'a>(dict: &'a Dictionary, key: &'a str) -> &'a str { + dict.get(key) + .or_else(|| dictionary(DEFAULT_LOCALE).get(key)) + .map_or(key, String::as_str) +} + +/// `translate(locale, key)`. +pub fn translate(locale: Locale, key: &str) -> String { + template(locale, key).to_string() +} + +/// `translate(locale, key, params)`. +pub fn translate_with(locale: Locale, key: &str, params: &TranslationParams<'_>) -> String { + format_template(template(locale, key), Some(params)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// TS mocks a French dictionary without "app.loading"; the lookup must + /// serve the real English string for it, not the key. + #[test] + fn falls_back_to_en_for_a_key_missing_from_a_locale() { + let mut fr = dictionary(Locale::Fr).clone(); + fr.remove("app.loading"); + assert_eq!(lookup(&fr, "app.loading"), "Loading..."); + assert_eq!( + lookup(&fr, "app.loading"), + translate(Locale::En, "app.loading") + ); + assert_eq!(lookup(&fr, "common.close"), "Fermer"); + } +} diff --git a/crates/accshift-app/src/jobs.rs b/crates/accshift-app/src/jobs.rs new file mode 100644 index 00000000..23e5e2d7 --- /dev/null +++ b/crates/accshift-app/src/jobs.rs @@ -0,0 +1,149 @@ +//! Backend calls off the UI thread. +//! +//! A small fixed pool of threads runs closures against the shared backend. +//! Each call hands back a [`Pending`] the UI polls once per frame; when a +//! result lands, the pool calls the wake callback so an idle UI repaints and +//! picks it up (the egui shell passes `Context::request_repaint`). No async +//! runtime: every backend method blocks, so threads are the whole story. + +use crate::backend::Backend; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +type Task = Box; +type Wake = Arc; + +/// The pool. Dropping it lets queued jobs finish, then joins the threads. +pub struct Jobs { + backend: Arc, + queue: Option>, + workers: Vec>, + wake: Wake, +} + +impl Jobs { + /// `threads` workers (at least one) serving `backend`. `wake` runs on a + /// worker thread after each result is delivered. + pub fn new( + backend: Arc, + threads: usize, + wake: impl Fn() + Send + Sync + 'static, + ) -> Self { + let (queue, tasks) = mpsc::channel::(); + let tasks = Arc::new(Mutex::new(tasks)); + let workers = (0..threads.max(1)) + .map(|index| { + let tasks = Arc::clone(&tasks); + let backend = Arc::clone(&backend); + std::thread::Builder::new() + .name(format!("accshift-job-{index}")) + .spawn(move || loop { + // The lock is held only while taking a task, never + // while running it. + let next = tasks.lock().map(|rx| rx.recv()); + match next { + // A panicking call drops its sender, so its + // Pending reads Gone; the worker lives on. + Ok(Ok(task)) => { + let _ = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + task(backend.as_ref()) + })); + } + _ => break, + } + }) + .expect("spawn job worker") + }) + .collect(); + Self { + backend, + queue: Some(queue), + workers, + wake: Arc::new(wake), + } + } + + /// The backend the pool serves, for a synchronous call where blocking is + /// fine (tests, a CLI, shutdown). + pub fn backend(&self) -> &Arc { + &self.backend + } + + /// Queues `call` and returns its pending result. + pub fn run(&self, call: F) -> Pending + where + T: Send + 'static, + F: FnOnce(&dyn Backend) -> T + Send + 'static, + { + let (tx, rx) = mpsc::channel(); + let wake = Arc::clone(&self.wake); + let task: Task = Box::new(move |backend| { + // A dropped Pending means nobody waits for this result any more. + if tx.send(call(backend)).is_ok() { + wake(); + } + }); + if let Some(queue) = &self.queue { + // Workers only stop once the queue is dropped, in Drop. + let _ = queue.send(task); + } + Pending { rx, done: false } + } +} + +impl Drop for Jobs { + fn drop(&mut self) { + self.queue = None; + for worker in self.workers.drain(..) { + let _ = worker.join(); + } + } +} + +/// What a polled [`Pending`] holds. +#[derive(Debug, PartialEq, Eq)] +pub enum Poll { + /// Still running. + Waiting, + /// The result, handed out once. + Ready(T), + /// Already taken, or the worker died before answering (the call panicked). + Gone, +} + +/// A backend call in flight. Poll it from the UI thread. +#[derive(Debug)] +pub struct Pending { + rx: Receiver, + done: bool, +} + +impl Pending { + /// Non-blocking: the result once, then [`Poll::Gone`]. + pub fn poll(&mut self) -> Poll { + if self.done { + return Poll::Gone; + } + match self.rx.try_recv() { + Ok(value) => { + self.done = true; + Poll::Ready(value) + } + Err(TryRecvError::Empty) => Poll::Waiting, + Err(TryRecvError::Disconnected) => { + self.done = true; + Poll::Gone + } + } + } + + /// Blocks until the result arrives. `None` if the call panicked. + pub fn wait(self) -> Option { + if self.done { + return None; + } + self.rx.recv().ok() + } +} diff --git a/crates/accshift-app/src/js.rs b/crates/accshift-app/src/js.rs new file mode 100644 index 00000000..fd0e1141 --- /dev/null +++ b/crates/accshift-app/src/js.rs @@ -0,0 +1,307 @@ +//! The few JavaScript semantics the ported stores lean on, so a value the TS +//! code normalized one way is normalized the same way here: `String.trim`, +//! `String.slice` on UTF-16 units, `Number(value)`, `Math.round` and +//! `Boolean(value)`. + +use serde_json::Value; + +/// The characters `String.prototype.trim` strips: ECMAScript WhiteSpace plus +/// LineTerminator. Not Rust's `char::is_whitespace`, which keeps U+FEFF and +/// strips U+0085. +pub fn is_js_whitespace(c: char) -> bool { + matches!( + c, + '\u{9}' | '\u{a}' | '\u{b}' | '\u{c}' | '\u{d}' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +/// `value.trim()`. +pub fn trim(value: &str) -> &str { + value.trim_matches(is_js_whitespace) +} + +/// `value.length`: UTF-16 code units. +pub fn utf16_len(value: &str) -> usize { + value.encode_utf16().count() +} + +/// `value.slice(0, max)`: the first `max` UTF-16 code units. A surrogate pair +/// cut in half becomes U+FFFD, where JS would keep the lone surrogate. +pub fn utf16_prefix(value: &str, max: usize) -> String { + if utf16_len(value) <= max { + return value.to_string(); + } + let units: Vec = value.encode_utf16().take(max).collect(); + String::from_utf16_lossy(&units) +} + +/// `Boolean(value)`, `None` standing for `undefined`. +pub fn truthy(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => false, + Some(Value::Bool(flag)) => *flag, + Some(Value::Number(number)) => number.as_f64().is_some_and(|n| n != 0.0 && !n.is_nan()), + Some(Value::String(text)) => !text.is_empty(), + Some(Value::Array(_)) | Some(Value::Object(_)) => true, + } +} + +/// `value !== false`, `None` standing for `undefined`. +pub fn not_false(value: Option<&Value>) -> bool { + !matches!(value, Some(Value::Bool(false))) +} + +/// `a ?? b`. +pub fn nullish_or<'a>(a: Option<&'a Value>, b: Option<&'a Value>) -> Option<&'a Value> { + match a { + None | Some(Value::Null) => b, + some => some, + } +} + +/// `value` as a record, the way the stores' `asRecord` reads it: anything but +/// a plain object is empty. +pub fn as_record(value: Option<&Value>) -> Option<&serde_json::Map> { + match value { + Some(Value::Object(map)) => Some(map), + _ => None, + } +} + +/// `typeof value === "string" ? value : None`. +pub fn as_str(value: Option<&Value>) -> Option<&str> { + match value { + Some(Value::String(text)) => Some(text), + _ => None, + } +} + +/// `Number(value)`, `None` standing for `undefined`. +pub fn to_number(value: Option<&Value>) -> f64 { + match value { + None => f64::NAN, + Some(value) => value_to_number(value), + } +} + +fn value_to_number(value: &Value) -> f64 { + match value { + Value::Null => 0.0, + Value::Bool(flag) => f64::from(u8::from(*flag)), + Value::Number(number) => number.as_f64().unwrap_or(f64::NAN), + Value::String(text) => string_to_number(text), + // Number([]) is Number("") and Number([x]) is Number(String(x)); two + // or more entries join with a comma, which never parses. + Value::Array(items) => match items.as_slice() { + [] => 0.0, + [only] => match only { + Value::Null => 0.0, + Value::Bool(_) | Value::Object(_) => f64::NAN, + other => value_to_number(other), + }, + _ => f64::NAN, + }, + Value::Object(_) => f64::NAN, + } +} + +/// ECMAScript `StringToNumber`. +pub fn string_to_number(text: &str) -> f64 { + let text = trim(text); + if text.is_empty() { + return 0.0; + } + match text { + "Infinity" | "+Infinity" => return f64::INFINITY, + "-Infinity" => return f64::NEG_INFINITY, + _ => {} + } + for (prefix, radix) in [ + ("0x", 16), + ("0X", 16), + ("0o", 8), + ("0O", 8), + ("0b", 2), + ("0B", 2), + ] { + if let Some(digits) = text.strip_prefix(prefix) { + if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) { + return f64::NAN; + } + return digits.chars().fold(0.0, |acc, c| { + acc * f64::from(radix) + f64::from(c.to_digit(radix).unwrap_or(0)) + }); + } + } + if is_decimal_literal(text) { + text.parse::().unwrap_or(f64::NAN) + } else { + f64::NAN + } +} + +/// `[+-] (digits [. digits?] | . digits) [(e|E) [+-] digits]`. +fn is_decimal_literal(text: &str) -> bool { + let bytes = text.as_bytes(); + let mut index = 0; + if matches!(bytes.first(), Some(b'+' | b'-')) { + index += 1; + } + let int_start = index; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + let int_digits = index - int_start; + let mut frac_digits = 0; + if index < bytes.len() && bytes[index] == b'.' { + index += 1; + let frac_start = index; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + frac_digits = index - frac_start; + } + if int_digits == 0 && frac_digits == 0 { + return false; + } + if index < bytes.len() && matches!(bytes[index], b'e' | b'E') { + index += 1; + if matches!(bytes.get(index), Some(b'+' | b'-')) { + index += 1; + } + let exp_start = index; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + if index == exp_start { + return false; + } + } + index == bytes.len() +} + +/// `Math.round`: nearest integer, halves toward positive infinity. +pub fn round(value: f64) -> f64 { + let floor = value.floor(); + if value - floor >= 0.5 { + floor + 1.0 + } else { + floor + } +} + +/// The stores' `clampInt`: `Number(value)`, rounded and clamped, or +/// `fallback` when it is not finite. +pub fn clamp_int(value: Option<&Value>, min: i64, max: i64, fallback: i64) -> i64 { + let numeric = to_number(value); + if !numeric.is_finite() { + return fallback; + } + let rounded = round(numeric).clamp(min as f64, max as f64); + rounded as i64 +} + +/// `String(value)` for a number, as the i18n templates print one. +pub fn number_to_string(value: f64) -> String { + if value.is_nan() { + return "NaN".to_string(); + } + if value.is_infinite() { + return if value > 0.0 { "Infinity" } else { "-Infinity" }.to_string(); + } + if value == value.trunc() && value.abs() < 1e21 { + return format!("{}", value as i128); + } + format!("{value}") +} + +/// `/^\d+$/.test(value)`: ASCII digits only, at least one. +pub fn is_ascii_digits(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// `/^[0-9a-fA-F]{n}$/` for one of `lengths`, after a leading `#`. +pub fn is_hex_color(value: &str, lengths: &[usize]) -> bool { + value.strip_prefix('#').is_some_and(|hex| { + lengths.contains(&hex.len()) && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) +} + +/// A random 64-bit value from the standard library's per-process hasher keys +/// and the clock, for ids that only need to be unique, as `Math.random` and +/// `crypto.randomUUID` are used in the stores. +pub fn random_u64() -> u64 { + use std::collections::hash_map::RandomState; + use std::hash::{BuildHasher, Hasher}; + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let mut hasher = RandomState::new().build_hasher(); + hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed)); + if let Ok(elapsed) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + hasher.write_u128(elapsed.as_nanos()); + } + hasher.finish() +} + +/// `n.toString(36)`. +pub fn to_base36(mut value: u64) -> String { + const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + if value == 0 { + return "0".to_string(); + } + let mut out = Vec::new(); + while value > 0 { + out.push(DIGITS[(value % 36) as usize]); + value /= 36; + } + out.reverse(); + String::from_utf8(out).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn number_follows_js() { + assert!(to_number(None).is_nan()); + assert_eq!(to_number(Some(&json!(null))), 0.0); + assert_eq!(to_number(Some(&json!(true))), 1.0); + assert_eq!(to_number(Some(&json!(" 42 "))), 42.0); + assert_eq!(to_number(Some(&json!(""))), 0.0); + assert_eq!(to_number(Some(&json!("0x10"))), 16.0); + assert!(to_number(Some(&json!("inf"))).is_nan()); + assert!(to_number(Some(&json!("1e"))).is_nan()); + assert_eq!(to_number(Some(&json!(".5"))), 0.5); + assert_eq!(to_number(Some(&json!([]))), 0.0); + assert_eq!(to_number(Some(&json!(["7"]))), 7.0); + assert!(to_number(Some(&json!([1, 2]))).is_nan()); + assert!(to_number(Some(&json!({}))).is_nan()); + } + + #[test] + fn round_and_clamp_follow_js() { + assert_eq!(round(2.5), 3.0); + assert_eq!(round(-2.5), -2.0); + assert_eq!(round(0.499_999_999_999_999_94), 0.0); + assert_eq!(clamp_int(Some(&json!(140.6)), 75, 150, 100), 141); + assert_eq!(clamp_int(Some(&json!("abc")), 75, 150, 100), 100); + assert_eq!(clamp_int(Some(&json!(null)), 0, 90, 7), 0); + } + + #[test] + fn trim_and_slice_follow_js() { + assert_eq!(trim("\u{feff} a \u{3000}"), "a"); + assert_eq!(trim("\u{85}a"), "\u{85}a"); + assert_eq!(utf16_prefix("abc", 2), "ab"); + assert_eq!(utf16_len("\u{1f600}"), 2); + } +} diff --git a/crates/accshift-app/src/legacy_web_storage/chromium.rs b/crates/accshift-app/src/legacy_web_storage/chromium.rs new file mode 100644 index 00000000..31c3c6e5 --- /dev/null +++ b/crates/accshift-app/src/legacy_web_storage/chromium.rs @@ -0,0 +1,203 @@ +//! The WebView2 (Chromium) `localStorage` LevelDB. +//! +//! Layout, from Chromium `main` at f990ecd0892e +//! (`components/services/storage/dom_storage/leveldb/local_storage_leveldb.h:39-56`, +//! `local_storage_leveldb.cc:135-156`): +//! +//! - `VERSION`, `META:`, `METAACCESS:`: bookkeeping, +//! skipped. +//! - `_` + storage key + `0x00` + script key: one item. A first-party storage +//! key serializes as the bare origin, `http://tauri.localhost`, with no +//! trailing slash (`third_party/blink/common/storage_key/storage_key.cc:725-736`, +//! `SerializeForLocalStorage`). +//! +//! Script keys and values both start with a format byte +//! (`third_party/blink/renderer/modules/storage/cached_storage_area.cc:37`, +//! decoding at `:748-768`): `0x00` UTF-16LE, `0x01` Latin-1, anything else +//! corrupt. An empty byte string is the empty string. +//! +//! The folder is never opened in place: opening a LevelDB can write recovery +//! files. Every file except `LOCK` and the text `LOG`s is read into +//! `rusty_leveldb`'s in-memory env and the database is opened there, so the +//! `.log` replay and any compaction happen in memory only. + +use super::{is_legacy_key, LegacyRead}; +use rusty_leveldb::env::Env; +use rusty_leveldb::{LdbIterator, MemEnv, Options, DB}; +use std::io::Write; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::Path; +use std::rc::Rc; + +/// The origins the app was served from, preferred first. +pub const ORIGINS: [&str; 2] = ["http://tauri.localhost", "https://tauri.localhost"]; + +/// Splits an item key into its storage key and encoded script key; `None` +/// for the bookkeeping keys. +pub fn split_item_key(key: &[u8]) -> Option<(&[u8], &[u8])> { + let rest = key.strip_prefix(b"_")?; + let separator = rest.iter().position(|byte| *byte == 0)?; + Some((&rest[..separator], &rest[separator + 1..])) +} + +/// Decodes a script key or a value: format byte, then UTF-16LE or Latin-1. +/// Unpaired surrogates (which a Rust string cannot hold) become U+FFFD and +/// the second field says so. +pub fn decode_string(bytes: &[u8]) -> Result<(String, bool), String> { + let Some((format, payload)) = bytes.split_first() else { + return Ok((String::new(), false)); + }; + match format { + 0 => { + if payload.len() % 2 != 0 { + return Err(format!("odd UTF-16 length {}", payload.len())); + } + let units: Vec = payload + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect(); + match String::from_utf16(&units) { + Ok(text) => Ok((text, false)), + Err(_) => Ok((String::from_utf16_lossy(&units), true)), + } + } + 1 => Ok(( + payload.iter().map(|byte| char::from(*byte)).collect(), + false, + )), + other => Err(format!("unknown format byte {other}")), + } +} + +/// Encodes a string the way Chromium stores it: Latin-1 when every char fits, +/// UTF-16LE otherwise. For tests and fixtures. +pub fn encode_string(text: &str, force_utf16: bool) -> Vec { + if !force_utf16 && text.chars().all(|c| u32::from(c) < 0x100) { + let mut out = vec![1]; + out.extend(text.chars().map(|c| u32::from(c) as u8)); + return out; + } + let mut out = vec![0]; + for unit in text.encode_utf16() { + out.extend_from_slice(&unit.to_le_bytes()); + } + out +} + +/// The item key of `script_key` under `origin`, encoded as Chromium writes it. +pub fn item_key(origin: &str, script_key: &str, force_utf16: bool) -> Vec { + let mut key = vec![b'_']; + key.extend_from_slice(origin.as_bytes()); + key.push(0); + key.extend(encode_string(script_key, force_utf16)); + key +} + +/// Reads the legacy keys from a copy of the LevelDB folder `dir`. +pub fn read_leveldb(dir: &Path) -> LegacyRead { + read_leveldb_origins(dir, &ORIGINS) +} + +/// [`read_leveldb`] for other origins, preferred first: the parity bench +/// serves the page from `http://localhost:1420`. +pub fn read_leveldb_origins(dir: &Path, origins: &[&str]) -> LegacyRead { + let mut result = LegacyRead::default(); + if !dir.is_dir() { + result.notes.push(format!( + "no WebView2 localStorage folder at {}", + dir.display() + )); + return result; + } + match catch_unwind(AssertUnwindSafe(|| read_copy(dir, origins, &mut result))) { + Ok(Ok(())) => {} + Ok(Err(reason)) => result.notes.push(format!("{}: {reason}", dir.display())), + Err(_) => result + .notes + .push(format!("{}: the LevelDB reader panicked", dir.display())), + } + result +} + +fn read_copy(dir: &Path, origins: &[&str], result: &mut LegacyRead) -> Result<(), String> { + let env: Rc> = Rc::new(Box::new(MemEnv::new())); + let db_path = Path::new("db"); + let entries = std::fs::read_dir(dir).map_err(|e| format!("cannot list: {e}"))?; + let mut copied = 0usize; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if matches!(name, "LOCK" | "LOG" | "LOG.old") || !entry.path().is_file() { + continue; + } + // std opens with FILE_SHARE_READ | WRITE | DELETE on Windows, so a + // running WebView2 does not block the read. + let bytes = match std::fs::read(entry.path()) { + Ok(bytes) => bytes, + Err(error) => { + result.notes.push(format!("cannot read {name}: {error}")); + continue; + } + }; + let mut file = env + .open_writable_file(&db_path.join(name)) + .map_err(|e| format!("in-memory copy of {name}: {e}"))?; + file.write_all(&bytes) + .map_err(|e| format!("in-memory copy of {name}: {e}"))?; + copied += 1; + } + if copied == 0 { + return Err("empty folder".to_string()); + } + + let options = Options { + env, + create_if_missing: false, + ..Options::default() + }; + let mut db = DB::open(db_path, options).map_err(|e| format!("cannot open: {e}"))?; + let mut iter = db.new_iter().map_err(|e| format!("cannot iterate: {e}"))?; + let mut by_origin: Vec> = vec![Vec::new(); origins.len()]; + while let Some((key, value)) = iter.next() { + let Some((origin, script_key)) = split_item_key(&key) else { + continue; + }; + let Some(slot) = origins.iter().position(|o| o.as_bytes() == origin) else { + continue; + }; + let Ok((script_key, _)) = decode_string(script_key) else { + continue; + }; + if !is_legacy_key(&script_key) { + continue; + } + match decode_string(&value) { + Ok((text, lossy)) => { + if lossy { + result.notes.push(format!( + "{}: unpaired surrogates in {script_key} replaced", + origins[slot] + )); + } + by_origin[slot].push((script_key, text)); + } + Err(reason) => result + .notes + .push(format!("{}: skipped {script_key}: {reason}", origins[slot])), + } + } + for (slot, items) in by_origin.into_iter().enumerate() { + if items.is_empty() { + continue; + } + result + .notes + .push(format!("{}: {} legacy keys", origins[slot], items.len())); + for (key, value) in items { + result.storage.entry(key).or_insert(value); + } + } + Ok(()) +} diff --git a/crates/accshift-app/src/legacy_web_storage/mod.rs b/crates/accshift-app/src/legacy_web_storage/mod.rs new file mode 100644 index 00000000..14c5bbc2 --- /dev/null +++ b/crates/accshift-app/src/legacy_web_storage/mod.rs @@ -0,0 +1,246 @@ +//! The WebView `localStorage` of installs older than v1.0.0, read from disk. +//! +//! Before v1.0.0 the Tauri app kept settings, folders, notes, colours and +//! caches only in the WebView's `localStorage`; the Svelte app copies them +//! into the backend stores in `initializeClientStorage` +//! (`src/lib/storage/clientStorage.ts`). The egui app has no WebView, so it +//! reads that storage itself and hands the 12 keys of +//! [`LEGACY_LOCAL_STORAGE_KEYS`] to [`crate::storage::initialize`]. +//! +//! Boot use: [`needs_read`] on the boot snapshot first (no I/O), [`read`] only +//! when it says so. Nothing here panics or fails the boot: a missing folder, +//! a locked or corrupt database or an undecodable value gives an empty or +//! partial map, with the reason in [`LegacyRead::notes`]. +//! +//! # Where the storage lives +//! +//! Every path starts at Tauri's data folder for the identifier, which the +//! egui app shares (`crate::real::context`): Tauri 2.11 forces the webview +//! data directory to `BaseDirectory::LocalData` + identifier on Windows and +//! Linux (`tauri-2.11.5/src/manager/webview.rs:534-545`), that is +//! `dirs::data_local_dir()` (`dirs-6.0.0/src/win.rs:11`, `lin.rs:11-12`). +//! +//! - Windows, WebView2 (Chromium): `\EBWebView\Default\Local Storage\leveldb` +//! (WebView2's user data folder layout, given the data directory by +//! `vendor/wry/src/webview2/mod.rs:312-330`). Origin +//! `http://tauri.localhost`: `tauri-2.11.5/src/manager/mod.rs:339-343`, +//! `use_https_scheme` false by default and unset in every released +//! `tauri.conf.json`; `https://tauri.localhost` is read as a fallback. +//! Format in [`chromium`]. +//! - Linux, WebKitGTK: wry builds a `WebsiteDataManager` with +//! `base-data-directory` = the data folder +//! (`vendor/wry/src/webkitgtk/web_context.rs:32-39`). Origin +//! `tauri://localhost` (`tauri-2.11.5/src/manager/mod.rs:339-343`). +//! Paths in [`webkit_paths::linux_candidates`]. +//! - macOS, WKWebView: wry uses `WKWebsiteDataStore.defaultDataStore` unless +//! a data store identifier is set (`vendor/wry/src/wkwebview/mod.rs:228-243`; +//! the app sets none). Same origin. Paths in +//! [`webkit_paths::macos_candidates`]. +//! +//! Old names: the identifier was `com.zazaswitcher.desktop` until d90d838 +//! (2026-02-14), but every release tag, v0.1.0 included, ships +//! `com.accshift.desktop` and the `accshift_*` keys, so no other profile is +//! read. Every v0.x release was Windows only. +//! +//! [`LEGACY_LOCAL_STORAGE_KEYS`]: crate::storage::LEGACY_LOCAL_STORAGE_KEYS + +#[cfg(windows)] +pub mod chromium; +pub mod webkit_paths; +#[cfg(not(windows))] +mod webkit_sqlite; + +use crate::storage::{LegacyLocalStorage, CLIENT_STORE_IDS, LEGACY_LOCAL_STORAGE_KEYS}; +use crate::types::ClientStorageSnapshot; +use accshift_core::AppContext; +use std::path::{Path, PathBuf}; + +/// What [`read`] found. `storage` only ever holds the 12 legacy keys. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct LegacyRead { + pub storage: LegacyLocalStorage, + /// One line per place looked at or value skipped: paths, key names and + /// reasons, never a value. + pub notes: Vec, +} + +impl LegacyRead { + /// Adds the keys of `other` that are not there yet, and its notes. + pub fn merge_missing(&mut self, other: LegacyRead) { + for (key, value) in other.storage { + self.storage.entry(key).or_insert(value); + } + self.notes.extend(other.notes); + } +} + +/// True when `key` is one of the 12 legacy keys. +pub fn is_legacy_key(key: &str) -> bool { + LEGACY_LOCAL_STORAGE_KEYS.iter().any(|(_, k)| *k == key) +} + +/// The cheap check, no I/O: true when a client store is missing or `null` in +/// the boot snapshot, the only case where [`crate::storage::initialize`] +/// would migrate anything (`ClientStorage::migrate_legacy` skips every store +/// that has a value). +pub fn needs_read(snapshot: &ClientStorageSnapshot) -> bool { + CLIENT_STORE_IDS + .iter() + .any(|id| snapshot.stores.get(*id).is_none_or(|value| value.is_null())) +} + +/// The boot's `LegacyStorageRead` step ([`crate::lifecycle::BootStep`]): +/// [`read`] when the stores under `ctx` need it. It runs before the log +/// session begins, so the snapshot is loaded without the log line of +/// `load_client_storage_snapshot`; a snapshot that cannot be read gives +/// `None`, as [`crate::storage::initialize`] would migrate nothing then. +pub fn read_for_boot(ctx: &dyn AppContext, identifier: &str) -> Option { + read_for_boot_with(ctx, || read(identifier)) +} + +/// [`read_for_boot`] with the reader given. +pub fn read_for_boot_with( + ctx: &dyn AppContext, + read: impl FnOnce() -> LegacyRead, +) -> Option { + let snapshot = accshift_core::storage::load_client_storage_snapshot(ctx).ok()?; + needs_read(&snapshot).then(read) +} + +/// Reads the legacy `localStorage` of the app `identifier` (the Tauri bundle +/// identifier, [`crate::real::BUNDLE_IDENTIFIER`]) for this OS. +pub fn read(identifier: &str) -> LegacyRead { + let Some(data_local) = dirs::data_local_dir() else { + return note_only("no local data folder for this user"); + }; + read_in(&data_local, identifier) +} + +/// [`read`] with the per-user root given: `%LOCALAPPDATA%` on Windows, +/// `$XDG_DATA_HOME` (or `~/.local/share`) on Linux, `~/Library/Application +/// Support` on macOS. Only Windows and Linux use it; macOS reads under +/// `~/Library/WebKit`, found from the home folder. +#[cfg(windows)] +pub fn read_in(data_local: &Path, identifier: &str) -> LegacyRead { + chromium::read_leveldb(&webview2_leveldb_dir(data_local, identifier)) +} + +/// See the Windows version. +#[cfg(not(windows))] +pub fn read_in(data_local: &Path, identifier: &str) -> LegacyRead { + let candidates = if cfg!(target_os = "macos") { + match dirs::home_dir() { + Some(home) => webkit_paths::macos_candidates(&home, identifier), + None => return note_only("no home folder for this user"), + } + } else { + webkit_paths::linux_candidates(&data_local.join(identifier)) + }; + read_webkit_candidates(&candidates) +} + +/// The WebView2 localStorage folder under `data_local`. +pub fn webview2_leveldb_dir(data_local: &Path, identifier: &str) -> PathBuf { + data_local + .join(identifier) + .join("EBWebView") + .join("Default") + .join("Local Storage") + .join("leveldb") +} + +/// The cache folders of the Tauri app's WebView2 user data folder: HTTP, +/// code and shader caches, and the components WebView2 downloads. The egui +/// app never loads WebView2, so they only hold disk space (90 MB on a +/// months-old install); WebView2 rebuilds them if a Tauri build runs again. +/// `Default/Local Storage`, which [`read`] needs, is not in the list. +pub const WEBVIEW2_CACHE_DIRS: &[&str] = &[ + "Default/Cache", + "Default/Code Cache", + "Default/GPUCache", + "Default/DawnGraphiteCache", + "Default/DawnWebGPUCache", + "Default/Service Worker/CacheStorage", + "Default/Service Worker/ScriptCache", + "GrShaderCache", + "GraphiteDawnCache", + "ShaderCache", + "Subresource Filter", + "component_crx_cache", + "extensions_crx_cache", + "Speech Recognition", + "hyphen-data", +]; + +/// Deletes [`WEBVIEW2_CACHE_DIRS`] under `/EBWebView`, the +/// app's local data folder (identifier included). Returns the bytes freed +/// and one line per folder that could not go. +pub fn prune_webview2_caches(app_local_data: &Path) -> (u64, Vec) { + let root = app_local_data.join("EBWebView"); + let mut freed = 0; + let mut failures = Vec::new(); + for rel in WEBVIEW2_CACHE_DIRS { + let dir = rel + .split('/') + .fold(root.clone(), |dir, part| dir.join(part)); + if !dir.is_dir() { + continue; + } + let size = dir_size(&dir); + match std::fs::remove_dir_all(&dir) { + Ok(()) => freed += size, + Err(error) => failures.push(format!("{}: {error}", dir.display())), + } + } + (freed, failures) +} + +fn dir_size(dir: &Path) -> u64 { + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + entries + .filter_map(Result::ok) + .map(|entry| match entry.file_type() { + Ok(kind) if kind.is_dir() => dir_size(&entry.path()), + Ok(_) => entry.metadata().map_or(0, |meta| meta.len()), + Err(_) => 0, + }) + .sum() +} + +/// Reads each WebKit database that exists, newest layout first; a key found +/// in an earlier one wins. +#[cfg(not(windows))] +pub fn read_webkit_candidates(candidates: &[PathBuf]) -> LegacyRead { + let mut result = LegacyRead::default(); + let wanted: Vec<&str> = LEGACY_LOCAL_STORAGE_KEYS.iter().map(|(_, k)| *k).collect(); + for path in candidates { + if !path.is_file() { + result.notes.push(format!("no file at {}", path.display())); + continue; + } + let read = match webkit_sqlite::read_item_table(path, &wanted) { + Ok(rows) => LegacyRead { + notes: std::iter::once(format!( + "{}: {} legacy keys", + path.display(), + rows.items.len() + )) + .chain(rows.skipped) + .collect(), + storage: rows.items.into_iter().collect(), + }, + Err(reason) => note_only(&format!("{}: {reason}", path.display())), + }; + result.merge_missing(read); + } + result +} + +fn note_only(note: &str) -> LegacyRead { + LegacyRead { + storage: LegacyLocalStorage::new(), + notes: vec![note.to_string()], + } +} diff --git a/crates/accshift-app/src/legacy_web_storage/webkit_paths.rs b/crates/accshift-app/src/legacy_web_storage/webkit_paths.rs new file mode 100644 index 00000000..a40d9e58 --- /dev/null +++ b/crates/accshift-app/src/legacy_web_storage/webkit_paths.rs @@ -0,0 +1,148 @@ +//! Where WebKit keeps the `localStorage` of `tauri://localhost`. +//! +//! WebKit `main` at 1557a275c0cf, `Source/`: +//! +//! - Two layouts, chosen by `UnifiedOriginStorageLevel` +//! (`WebKit/NetworkProcess/storage/OriginStorageManager.cpp:519-543`): +//! - `None`: the "custom" path `/.localstorage` +//! (`LocalStorageManager.cpp:41`, `60-67`, `85-95`). +//! - `Basic` and up: `///LocalStorage/localstorage.sqlite3` +//! (`LocalStorageManager.cpp:42`, `97-103`; `OriginStorageManager.cpp:208-215`, +//! default bucket rooted at the origin folder, `:677`). On first access +//! WebKit moves an old-layout file there (`:530-536`), so both can exist +//! on disk across WebKit versions. +//! - Origin folder names: base64url without padding of +//! SHA-256(UTF-8 origin string + 8-byte salt), salt read from +//! `/salt` +//! (`NetworkStorageManager.cpp:114-131`, `:243-244`; `WTF/wtf/text/Base64.h:140-142`; +//! `WTF/wtf/FileSystem.h:152`). For the app's own page the top origin and +//! the origin are the same. +//! - Origin string `tauri://localhost`, database identifier +//! `tauri_localhost_0` (no port is port 0): +//! `WebCore/page/SecurityOriginData.cpp:46-60`, `:117-121`. +//! +//! Linux (WebKitGTK through `webkit2gtk-4.1`, `webkit2gtk-sys-2.0.2/Cargo.toml:58`, +//! not the 2022 GLib API): level `None` +//! (`WebKit/UIProcess/WebsiteData/glib/WebsiteDataStoreGLib.cpp:79-86`), the +//! localStorage directory `/localstorage` +//! (`WebKit/UIProcess/API/glib/WebKitWebsiteDataManager.cpp:242-243`, +//! `WebKit/UIProcess/WebsiteData/WebsiteDataStore.cpp:2624-2629`). The +//! unified layout under `/storage` +//! (`WebsiteDataStore.cpp:2549-2552`) is checked too, for a WebKitGTK 6 build. +//! +//! macOS (WKWebView, `defaultDataStore`, app not sandboxed: `entitlements` +//! is `null` in `tauri.conf.json`): level `Standard` +//! (`WebKit/UIProcess/WebsiteData/Cocoa/WebsiteDataStoreCocoa.mm:939-942`), +//! website data under `~/Library/WebKit//WebsiteData` +//! (`:590-604`, bundle id `:119-129`), general storage `Default` +//! (`:385-390`), old localStorage directory `LocalStorage` (`:470-475`). + +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +/// The origin the app page has under WebKit. +pub const ORIGIN: &str = "tauri://localhost"; +/// `SecurityOriginData::databaseIdentifier` of [`ORIGIN`]. +pub const DATABASE_IDENTIFIER: &str = "tauri_localhost_0"; + +/// The folder name WebKit gives `origin` under a general storage directory +/// whose salt is `salt`. +pub fn origin_folder_name(origin: &str, salt: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(origin.as_bytes()); + hasher.update(salt); + base64url_no_pad(&hasher.finalize()) +} + +/// `///LocalStorage/localstorage.sqlite3` when +/// `/salt` holds the 8-byte salt. +pub fn unified_path(general_storage: &Path) -> Option { + let salt = std::fs::read(general_storage.join("salt")).ok()?; + if salt.len() != 8 { + return None; + } + let folder = origin_folder_name(ORIGIN, &salt); + Some( + general_storage + .join(&folder) + .join(&folder) + .join("LocalStorage") + .join("localstorage.sqlite3"), + ) +} + +/// `

/tauri_localhost_0.localstorage`. +pub fn old_path(local_storage_dir: &Path) -> PathBuf { + local_storage_dir.join(format!("{DATABASE_IDENTIFIER}.localstorage")) +} + +/// Linux, newest layout first. `base_data` is the webview data directory: +/// `$XDG_DATA_HOME/` or `~/.local/share/`. +pub fn linux_candidates(base_data: &Path) -> Vec { + let mut out = Vec::new(); + out.extend(unified_path(&base_data.join("storage"))); + out.push(old_path(&base_data.join("localstorage"))); + out +} + +/// macOS, newest layout first, under `~/Library/WebKit//WebsiteData`. +pub fn macos_candidates(home: &Path, identifier: &str) -> Vec { + let website_data = home + .join("Library") + .join("WebKit") + .join(identifier) + .join("WebsiteData"); + let mut out = Vec::new(); + out.extend(unified_path(&website_data.join("Default"))); + out.push(old_path(&website_data.join("LocalStorage"))); + out +} + +fn base64url_no_pad(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + let chars = chunk.len() + 1; + for i in 0..chars { + out.push(char::from(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize])); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64url_matches_rfc4648_vectors_without_padding() { + assert_eq!(base64url_no_pad(b""), ""); + assert_eq!(base64url_no_pad(b"f"), "Zg"); + assert_eq!(base64url_no_pad(b"fo"), "Zm8"); + assert_eq!(base64url_no_pad(b"foo"), "Zm9v"); + assert_eq!(base64url_no_pad(b"foob"), "Zm9vYg"); + assert_eq!(base64url_no_pad(&[0xfb, 0xff]), "-_8"); + } + + #[test] + fn origin_folder_hashes_origin_then_salt() { + // SHA-256("abc"), FIPS 180-2 vector, in base64url. + assert_eq!( + origin_folder_name("ab", b"c"), + "ungWv48Bz-pBQUDeXa4iI7ADYaOWF3qctBD_YfIAFa0" + ); + } + + #[test] + fn origin_folder_is_a_43_char_sha256() { + let name = origin_folder_name(ORIGIN, &[0; 8]); + assert_eq!(name.len(), 43); + assert!(!name.contains(['+', '/', '='])); + } +} diff --git a/crates/accshift-app/src/legacy_web_storage/webkit_sqlite.rs b/crates/accshift-app/src/legacy_web_storage/webkit_sqlite.rs new file mode 100644 index 00000000..c45bab1f --- /dev/null +++ b/crates/accshift-app/src/legacy_web_storage/webkit_sqlite.rs @@ -0,0 +1,136 @@ +//! Reads WebKit's localStorage `ItemTable` from a copy of the sqlite file. +//! +//! Schema (WebKit `main` at 1557a275c0cf, +//! `Source/WebKit/NetworkProcess/storage/SQLiteStorageArea.cpp:47-48`): +//! `ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB NOT NULL)`, +//! the old `.localstorage` files with the same table. Keys are bound as text, +//! values as the UTF-16 code units of the string (`bindBlob`, `:399`; read +//! back with `columnBlobAsString`, `:356-357`), little-endian on every +//! machine WebKit runs on here. A key stored as a blob or a value stored as +//! text is accepted too. +//! +//! Self-contained (std and rusqlite only): `tests/legacy_web_storage.rs` +//! compiles this file by path so the WebKit fixtures run on Windows, where +//! the app itself never links sqlite. + +use rusqlite::types::ValueRef; +use rusqlite::{Connection, OpenFlags}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// What one database held. +#[derive(Debug, Default)] +pub struct ItemRows { + /// `(key, value)` for each wanted key. + pub items: Vec<(String, String)>, + /// One line per wanted key whose value could not be decoded or was + /// repaired. + pub skipped: Vec, +} + +/// Copies `db` (and its `-wal`, if any) to a fresh temp folder, opens the +/// copy and returns the rows whose key is in `wanted`. The original is only +/// read. +pub fn read_item_table(db: &Path, wanted: &[&str]) -> Result { + let temp = TempDir::new().map_err(|e| format!("temp folder: {e}"))?; + let copy = temp.0.join("localstorage.sqlite3"); + std::fs::copy(db, ©).map_err(|e| format!("cannot copy: {e}"))?; + let wal = PathBuf::from(format!("{}-wal", db.display())); + if wal.is_file() { + std::fs::copy(&wal, temp.0.join("localstorage.sqlite3-wal")) + .map_err(|e| format!("cannot copy the -wal: {e}"))?; + } + let connection = Connection::open_with_flags( + ©, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|e| format!("cannot open: {e}"))?; + let mut statement = connection + .prepare("SELECT key, value FROM ItemTable") + .map_err(|e| format!("no ItemTable: {e}"))?; + let mut rows = statement + .query([]) + .map_err(|e| format!("cannot query: {e}"))?; + let mut out = ItemRows::default(); + loop { + let row = match rows.next() { + Ok(Some(row)) => row, + Ok(None) => break, + Err(error) => { + out.skipped.push(format!("stopped reading: {error}")); + break; + } + }; + let Ok(Ok((key, _))) = row.get_ref(0).map(decode) else { + continue; + }; + if !wanted.contains(&key.as_str()) { + continue; + } + match row.get_ref(1).map(decode) { + Ok(Ok((value, lossy))) => { + if lossy { + out.skipped + .push(format!("unpaired surrogates in {key} replaced")); + } + out.items.push((key, value)); + } + Ok(Err(reason)) => out.skipped.push(format!("skipped {key}: {reason}")), + Err(error) => out.skipped.push(format!("skipped {key}: {error}")), + } + } + Ok(out) +} + +/// Text as UTF-8, a blob as UTF-16LE. Unpaired surrogates, which a Rust +/// string cannot hold, become U+FFFD and the flag says so. +fn decode(value: ValueRef<'_>) -> Result<(String, bool), String> { + match value { + ValueRef::Text(bytes) => String::from_utf8(bytes.to_vec()) + .map(|text| (text, false)) + .map_err(|_| "bad UTF-8".into()), + ValueRef::Blob(bytes) => { + if bytes.len() % 2 != 0 { + return Err(format!("odd UTF-16 length {}", bytes.len())); + } + let units: Vec = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect(); + Ok(match String::from_utf16(&units) { + Ok(text) => (text, false), + Err(_) => (String::from_utf16_lossy(&units), true), + }) + } + ValueRef::Null => Err("null".into()), + ValueRef::Integer(_) | ValueRef::Real(_) => Err("not a string".into()), + } +} + +/// A temp folder removed on drop. +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> std::io::Result { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!( + "accshift-legacy-webkit-{}-{nanos}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&path)?; + Ok(Self(path)) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/crates/accshift-app/src/lib.rs b/crates/accshift-app/src/lib.rs new file mode 100644 index 00000000..a4a3d805 --- /dev/null +++ b/crates/accshift-app/src/lib.rs @@ -0,0 +1,60 @@ +//! UI-free logic of the accshift egui port. +//! +//! - [`backend::Backend`]: one blocking method per Tauri command the frontend +//! calls, with the same payloads ([`types`]) and error strings ([`error`]). +//! - [`mock::MockBackend`]: the TS mock's data, for parity work. +//! - [`real::RealBackend`]: the real backend on `accshift-core`, the commands +//! in [`real::PORTED`] filled, the others still "not ported". +//! - [`jobs`]: runs calls off the UI thread and wakes the UI on each result. +//! - [`dispatch`]: the same commands by name, with JSON arguments. +//! - [`storage`]: the raw client stores, their saves and refreshes. +//! - [`stores::ClientStores`]: every client store, typed ([`settings`], +//! [`folders`], [`personas`] and the small ones), over the storage. +//! - [`i18n`]: the seven dictionaries and `translate`. +//! - [`accounts`]: account loading per tab, profile caches, avatars, priming +//! and the settings refresh actions, driven through [`jobs`]. +//! - [`display`]: folder navigation, search and the flat list of cards a grid +//! draws. +//! - [`actions`]: dialogs, card actions, navigation, personas, the command +//! palette, the keyboard model and toasts, as state plus effects. +//! - [`secure`]: the PIN, the inactivity blur, the window activity and the +//! secure screen; [`streamer`]: streamer mode. +//! - [`bulk_edit`], [`platform_add_flow`], [`platform_shell`] and +//! [`cs2_bridge`]: the Steam bulk edit, the add-account flow, the platform +//! shell state and the CS2 bridge queue. +//! - [`lifecycle`]: boot and shutdown order, the updater, single instance, +//! deep links and process control of the Tauri plugins. + +pub mod accounts; +pub mod actions; +pub mod backend; +pub mod bulk_edit; +pub mod cs2_bridge; +pub mod dispatch; +pub mod display; +pub mod drag; +pub mod error; +pub mod folders; +pub mod i18n; +pub mod jobs; +pub mod js; +pub mod legacy_web_storage; +pub mod lifecycle; +pub mod menu; +pub mod mock; +pub mod personas; +pub mod platform_add_flow; +pub mod platform_shell; +pub mod platforms; +pub mod real; +pub mod secure; +pub mod settings; +pub mod storage; +pub mod stores; +pub mod streamer; +pub mod text; +pub mod time; +pub mod types; + +pub use backend::{Backend, COMMANDS}; +pub use error::{BackendError, BackendResult}; diff --git a/crates/accshift-app/src/lifecycle/app_shell.rs b/crates/accshift-app/src/lifecycle/app_shell.rs new file mode 100644 index 00000000..b193c1ec --- /dev/null +++ b/crates/accshift-app/src/lifecycle/app_shell.rs @@ -0,0 +1,336 @@ +//! `src/lib/app/useAppLifecycle.svelte.ts`: what the shell does once the +//! storage is read (`initializeAppShell`) and when another process changed +//! the stores (`refreshExternalStorageState`), as ordered plans. The shell +//! owns the state; these functions decide. + +use crate::accounts::loading::is_platform_usable; +use crate::accounts::{Toast, ToastKind}; +use crate::platform_shell::initial_active_tab; +use crate::platforms::{PlatformRegistry, RuntimeOs}; +use crate::settings::AppSettings; +use crate::storage::{ + CLIENT_STORE_ACCOUNT_CARD_COLORS, CLIENT_STORE_ACCOUNT_CARD_NOTES, CLIENT_STORE_FOLDERS, + CLIENT_STORE_FOLDER_CARD_COLORS, CLIENT_STORE_PERSONAS, CLIENT_STORE_SETTINGS, + CLIENT_STORE_VIEW_MODE, STORAGE_TARGET_APP_CONFIG_LOCAL, STORAGE_TARGET_APP_CONFIG_PORTABLE, + STORAGE_TARGET_CUSTOM_THEMES, +}; +use crate::text::Text; +use crate::types::SettingsSnapshot; + +/// `semverCore`: the first `x.y.z` in the version, else the version. +pub fn semver_core(version: &str) -> String { + let bytes = version.as_bytes(); + for start in 0..bytes.len() { + if let Some(end) = match_triplet(bytes, start) { + return version[start..end].to_string(); + } + } + version.to_string() +} + +/// `\d+\.\d+\.\d+` anchored at `start`, greedy like the regex. +fn match_triplet(bytes: &[u8], start: usize) -> Option { + let mut index = start; + for part in 0..3 { + let digits = bytes[index..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + if digits == 0 { + return None; + } + index += digits; + if part < 2 { + if bytes.get(index) != Some(&b'.') { + return None; + } + index += 1; + } + } + Some(index) +} + +/// The migration toast of `initializeAppShell`. +pub fn migration_toast(migration_result: &str) -> Option { + if migration_result == "migrated" { + Some(Toast::new( + Text::key("toast.legacyConfigMigrated"), + ToastKind::Default, + )) + } else if migration_result.starts_with("error:") { + Some(Toast::new( + Text::key("toast.legacyConfigMigrationFailed"), + ToastKind::Default, + )) + } else { + None + } +} + +/// `enableDetectedPlatformsOnFirstRun`, after `platform_detect_installed` +/// answered (an error counts as an empty list). `None` when settings were +/// already stored and nothing was asked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FirstRunPlatforms { + /// `setDetectedPlatforms(usable)`. + pub detected: Vec, + /// The settings to save then `refreshSettings`, when any is usable. + pub enable: Option<(Vec, String)>, +} + +pub fn first_run_platforms( + detected: &[String], + registry: &PlatformRegistry, + os: RuntimeOs, +) -> FirstRunPlatforms { + let usable: Vec = detected + .iter() + .filter(|id| is_platform_usable(registry, id, os)) + .cloned() + .collect(); + let enable = usable.first().map(|first| (usable.clone(), first.clone())); + FirstRunPlatforms { + detected: usable, + enable, + } +} + +/// The `trackSettingsSnapshot` payload of `initializeAppShell`. +pub fn settings_snapshot(settings: &AppSettings) -> SettingsSnapshot { + let text = |value: serde_json::Result| { + value + .ok() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_default() + }; + SettingsSnapshot { + ui_language: settings.language.code().to_string(), + enabled_platforms: settings.enabled_platforms.clone(), + personas_enabled: settings.personas_enabled, + pin_enabled: settings.pin_enabled, + cli_enabled: settings.cli_enabled, + deep_links_enabled: settings.deep_links_enabled, + streamer_mode: text(serde_json::to_value(settings.streamer_mode)), + animations: text(serde_json::to_value(settings.animations)), + } +} + +/// The last step of `initializeAppShell`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InitialLoad { + /// `loadAccounts(false, false, false, false, true)`. + LoadAccounts, + /// `navigation.refreshCurrentItems()` then `queueGridPadding()`. + RefreshItems, +} + +pub fn initial_load(registry: &PlatformRegistry, active_tab: &str, os: RuntimeOs) -> InitialLoad { + if is_platform_usable(registry, active_tab, os) { + InitialLoad::LoadAccounts + } else { + InitialLoad::RefreshItems + } +} + +/// `initializeAppShell` in order, for the shell to run step by step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShellInitStep { + /// The migration toast, if any. + MigrationToast, + /// `applyCustomThemePayloads(boot.customThemes)`. + ApplyCustomThemes, + RefreshSettings, + /// `setAppVersion(semverCore(getVersion()))`. + SetAppVersion, + SetRuntimeOs, + /// [`first_run_platforms`], only without stored settings. + EnableDetectedPlatformsOnFirstRun, + /// `setActiveTab(initial_active_tab)` when it differs. + PickInitialTab, + MarkBootReady, + TrackSettingsSnapshot, + /// [`initial_load`]. + InitialLoad, +} + +pub const SHELL_INIT_ORDER: [ShellInitStep; 10] = [ + ShellInitStep::MigrationToast, + ShellInitStep::ApplyCustomThemes, + ShellInitStep::RefreshSettings, + ShellInitStep::SetAppVersion, + ShellInitStep::SetRuntimeOs, + ShellInitStep::EnableDetectedPlatformsOnFirstRun, + ShellInitStep::PickInitialTab, + ShellInitStep::MarkBootReady, + ShellInitStep::TrackSettingsSnapshot, + ShellInitStep::InitialLoad, +]; + +/// `SETTINGS_TARGETS`. +pub const SETTINGS_TARGETS: [&str; 2] = [CLIENT_STORE_SETTINGS, STORAGE_TARGET_CUSTOM_THEMES]; +/// `CARD_COLOR_TARGETS`. +pub const CARD_COLOR_TARGETS: [&str; 2] = [ + CLIENT_STORE_ACCOUNT_CARD_COLORS, + CLIENT_STORE_FOLDER_CARD_COLORS, +]; +/// `GRID_TARGETS`. +pub const GRID_TARGETS: [&str; 6] = [ + CLIENT_STORE_FOLDERS, + CLIENT_STORE_ACCOUNT_CARD_NOTES, + CLIENT_STORE_ACCOUNT_CARD_COLORS, + CLIENT_STORE_FOLDER_CARD_COLORS, + CLIENT_STORE_VIEW_MODE, + CLIENT_STORE_SETTINGS, +]; +/// `APP_CONFIG_TARGETS`. +pub const APP_CONFIG_TARGETS: [&str; 2] = [ + STORAGE_TARGET_APP_CONFIG_PORTABLE, + STORAGE_TARGET_APP_CONFIG_LOCAL, +]; + +/// One step of `refreshExternalStorageState`, in the order TS runs them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefreshStep { + /// `log_app_event(info, frontend.storage.refresh, "Detected external + /// storage changes", {changed, activeTab})`. + Log { + details: String, + }, + LoadCustomThemes, + RefreshSettings, + /// `addFlow.cancel()` (only when a flow is open). + CancelAddFlow, + ResetVisiblePrimeState, + ClearLoaderForPlatformChange, + SetActiveTab(String), + ClearCurrentFolder, + /// `replaceHistoryState({tab, folderId: null, showSettings: false})`. + ReplaceHistory(String), + SyncViewMode, + BumpCardColorVersion, + BumpCardNoteVersion, + RefreshPersonas, + RefreshCurrentItems, + PrepareVisibleAccounts, + QueueGridPadding, + /// `loadAccounts(force, false, false, warnings, false)`; `loadAccounts(true)` + /// after a tab change has `warnings` false too. + LoadAccounts { + force: bool, + warnings: bool, + }, +} + +/// `refreshExternalStorageState` after `refreshClientStorageIfChanged` +/// answered `changed`. `settings` are the settings once refreshed (the +/// stores already hold the new snapshot); `add_flow_open` is +/// `addFlow.flow != null`. +pub fn plan_external_refresh( + changed: &[String], + settings: &AppSettings, + active_tab: &str, + registry: &PlatformRegistry, + os: RuntimeOs, + add_flow_open: bool, +) -> Vec { + let mut steps = Vec::new(); + if changed.is_empty() { + return steps; + } + // `JSON.stringify({ changed, activeTab })`, keys in that order (a + // `serde_json::Map` would sort them). + let details = format!( + "{{\"changed\":{},\"activeTab\":{}}}", + serde_json::Value::from(changed.to_vec()), + serde_json::Value::from(active_tab) + ); + steps.push(RefreshStep::Log { details }); + let touched = |target: &str| changed.iter().any(|c| c == target); + let any_of = |targets: &[&str]| targets.iter().any(|t| touched(t)); + let mut tab = active_tab.to_string(); + let mut tab_changed = false; + + if touched(STORAGE_TARGET_CUSTOM_THEMES) { + steps.push(RefreshStep::LoadCustomThemes); + } + if any_of(&SETTINGS_TARGETS) { + steps.push(RefreshStep::RefreshSettings); + if !settings.enabled_platforms.contains(&tab) || !is_platform_usable(registry, &tab, os) { + if add_flow_open { + steps.push(RefreshStep::CancelAddFlow); + } + steps.push(RefreshStep::ResetVisiblePrimeState); + steps.push(RefreshStep::ClearLoaderForPlatformChange); + tab = initial_active_tab(registry, settings, os); + steps.push(RefreshStep::SetActiveTab(tab.clone())); + tab_changed = true; + steps.push(RefreshStep::ClearCurrentFolder); + steps.push(RefreshStep::ReplaceHistory(tab.clone())); + } + } + if touched(CLIENT_STORE_VIEW_MODE) { + steps.push(RefreshStep::SyncViewMode); + } + if any_of(&CARD_COLOR_TARGETS) { + steps.push(RefreshStep::BumpCardColorVersion); + } + if touched(CLIENT_STORE_ACCOUNT_CARD_NOTES) { + steps.push(RefreshStep::BumpCardNoteVersion); + } + if touched(CLIENT_STORE_PERSONAS) { + steps.push(RefreshStep::RefreshPersonas); + } + if any_of(&GRID_TARGETS) { + steps.push(RefreshStep::RefreshCurrentItems); + steps.push(RefreshStep::PrepareVisibleAccounts); + steps.push(RefreshStep::QueueGridPadding); + } + + let capabilities = registry.definition(&tab).map(|def| &def.capabilities); + if tab_changed { + if is_platform_usable(registry, &tab, os) { + steps.push(RefreshStep::LoadAccounts { + force: true, + warnings: false, + }); + } else { + steps.push(RefreshStep::RefreshCurrentItems); + steps.push(RefreshStep::QueueGridPadding); + } + } else { + let external: Vec<&str> = capabilities + .map(|c| c.external_data_stores.iter().map(|s| s.as_ref()).collect()) + .unwrap_or_default(); + if any_of(&APP_CONFIG_TARGETS) || any_of(&external) { + steps.push(RefreshStep::LoadAccounts { + force: true, + warnings: capabilities.is_some_and(|c| c.account_warnings), + }); + } + } + steps +} + +/// The `externalStorageRefreshInFlight` guard plus the focus and visibility +/// handlers. +#[derive(Debug, Default)] +pub struct ExternalRefreshGate { + in_flight: bool, +} + +impl ExternalRefreshGate { + /// `handleWindowFocus`, or `handleVisibilityChange` with the window + /// visible: true when a refresh should start now. + pub fn begin(&mut self) -> bool { + if self.in_flight { + return false; + } + self.in_flight = true; + true + } + + /// The `finally`. + pub fn end(&mut self) { + self.in_flight = false; + } +} diff --git a/crates/accshift-app/src/lifecycle/autostart.rs b/crates/accshift-app/src/lifecycle/autostart.rs new file mode 100644 index 00000000..c1bd2a1c --- /dev/null +++ b/crates/accshift-app/src/lifecycle/autostart.rs @@ -0,0 +1,242 @@ +//! Launch at system startup: the `HKCU\...\Run` value the Windows build +//! writes so Windows starts accshift minimized to the tray at sign-in. The +//! parsing and the decision of what to write live here behind a trait so +//! tests never touch the real registry; the egui shell reads [`TRAY_FLAG`] +//! off the command line to start hidden. + +/// `HKEY_CURRENT_USER` subkey Windows runs at sign-in, per user, no admin. +pub const RUN_KEY: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +/// The value name we own under it. A user is unlikely to keep another Run +/// value by this name, so disabling removes it whole. +pub const VALUE_NAME: &str = "Accshift"; +/// Passed so the launch goes straight to the tray instead of opening the +/// window in the user's face at every sign-in. +pub const TRAY_FLAG: &str = "--tray"; + +/// The Run value for this executable: the quoted path then the tray flag, +/// as Windows parses `lpCmdLine` (a quoted first token is the program). +pub fn run_command(exe: &str) -> String { + format!("\"{exe}\" {TRAY_FLAG}") +} + +/// The executable a Run value launches: the first quoted token, or the first +/// whitespace-separated token when it is unquoted. +pub fn command_exe(value: &str) -> Option { + let trimmed = value.trim_start(); + let exe = if let Some(rest) = trimmed.strip_prefix('"') { + rest.split('"').next().unwrap_or("") + } else { + trimmed.split_whitespace().next().unwrap_or("") + }; + (!exe.is_empty()).then(|| exe.to_string()) +} + +/// Whether a stored Run value launches this executable. Windows paths are +/// case-insensitive, and the flags are ignored so a value we wrote with a +/// different flag set still reads as ours. +pub fn points_at(value: &str, exe: &str) -> bool { + command_exe(value).is_some_and(|stored| stored.eq_ignore_ascii_case(exe)) +} + +/// The `HKCU\...\Run` values, behind a trait so tests never touch the real +/// registry. +pub trait RunRegistry { + /// The string value, `None` when the key or the value is absent. + fn get(&self, name: &str) -> Result, String>; + fn set(&mut self, name: &str, value: &str) -> Result<(), String>; + /// Remove the value; `Ok` when it was already absent. + fn remove(&mut self, name: &str) -> Result<(), String>; +} + +/// Whether autostart is on for this executable: a value under [`VALUE_NAME`] +/// that points at `exe`. A stale value from another path reads as off, so the +/// toggle reflects what would actually launch. +pub fn is_enabled(registry: &dyn RunRegistry, exe: &str) -> Result { + Ok(registry + .get(VALUE_NAME)? + .is_some_and(|value| points_at(&value, exe))) +} + +/// Make the Run value match `enabled`: write our command when enabling (even +/// over a stale value), remove our value when disabling. Idempotent: a value +/// already equal to what we would write is left untouched. +pub fn wire(registry: &mut dyn RunRegistry, enabled: bool, exe: &str) -> Result<(), String> { + let current = registry.get(VALUE_NAME)?; + if enabled { + let want = run_command(exe); + if current.as_deref() != Some(want.as_str()) { + registry.set(VALUE_NAME, &want)?; + } + } else if current.is_some() { + registry.remove(VALUE_NAME)?; + } + Ok(()) +} + +/// `dunce::simplified(current_exe())`, the same path shape the deep-link +/// registration writes, so both agree on what "this executable" is. +#[cfg(windows)] +pub fn current_exe_path() -> std::io::Result { + let exe = std::fs::canonicalize(std::env::current_exe()?)?; + Ok(dunce::simplified(&exe).display().to_string()) +} + +/// Only the shipped `accshift-gui` release exe manages the Run value: a debug +/// or renamed build would register a path the installed app never launches, +/// the same guard the scheme registration uses. +#[cfg(windows)] +pub fn manages_current_exe() -> bool { + if cfg!(debug_assertions) { + return false; + } + std::env::current_exe() + .ok() + .and_then(|exe| exe.file_stem().map(|s| s.to_string_lossy().into_owned())) + .as_deref() + == Some("accshift-gui") +} + +/// The real `HKCU\...\Run` key, on `windows-registry` like the deep links. +#[cfg(windows)] +pub struct WindowsRun; + +#[cfg(windows)] +impl RunRegistry for WindowsRun { + fn get(&self, name: &str) -> Result, String> { + let Ok(key) = windows_registry::CURRENT_USER.open(RUN_KEY) else { + return Ok(None); + }; + match key.get_string(name) { + Ok(value) => Ok(Some(value)), + // The value is absent (or not a string): nothing stored for us. + Err(_) => Ok(None), + } + } + + fn set(&mut self, name: &str, value: &str) -> Result<(), String> { + windows_registry::CURRENT_USER + .create(RUN_KEY) + .and_then(|key| key.set_string(name, value)) + .map_err(|e| e.to_string()) + } + + fn remove(&mut self, name: &str) -> Result<(), String> { + // `create` opens the always-present Run key with write access. + let key = windows_registry::CURRENT_USER + .create(RUN_KEY) + .map_err(|e| e.to_string())?; + match key.remove_value(name) { + Ok(()) => Ok(()), + // Already gone is success. + Err(_) => Ok(()), + } + } +} + +/// Set the real Run value for this executable. +#[cfg(windows)] +pub fn apply(enabled: bool) -> Result<(), String> { + let exe = current_exe_path().map_err(|e| e.to_string())?; + wire(&mut WindowsRun, enabled, &exe) +} + +/// Whether the real Run value points at this executable. +#[cfg(windows)] +pub fn enabled() -> bool { + let Ok(exe) = current_exe_path() else { + return false; + }; + is_enabled(&WindowsRun, &exe).unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[derive(Default)] + struct Fake { + values: HashMap, + } + + impl RunRegistry for Fake { + fn get(&self, name: &str) -> Result, String> { + Ok(self.values.get(name).cloned()) + } + fn set(&mut self, name: &str, value: &str) -> Result<(), String> { + self.values.insert(name.to_string(), value.to_string()); + Ok(()) + } + fn remove(&mut self, name: &str) -> Result<(), String> { + self.values.remove(name); + Ok(()) + } + } + + const EXE: &str = r"C:\Program Files\Accshift\accshift-gui.exe"; + + #[test] + fn run_command_quotes_the_path_and_adds_the_flag() { + assert_eq!( + run_command(EXE), + r#""C:\Program Files\Accshift\accshift-gui.exe" --tray"# + ); + } + + #[test] + fn command_exe_reads_the_program_from_either_form() { + assert_eq!(command_exe(&run_command(EXE)).as_deref(), Some(EXE)); + assert_eq!( + command_exe(r"C:\a\b.exe --tray").as_deref(), + Some(r"C:\a\b.exe") + ); + assert_eq!(command_exe(" ").as_deref(), None); + } + + #[test] + fn points_at_ignores_case_and_flags() { + assert!(points_at(&run_command(EXE), &EXE.to_uppercase())); + assert!(points_at(r#""C:\a\b.exe""#, r"c:\a\b.exe")); + assert!(!points_at(r#""C:\other\accshift-gui.exe" --tray"#, EXE)); + } + + #[test] + fn wire_enables_disables_and_is_idempotent() { + let mut reg = Fake::default(); + assert!(!is_enabled(®, EXE).unwrap()); + + wire(&mut reg, true, EXE).unwrap(); + assert_eq!( + reg.get(VALUE_NAME).unwrap().as_deref(), + Some(run_command(EXE).as_str()) + ); + assert!(is_enabled(®, EXE).unwrap()); + + // A second enable writes nothing new. + let before = reg.values.clone(); + wire(&mut reg, true, EXE).unwrap(); + assert_eq!(reg.values, before); + + wire(&mut reg, false, EXE).unwrap(); + assert!(reg.get(VALUE_NAME).unwrap().is_none()); + assert!(!is_enabled(®, EXE).unwrap()); + + // Disabling again is a no-op. + wire(&mut reg, false, EXE).unwrap(); + assert!(reg.get(VALUE_NAME).unwrap().is_none()); + } + + #[test] + fn enabling_overwrites_a_stale_path() { + let mut reg = Fake::default(); + reg.set(VALUE_NAME, r#""C:\Old\accshift-gui.exe" --tray"#) + .unwrap(); + // The stored path is not ours, so it reads as off. + assert!(!is_enabled(®, EXE).unwrap()); + wire(&mut reg, true, EXE).unwrap(); + assert_eq!( + reg.get(VALUE_NAME).unwrap().as_deref(), + Some(run_command(EXE).as_str()) + ); + } +} diff --git a/crates/accshift-app/src/lifecycle/boot.rs b/crates/accshift-app/src/lifecycle/boot.rs new file mode 100644 index 00000000..5fc16b93 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/boot.rs @@ -0,0 +1,276 @@ +//! The boot helpers of the webview side: `src/lib/app/bootMarks.ts`, +//! `bootLocale.ts`, `bootPayload.ts`, the log queue of `src/main.ts`, +//! `src/lib/shared/appLogger.ts`, `src/lib/app/telemetryClient.ts` and the +//! close request of `src/lib/app/useDocumentListeners.ts`. + +use crate::backend::Backend; +use crate::error::BackendResult; +use crate::js; +use crate::platforms::descriptors::PlatformDescriptor; +use crate::platforms::PlatformRegistry; +use crate::types::{BootPayload, SettingsSnapshot}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +/// main.ts: `finishBoot("load")` this long after `load` when `boot-ready` +/// never came. +pub const FINISH_BOOT_FALLBACK: Duration = Duration::from_millis(1500); +/// main.ts: the boot log buffer is written at boot end or after this. +pub const BOOT_LOG_FLUSH: Duration = Duration::from_millis(5000); +/// boot.rs `spawn_boot_failsafe`: show the window anyway after this. +pub const BOOT_FAILSAFE: Duration = Duration::from_millis(5000); +/// The failsafe's warn line. +pub const BOOT_FAILSAFE_SOURCE: &str = "backend.boot-failsafe"; +pub const BOOT_FAILSAFE_MESSAGE: &str = + "Rust failsafe triggered after 5000ms; forcing main window visibility"; +/// main.ts `queueLog`: message and details caps, in UTF-16 units. +pub const LOG_MESSAGE_MAX: usize = 512; +pub const LOG_DETAILS_MAX: usize = 16384; + +/// `queueLog`'s truncation. +pub fn truncate_log(message: &str, details: Option<&str>) -> (String, Option) { + ( + js::utf16_prefix(message, LOG_MESSAGE_MAX), + details.map(|details| js::utf16_prefix(details, LOG_DETAILS_MAX)), + ) +} + +/// `bootMarks.ts`: milestones in milliseconds since the process origin, +/// rounded to 0.1 ms; the first write wins. +#[derive(Debug, Clone)] +pub struct BootMarks { + origin: Instant, + marks: BTreeMap, +} + +impl BootMarks { + pub fn new(origin: Instant) -> Self { + Self { + origin, + marks: BTreeMap::new(), + } + } + + fn round(ms: f64) -> f64 { + (ms * 10.0).round() / 10.0 + } + + fn elapsed_ms(&self, now: Instant) -> f64 { + now.duration_since(self.origin).as_secs_f64() * 1000.0 + } + + /// `markBoot(name)`. + pub fn mark(&mut self, name: &str, now: Instant) { + let at = Self::round(self.elapsed_ms(now)); + self.marks.entry(name.to_string()).or_insert(at); + } + + /// `sinceBoot(name)`. + pub fn since(&self, name: &str, now: Instant) -> Option { + let at = *self.marks.get(name)?; + Some(Self::round(self.elapsed_ms(now) - at)) + } + + /// `bootMarks()`: the `marks` argument of `finish_boot`. + pub fn to_value(&self) -> Value { + Value::Object( + self.marks + .iter() + .map(|(name, at)| (name.clone(), Value::from(*at))) + .collect(), + ) + } +} + +/// `BOOT_LOCALE_KEY` of bootLocale.ts. +pub const BOOT_LOCALE_KEY: &str = "accshift_boot_locale"; + +/// `rememberBootLocale`: the value to write, when it differs from the one +/// stored. The egui build embeds every dictionary, so nothing reads it back; +/// the key is kept for a Tauri build sharing the profile. +pub fn remember_boot_locale(stored: Option<&str>, locale: &str) -> Option { + (stored != Some(locale)).then(|| locale.to_string()) +} + +/// `REDACTED_KEYS` of appLogger.ts, compared lowercased. +pub const REDACTED_KEYS: [&str; 14] = [ + "username", + "requestedusername", + "autologinuser", + "currentaccountfromloginusers", + "email", + "targetemail", + "currentaccount", + "steamid", + "accountid", + "profileid", + "targetprofileid", + "currentprofileid", + "uuid", + "userid", +]; + +/// `maskValue`. +pub fn mask_value(value: &Value) -> Value { + match value { + Value::String(text) if js::utf16_len(text) > 2 => { + Value::String(format!("{}***", js::utf16_prefix(text, 2))) + } + Value::Array(items) => Value::String(format!("[{} items]", items.len())), + _ => Value::String("***".into()), + } +} + +/// `redactObject`: masks the listed keys, recursing into nested objects +/// (not into arrays). +pub fn redact_object(object: &serde_json::Map) -> serde_json::Map { + object + .iter() + .map(|(key, value)| { + let value = if REDACTED_KEYS.contains(&key.to_lowercase().as_str()) { + mask_value(value) + } else if let Value::Object(inner) = value { + Value::Object(redact_object(inner)) + } else { + value.clone() + }; + (key.clone(), value) + }) + .collect() +} + +/// `serializeLogValue` for JSON values: strings as they are, objects +/// redacted, the rest as JSON. A top-level array goes through +/// `redactObject` too, as in TS (`typeof [] === "object"`), so it comes out +/// as an object keyed by index. Object keys come out sorted (TS keeps +/// insertion order, integer-like keys first). +pub fn serialize_log_value(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Object(object) => Value::Object(redact_object(object)).to_string(), + Value::Array(items) => { + let indexed = items + .iter() + .enumerate() + .map(|(index, item)| (index.to_string(), item.clone())) + .collect(); + Value::Object(redact_object(&indexed)).to_string() + } + other => other.to_string(), + } +} + +/// `logAppEvent`: details serialized, failures swallowed. +pub fn log_app_event( + backend: &dyn Backend, + level: &str, + source: &str, + message: &str, + details: Option<&Value>, +) { + let details = details + .filter(|value| !value.is_null()) + .map(serialize_log_value); + let _ = backend.log_app_event(level, source, message, details.as_deref()); +} + +/// `bootPayload.ts`: `fetchBootPayload` asks once (`get_boot_payload`) and +/// fills the registry with the user platforms; `getBootPayload` reads the +/// cached answer. The document-side prefetch has no egui equivalent. +#[derive(Debug, Default)] +pub struct BootPayloadCache { + payload: Option, +} + +impl BootPayloadCache { + /// `fetchBootPayload`. A later call asks again, as TS. + pub fn fetch( + &mut self, + backend: &dyn Backend, + registry: &mut PlatformRegistry, + ) -> BackendResult<&BootPayload> { + let payload = backend.get_boot_payload()?; + let descriptors: Vec = payload + .user_platforms + .loaded + .iter() + .filter_map(PlatformDescriptor::from_core) + .collect(); + registry.register_user_platforms(&descriptors); + Ok(self.payload.insert(payload)) + } + + /// `getBootPayload`. + pub fn get(&self) -> Option<&BootPayload> { + self.payload.as_ref() + } +} + +/// telemetryClient.ts `trackAccountAddStarted`, fire and forget (the mock +/// rejects every `telemetry_` call; the error is dropped, as TS). +pub fn track_account_add_started(backend: &dyn Backend, platform_id: &str) { + let _ = backend.telemetry_track_account_add_started(platform_id); +} + +/// telemetryClient.ts `trackAccountAddCancelled`. +pub fn track_account_add_cancelled(backend: &dyn Backend, platform_id: &str) { + let _ = backend.telemetry_track_account_add_cancelled(platform_id); +} + +/// telemetryClient.ts `trackAccountAdded`. +pub fn track_account_added(backend: &dyn Backend, platform_id: &str) { + let _ = backend.telemetry_track_account_added(platform_id); +} + +/// telemetryClient.ts `trackOperationFailed`. +pub fn track_operation_failed( + backend: &dyn Backend, + operation: &str, + error_code: &str, + platform_id: Option<&str>, +) { + let _ = backend.telemetry_track_operation_failed(operation, error_code, platform_id); +} + +/// telemetryClient.ts `trackUpdate`, fire and forget. +pub fn track_update( + backend: &dyn Backend, + stage: &str, + target_version: Option<&str>, + error_code: Option<&str>, +) { + let _ = backend.telemetry_track_update(stage, target_version, error_code); +} + +/// telemetryClient.ts `trackSettingsSnapshot`, fire and forget. +pub fn track_settings_snapshot(backend: &dyn Backend, snapshot: &SettingsSnapshot) { + let _ = backend.telemetry_track_settings_snapshot(snapshot); +} + +/// `createCloseRequestHandler`: the first close request flushes, then the +/// window goes; later ones are ignored. +#[derive(Debug, Default)] +pub struct CloseRequest { + closing: bool, +} + +impl CloseRequest { + /// True the first time: run the shutdown. False while it runs. + pub fn request(&mut self) -> bool { + if self.closing { + return false; + } + self.closing = true; + true + } + + pub fn is_closing(&self) -> bool { + self.closing + } +} + +/// The console line a failed flush on close writes. +pub fn close_flush_error(error: &str) -> String { + format!("Failed to flush pending saves on close: {error}") +} diff --git a/crates/accshift-app/src/lifecycle/deep_link.rs b/crates/accshift-app/src/lifecycle/deep_link.rs new file mode 100644 index 00000000..2c39bbf9 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/deep_link.rs @@ -0,0 +1,832 @@ +//! Deep links: the `tauri-plugin-deep-link` 2.4.10 desktop half (launch +//! arguments, scheme registration), the Rust handler of +//! `src-tauri/src/boot.rs` (`wire_deep_links`, the log shape) and the TS +//! side (`src/lib/app/deepLinkUrl.ts`, `src/lib/app/useDeepLink.svelte.ts`). +//! +//! Plugin lines are in its `src/lib.rs`. + +use crate::accounts::loading::is_platform_usable; +use crate::js; +use crate::platforms::{PlatformAccount, PlatformRegistry, RuntimeOs}; +use crate::settings::AppSettings; +use crate::text::Text; +use std::time::{Duration, Instant}; +use url::Url; + +/// `plugins.deep-link.desktop.schemes` of tauri.conf.json. +pub const SCHEMES: &[&str] = &["accshift"]; + +/// `handle_cli_arguments` (lines 196-222), Windows and Linux: the argument +/// list must be the executable plus exactly one argument, a URL whose +/// scheme is configured. That URL becomes the plugin's `current` and is +/// emitted as `deep-link://new-url`. macOS gets URLs from the OS instead. +pub fn handle_cli_arguments>(args: &[S], schemes: &[&str]) -> Option { + if !(cfg!(windows) || cfg!(target_os = "linux")) { + return None; + } + if args.len() != 2 { + return None; + } + let url = args[1].as_ref().parse::().ok()?; + schemes.contains(&url.scheme()).then_some(url) +} + +/// What a second launch does in the running app: the single-instance +/// callback of src-tauri/src/lib.rs (lines 82-97), with the deep-link +/// plugin's `handle_cli_arguments` the `deep-link` feature runs first. +#[derive(Debug, Clone, PartialEq)] +pub struct SecondLaunch { + /// The URL delivered to the deep-link listeners, if the arguments were one. + pub url: Option, + /// `backend.single-instance` info line. + pub log_source: &'static str, + pub log_message: &'static str, + /// Show and focus the main window: only once boot completed. + pub show_window: bool, +} + +pub fn second_launch>(args: &[S], boot_completed: bool) -> SecondLaunch { + SecondLaunch { + url: handle_cli_arguments(args, SCHEMES), + log_source: "backend.single-instance", + log_message: "Second instance launch redirected to the running app", + show_window: boot_completed, + } +} + +/// What `on_open_url` of boot.rs (lines 699-731) does with delivered URLs. +#[derive(Debug, Clone, PartialEq)] +pub struct OpenUrlEffects { + /// `info`, `backend.deep-link`, "Deep link received", these details. + pub log_details: String, + /// `telemetry::Event::DeepLinkUsed` (`RealBackend::track_deep_link`). + pub track: bool, + pub show_window: bool, +} + +pub fn on_open_url(urls: &[Url], boot_completed: bool) -> OpenUrlEffects { + OpenUrlEffects { + log_details: urls + .iter() + .map(deep_link_log_shape) + .collect::>() + .join(" "), + track: true, + show_window: boot_completed, + } +} + +/// boot.rs lines 147-161: scheme, action and at most 32 characters of the +/// platform; the account and any query or fragment are replaced. +pub fn deep_link_log_shape(url: &Url) -> String { + let mut segments = url.path().split('/').filter(|s| !s.is_empty()); + let mut shape = format!("{}://{}", url.scheme(), url.host_str().unwrap_or("")); + if let Some(platform) = segments.next() { + shape.push('/'); + shape.push_str(&platform.chars().take(32).collect::()); + } + if segments.next().is_some() { + shape.push_str("/"); + } + if segments.next().is_some() || url.query().is_some() || url.fragment().is_some() { + shape.push_str("<+extra>"); + } + shape +} + +/// boot.rs lines 652-654: register unless every scheme answered `true`. A +/// failed check must arrive as `false` (fail open). +pub fn schemes_need_registration(schemes: &[String], registered: &[bool]) -> bool { + schemes.len() != registered.len() || registered.iter().any(|r| !r) +} + +/// `dunce::simplified(current_exe())`, what the registry entries name. +/// tauri-utils canonicalizes the path, which adds `\\?\` on Windows; dunce +/// takes it off again. +pub fn current_exe_for_registration() -> std::io::Result { + let exe = std::fs::canonicalize(std::env::current_exe()?)?; + Ok(dunce::simplified(&exe).display().to_string()) +} + +/// One registry value write of [`registration_entries`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryValue { + /// Under `HKEY_CURRENT_USER`. + pub key: String, + /// `""` is the default value. + pub name: String, + pub value: String, +} + +/// `register` on Windows (lines 259-281), as data: the four values written +/// under `HKCU\Software\Classes\{scheme}`. +pub fn registration_entries(scheme: &str, identifier: &str, exe: &str) -> Vec { + let key_base = format!("Software\\Classes\\{scheme}"); + let value = |key: String, name: &str, value: String| RegistryValue { + key, + name: name.to_string(), + value, + }; + vec![ + value(key_base.clone(), "", format!("URL:{identifier} protocol")), + value(key_base.clone(), "URL Protocol", String::new()), + value(format!("{key_base}\\DefaultIcon"), "", format!("{exe},0")), + value( + format!("{key_base}\\shell\\open\\command"), + "", + format!("\"{exe}\" \"%1\""), + ), + ] +} + +/// The command `is_registered` compares with (lines 432-448). +pub fn registered_command(exe: &str) -> String { + format!("\"{exe}\" \"%1\"") +} + +/// The registry, behind a trait so tests never touch the real one. +pub trait SchemeRegistry { + /// Create `HKCU\{key}` and set one string value. + fn set_string(&mut self, key: &str, name: &str, value: &str) -> Result<(), String>; + /// `HKEY_CLASSES_ROOT\{key}` default value; `Ok(None)` when the key does + /// not open. + fn classes_root_default(&self, key: &str) -> Result, String>; +} + +/// `register` for one scheme. +pub fn register_scheme( + registry: &mut dyn SchemeRegistry, + scheme: &str, + identifier: &str, + exe: &str, +) -> Result<(), String> { + for entry in registration_entries(scheme, identifier, exe) { + registry.set_string(&entry.key, &entry.name, &entry.value)?; + } + Ok(()) +} + +/// `register_all`: every configured scheme, stopping at the first error. +pub fn register_all( + registry: &mut dyn SchemeRegistry, + schemes: &[&str], + identifier: &str, + exe: &str, +) -> Result<(), String> { + for scheme in schemes { + register_scheme(registry, scheme, identifier, exe)?; + } + Ok(()) +} + +/// `is_registered` on Windows (lines 432-448): the merged class view's +/// `{scheme}\shell\open\command` must be exactly `"{exe}" "%1"`. +pub fn is_registered( + registry: &dyn SchemeRegistry, + scheme: &str, + exe: &str, +) -> Result { + match registry.classes_root_default(&format!("{scheme}\\shell\\open\\command"))? { + None => Ok(false), + Some(command) => Ok(command == registered_command(exe)), + } +} + +/// The real registry, on `windows-registry` like the plugin. +#[cfg(windows)] +pub struct WindowsRegistry; + +#[cfg(windows)] +impl SchemeRegistry for WindowsRegistry { + fn set_string(&mut self, key: &str, name: &str, value: &str) -> Result<(), String> { + windows_registry::CURRENT_USER + .create(key) + .and_then(|key| key.set_string(name, value)) + .map_err(|e| e.to_string()) + } + + fn classes_root_default(&self, key: &str) -> Result, String> { + let Ok(key) = windows_registry::CLASSES_ROOT.open(key) else { + return Ok(None); + }; + key.get_string("").map(Some).map_err(|e| e.to_string()) + } +} + +/// The warn line `wire_deep_links` writes when registration fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistrationFailure { + pub source: &'static str, + pub message: &'static str, + pub details: String, +} + +/// boot.rs lines 661-695 on Windows: read each scheme, register them all +/// only when one does not point at this executable. +pub fn wire_scheme_registration( + registry: &mut dyn SchemeRegistry, + schemes: &[&str], + identifier: &str, + exe: &str, +) -> Result { + let owned: Vec = schemes.iter().map(ToString::to_string).collect(); + let registered: Vec = schemes + .iter() + .map(|scheme| is_registered(registry, scheme, exe).unwrap_or(false)) + .collect(); + if !schemes_need_registration(&owned, ®istered) { + return Ok(false); + } + register_all(registry, schemes, identifier, exe) + .map(|()| true) + .map_err(|details| RegistrationFailure { + source: "backend.deep-link", + message: "Failed to register accshift:// scheme", + details, + }) +} + +/// `register` on Linux (lines 283-364): a `{bin}-handler.desktop` file in +/// `{data_dir}/applications` claiming `x-scheme-handler/{scheme}`, merged +/// into an existing file, then `update-desktop-database` and `xdg-mime +/// default`. `wire_deep_links` runs it on every launch. +#[cfg(target_os = "linux")] +pub fn register_linux(scheme: &str, product_name: &str) -> Result<(), String> { + use std::io::Write; + use std::process::Command; + let bin = std::fs::canonicalize(std::env::current_exe().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let file_name = format!( + "{}-handler.desktop", + bin.file_name().unwrap_or_default().to_string_lossy() + ); + let exec = std::env::var_os("APPIMAGE") + .unwrap_or_else(|| bin.clone().into_os_string()) + .to_string_lossy() + .to_string(); + let qualified_exec = format!("\"{exec}\" %u"); + let target = dirs::data_dir() + .ok_or_else(|| "unknown data directory".to_string())? + .join("applications"); + std::fs::create_dir_all(&target).map_err(|e| e.to_string())?; + let target_file = target.join(&file_name); + let mime_type = format!("x-scheme-handler/{scheme}"); + + if let Ok(mut desktop_file) = ini::Ini::load_from_file(&target_file) { + if let Some(section) = desktop_file.section_mut(Some("Desktop Entry")) { + let old_mimes = section.remove("MimeType").unwrap_or_default(); + let mut change = false; + if !old_mimes.split(';').any(|mime| mime == mime_type) { + section.append("MimeType", format!("{mime_type};{old_mimes}")); + change = true; + } else { + section.insert("MimeType".to_string(), old_mimes); + } + let old_exec = section.remove("Exec").unwrap_or_default(); + if old_exec != qualified_exec { + section.append("Exec", qualified_exec); + change = true; + } else { + section.insert("Exec".to_string(), old_exec); + } + if change { + desktop_file + .write_to_file(&target_file) + .map_err(|e| e.to_string())?; + } + } + } else { + let mut file = std::fs::File::create(&target_file).map_err(|e| e.to_string())?; + file.write_all(desktop_template(product_name, &qualified_exec, &mime_type).as_bytes()) + .map_err(|e| e.to_string())?; + } + Command::new("update-desktop-database") + .arg(&target) + .status() + .map_err(|e| e.to_string())?; + Command::new("xdg-mime") + .args(["default", &file_name, mime_type.as_str()]) + .status() + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// The plugin's `template.desktop`. +pub fn desktop_template(name: &str, qualified_exec: &str, mime_type: &str) -> String { + format!( + "[Desktop Entry]\nType=Application\nName={name}\nExec={qualified_exec}\nTerminal=false\nMimeType={mime_type}\nNoDisplay=true\n" + ) +} + +/// `DeepLinkSwitchRequest`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeepLinkSwitchRequest { + pub platform_id: String, + pub account_ref: String, +} + +/// `MAX_DEEP_LINK_URL_LENGTH`, in UTF-16 units. +pub const MAX_DEEP_LINK_URL_LENGTH: usize = 2048; + +/// `parseDeepLink`: `accshift://switch//`. +pub fn parse_deep_link(raw_url: &str) -> Option { + let trimmed = js::trim(raw_url); + if js::utf16_len(trimmed) > MAX_DEEP_LINK_URL_LENGTH { + return None; + } + const PREFIX: &str = "accshift://"; + let head = trimmed.get(..PREFIX.len())?; + if !head.eq_ignore_ascii_case(PREFIX) { + return None; + } + let rest = &trimmed[PREFIX.len()..]; + let path = &rest[..rest.find(['?', '#']).unwrap_or(rest.len())]; + let segments: Vec = path + .split('/') + .filter(|segment| !segment.is_empty()) + .map(|segment| decode_uri_component(segment).unwrap_or_else(|| segment.to_string())) + .collect(); + if segments.len() == 3 && segments[0].to_lowercase() == "switch" { + let platform_id = segments[1].to_lowercase(); + let account_ref = js::trim(&segments[2]).to_string(); + if !platform_id.is_empty() && !account_ref.is_empty() { + return Some(DeepLinkSwitchRequest { + platform_id, + account_ref, + }); + } + } + None +} + +/// `decodeURIComponent`: `None` where JS throws (a `%` without two hex +/// digits, or bytes that are not UTF-8). +pub fn decode_uri_component(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let hex = bytes.get(index + 1..index + 3)?; + let hex = std::str::from_utf8(hex).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + index += 3; + } else { + out.push(bytes[index]); + index += 1; + } + } + String::from_utf8(out).ok() +} + +/// `findAccount`: an id match wins; a username or display name must be +/// unique, case-insensitive and trimmed. +pub fn find_account<'a>( + accounts: &'a [PlatformAccount], + reference: &str, +) -> Option<&'a PlatformAccount> { + let needle = js::trim(reference).to_lowercase(); + let key = |value: &str| js::trim(value).to_lowercase(); + if let Some(by_id) = accounts.iter().find(|account| key(&account.id) == needle) { + return Some(by_id); + } + let by_username: Vec<_> = accounts + .iter() + .filter(|account| key(&account.username) == needle) + .collect(); + match by_username.len() { + 1 => return Some(by_username[0]), + n if n > 1 => return None, + _ => {} + } + let by_display: Vec<_> = accounts + .iter() + .filter(|account| key(&account.display_name) == needle) + .collect(); + (by_display.len() == 1).then(|| by_display[0]) +} + +/// `BOOT_TIMEOUT_MS`. +pub const BOOT_TIMEOUT: Duration = Duration::from_millis(20_000); +/// `LOAD_TIMEOUT_MS`. +pub const LOAD_TIMEOUT: Duration = Duration::from_millis(15_000); + +/// What the controller reads of the app on each [`DeepLinkController::poll`]. +pub struct DeepLinkView<'a> { + pub boot_ready: bool, + pub pin_locked: bool, + pub settings: &'a AppSettings, + pub registry: &'a PlatformRegistry, + pub runtime_os: RuntimeOs, + pub active_tab: &'a str, + pub loader_loading: bool, + /// The visible tab's loaded accounts (`getAccounts`). + pub accounts: &'a [PlatformAccount], +} + +/// The work the controller hands the shell. Each one is answered with the +/// matching [`DeepLinkAnswer`]. +#[derive(Debug, Clone, PartialEq)] +pub enum DeepLinkAction { + Toast(Text), + /// `loadPlatformAccounts(platformId)`: answer `PlatformAccounts`. + LoadPlatformAccounts(String), + /// `loadAccounts()` of the visible tab: answer `AccountsLoaded`. + LoadAccounts, + /// `confirmSwitch(account, platformName)`: answer `Confirmed`. A shell + /// without the gate answers `true` at once. + Confirm { + account: PlatformAccount, + platform_name: String, + }, + /// `changeTab(platformId)`: answer `TabChanged`. + ChangeTab(String), + /// `switchToAccount(account)`: answer `Switched`. + Switch(PlatformAccount), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DeepLinkAnswer { + PlatformAccounts(Vec), + AccountsLoaded, + Confirmed(bool), + TabChanged, + Switched(bool), +} + +#[derive(Debug, Clone, PartialEq)] +enum Phase { + Idle, + /// `handleUrl`'s wait for boot (it goes on after the timeout). + WaitBootForUrl { + url: String, + since: Instant, + }, + /// `handleSwitch`'s own wait (it gives up after the timeout). + WaitBootForSwitch { + request: DeepLinkSwitchRequest, + since: Instant, + }, + AwaitPlatformAccounts(Switch), + WaitLoader { + switch: Switch, + since: Instant, + }, + AwaitReload(Switch), + AwaitConfirm(Switch), + AwaitTabChange(Switch), + WaitLoaderAfterTab { + switch: Switch, + since: Instant, + }, + AwaitReloadAfterTab(Switch), + AwaitSwitch(Switch), +} + +#[derive(Debug, Clone, PartialEq)] +struct Switch { + request: DeepLinkSwitchRequest, + platform_name: String, + on_other_tab: bool, + account: Option, +} + +/// `createDeepLinkController`: one link at a time, the latest one queued. +#[derive(Debug)] +pub struct DeepLinkController { + started: bool, + phase: Phase, + pending: Option, +} + +impl Default for DeepLinkController { + fn default() -> Self { + Self::new() + } +} + +impl DeepLinkController { + pub fn new() -> Self { + Self { + started: false, + phase: Phase::Idle, + pending: None, + } + } + + /// `start`: listen from now on. URLs delivered before are not replayed: + /// `onOpenUrl` only listens for `deep-link://new-url`. + pub fn start(&mut self) { + self.started = true; + } + + /// `stop`: drop the listener. A link in flight finishes. + pub fn stop(&mut self) { + self.started = false; + } + + pub fn is_busy(&self) -> bool { + self.phase != Phase::Idle + } + + /// The listener: each URL goes to `handleUrl`. + pub fn on_urls>(&mut self, urls: &[S], now: Instant) { + if !self.started { + return; + } + for url in urls { + self.handle_url(url.as_ref().to_string(), now); + } + } + + /// `handleUrl`: busy keeps only the latest link. + fn handle_url(&mut self, url: String, now: Instant) { + if self.is_busy() { + self.pending = Some(url); + return; + } + self.phase = Phase::WaitBootForUrl { url, since: now }; + } + + /// The `finally` of `handleUrl`. + fn finish(&mut self, now: Instant) { + self.phase = Phase::Idle; + if let Some(next) = self.pending.take() { + self.handle_url(next, now); + } + } + + /// Advances as far as the view allows; call once per frame while busy. + pub fn poll(&mut self, view: &DeepLinkView<'_>, now: Instant) -> Vec { + let mut actions = Vec::new(); + loop { + let before = self.phase.clone(); + self.step(view, now, &mut actions); + if self.phase == before { + return actions; + } + } + } + + fn step(&mut self, view: &DeepLinkView<'_>, now: Instant, actions: &mut Vec) { + match std::mem::replace(&mut self.phase, Phase::Idle) { + Phase::Idle => {} + Phase::WaitBootForUrl { url, since } => { + if !view.boot_ready && now.duration_since(since) <= BOOT_TIMEOUT { + self.phase = Phase::WaitBootForUrl { url, since }; + return; + } + match parse_deep_link(&url) { + None => { + if view.settings.deep_links_enabled { + actions.push(DeepLinkAction::Toast(Text::key("toast.deepLinkInvalid"))); + } + self.finish(now); + } + Some(request) => { + self.phase = Phase::WaitBootForSwitch { + request, + since: now, + }; + } + } + } + Phase::WaitBootForSwitch { request, since } => { + if !view.boot_ready { + if now.duration_since(since) > BOOT_TIMEOUT { + self.finish(now); + } else { + self.phase = Phase::WaitBootForSwitch { request, since }; + } + return; + } + self.gate(request, view, now, actions); + } + Phase::WaitLoader { switch, since } => { + if view.loader_loading && now.duration_since(since) <= LOAD_TIMEOUT { + self.phase = Phase::WaitLoader { switch, since }; + return; + } + match find_account(view.accounts, &switch.request.account_ref) { + Some(account) => self.found(switch, account.clone(), actions), + None => { + actions.push(DeepLinkAction::LoadAccounts); + self.phase = Phase::AwaitReload(switch); + } + } + } + Phase::WaitLoaderAfterTab { switch, since } => { + if view.loader_loading && now.duration_since(since) <= LOAD_TIMEOUT { + self.phase = Phase::WaitLoaderAfterTab { switch, since }; + return; + } + let id = switch + .account + .as_ref() + .map(|a| a.id.clone()) + .unwrap_or_default(); + match view.accounts.iter().find(|candidate| candidate.id == id) { + Some(account) => self.switch(switch, account.clone(), actions), + None => { + actions.push(DeepLinkAction::LoadAccounts); + self.phase = Phase::AwaitReloadAfterTab(switch); + } + } + } + waiting => self.phase = waiting, + } + } + + /// The checks at the top of `handleSwitch`. + fn gate( + &mut self, + request: DeepLinkSwitchRequest, + view: &DeepLinkView<'_>, + now: Instant, + actions: &mut Vec, + ) { + if view.pin_locked { + return self.finish(now); + } + let toast = |actions: &mut Vec, text: Text| { + actions.push(DeepLinkAction::Toast(text)); + }; + if !view.settings.deep_links_enabled { + toast(actions, Text::key("toast.deepLinkDisabled")); + return self.finish(now); + } + let Some(def) = view.registry.definition(&request.platform_id) else { + toast( + actions, + Text::with( + "toast.deepLinkUnknownPlatform", + [("platform", request.platform_id.clone())], + ), + ); + return self.finish(now); + }; + if !is_platform_usable(view.registry, &request.platform_id, view.runtime_os) { + toast( + actions, + Text::with( + "app.platformUnsupportedOs", + [("platform", def.name.clone())], + ), + ); + return self.finish(now); + } + if !view + .settings + .enabled_platforms + .contains(&request.platform_id) + { + toast( + actions, + Text::with( + "toast.deepLinkPlatformDisabled", + [("platform", def.name.clone())], + ), + ); + return self.finish(now); + } + let on_other_tab = view.active_tab != request.platform_id; + let switch = Switch { + platform_name: def.name.clone(), + on_other_tab, + account: None, + request, + }; + if on_other_tab { + actions.push(DeepLinkAction::LoadPlatformAccounts( + switch.request.platform_id.clone(), + )); + self.phase = Phase::AwaitPlatformAccounts(switch); + } else { + self.phase = Phase::WaitLoader { switch, since: now }; + } + } + + fn not_found(&mut self, switch: &Switch, actions: &mut Vec, now: Instant) { + actions.push(DeepLinkAction::Toast(Text::with( + "toast.deepLinkAccountNotFound", + [ + ("platform", switch.platform_name.clone()), + ("account", switch.request.account_ref.clone()), + ], + ))); + self.finish(now); + } + + fn found( + &mut self, + mut switch: Switch, + account: PlatformAccount, + actions: &mut Vec, + ) { + actions.push(DeepLinkAction::Confirm { + account: account.clone(), + platform_name: switch.platform_name.clone(), + }); + switch.account = Some(account); + self.phase = Phase::AwaitConfirm(switch); + } + + fn switch( + &mut self, + mut switch: Switch, + account: PlatformAccount, + actions: &mut Vec, + ) { + actions.push(DeepLinkAction::Switch(account.clone())); + switch.account = Some(account); + self.phase = Phase::AwaitSwitch(switch); + } + + /// `handleUrl`'s `catch`: the work the last action started threw (a + /// platform's account list failed to load). The link ends without a + /// toast and the queued one, if any, starts. + pub fn fail(&mut self, view: &DeepLinkView<'_>, now: Instant) -> Vec { + if self.is_busy() { + self.finish(now); + } + self.poll(view, now) + } + + /// The shell's answer to the last action. `view` is read after it. + pub fn answer( + &mut self, + answer: DeepLinkAnswer, + view: &DeepLinkView<'_>, + now: Instant, + ) -> Vec { + let mut actions = Vec::new(); + match (std::mem::replace(&mut self.phase, Phase::Idle), answer) { + (Phase::AwaitPlatformAccounts(switch), DeepLinkAnswer::PlatformAccounts(accounts)) => { + match find_account(&accounts, &switch.request.account_ref) { + Some(account) => self.found(switch, account.clone(), &mut actions), + None => self.not_found(&switch, &mut actions, now), + } + } + (Phase::AwaitReload(switch), DeepLinkAnswer::AccountsLoaded) => { + match find_account(view.accounts, &switch.request.account_ref) { + Some(account) => self.found(switch, account.clone(), &mut actions), + None => self.not_found(&switch, &mut actions, now), + } + } + (Phase::AwaitConfirm(switch), DeepLinkAnswer::Confirmed(allowed)) => { + if !allowed { + self.finish(now); + } else if switch.on_other_tab || view.active_tab != switch.request.platform_id { + actions.push(DeepLinkAction::ChangeTab( + switch.request.platform_id.clone(), + )); + self.phase = Phase::AwaitTabChange(switch); + } else if let Some(account) = switch.account.clone() { + self.switch(switch, account, &mut actions); + } else { + self.finish(now); + } + } + (Phase::AwaitTabChange(switch), DeepLinkAnswer::TabChanged) => { + if view.active_tab != switch.request.platform_id { + self.finish(now); + } else { + self.phase = Phase::WaitLoaderAfterTab { switch, since: now }; + } + } + (Phase::AwaitReloadAfterTab(switch), DeepLinkAnswer::AccountsLoaded) => { + let id = switch + .account + .as_ref() + .map(|a| a.id.clone()) + .unwrap_or_default(); + match view.accounts.iter().find(|candidate| candidate.id == id) { + Some(account) => self.switch(switch, account.clone(), &mut actions), + None => self.not_found(&switch, &mut actions, now), + } + } + (Phase::AwaitSwitch(switch), DeepLinkAnswer::Switched(switched)) => { + if switched { + let name = switch + .account + .as_ref() + .and_then(|account| { + [&account.display_name, &account.username, &account.id] + .into_iter() + .find(|value| !value.is_empty()) + .cloned() + }) + .unwrap_or_default(); + actions.push(DeepLinkAction::Toast(Text::with( + "toast.deepLinkSwitched", + [("account", name)], + ))); + } + self.finish(now); + } + (phase, _) => self.phase = phase, + } + actions.extend(self.poll(view, now)); + actions + } +} diff --git a/crates/accshift-app/src/lifecycle/mod.rs b/crates/accshift-app/src/lifecycle/mod.rs new file mode 100644 index 00000000..d458a8e5 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/mod.rs @@ -0,0 +1,249 @@ +//! The app lifecycle of the Tauri build, for the egui shell: boot and +//! shutdown order, the updater, single instance, deep links and process +//! control. +//! +//! - [`Lifecycle`]: the one entry point. [`Lifecycle::boot`] and +//! [`Lifecycle::shutdown`] run the steps of [`BOOT_ORDER`] and +//! [`SHUTDOWN_ORDER`] through a [`LifecycleHost`] the egui `AppModel` +//! implements; tests record the calls. +//! - [`updater`]: `tauri-plugin-updater` 2.11.0; [`update_flow`]: the +//! `useAppUpdater` state machine on top of it. +//! - [`single_instance`]: `tauri-plugin-single-instance` 2.4.4, same names +//! as the Tauri build so both see each other. +//! - [`deep_link`]: `tauri-plugin-deep-link` 2.4.10, `wire_deep_links`, +//! `deepLinkUrl.ts` and `useDeepLink`. +//! - [`process`]: `tauri-plugin-process` 2.3.1 (`exit`, `relaunch`). +//! - [`autostart`]: the `HKCU\...\Run` value for launch at sign-in into the +//! tray, new to the egui build. +//! - [`app_shell`]: `useAppLifecycle` (shell init, external refresh). +//! - [`boot`]: boot marks, boot locale, the log redaction, the telemetry +//! calls and the close request; [`ui_scale`]: `useUiScale` and the +//! backdrop decisions of `useThemeApplication`. + +pub mod app_shell; +pub mod autostart; +pub mod boot; +pub mod deep_link; +pub mod process; +pub mod single_instance; +pub mod ui_scale; +pub mod update_flow; +pub mod updater; + +use std::time::Duration; + +/// App.svelte: the update check starts this long after mount. +pub const UPDATE_CHECK_DELAY: Duration = Duration::from_millis(3500); +/// boot.rs `install_window_event_handlers`: the geometry save gets this +/// long before the process goes on without it. +pub const GEOMETRY_SAVE_WAIT: Duration = Duration::from_secs(5); + +/// One boot step, in [`BOOT_ORDER`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootStep { + /// [`single_instance::acquire`] with the app identifier + /// (`real::BUNDLE_IDENTIFIER`): the first plugin of src-tauri/src/lib.rs. + /// A forwarded launch exits here with code 0, before anything else. + SingleInstance, + /// [`deep_link::handle_cli_arguments`] on this launch's arguments: the + /// deep-link plugin's `init`. The URL is the plugin's `current`; the + /// listener started later does not see it (see the M5 traps). + LaunchUrl, + /// `legacy_web_storage::read` when `needs_read`: before any panic hook, + /// so a crash in the LevelDB reader cannot leave a half-written log. + LegacyStorageRead, + /// `storage::initialize` with the boot snapshot and the legacy map. + StorageInitialize, + /// The theme, locale and motion of the first frame + /// (`useThemeApplication`). + Theme, + /// `RealBackend::start_telemetry` (`TelemetryState::new`). + TelemetryStart, + /// `RealBackend::begin_session`: log rotation, "App setup started", + /// panic hook. + SessionBegin, + /// `RealBackend::spawn_maintenance` (`spawn_snapshot_upgrade`). + Maintenance, + /// [`deep_link::wire_scheme_registration`] on Windows, + /// `register_linux` on Linux: `wire_deep_links`. + SchemeRegistration, + /// Show the window after [`boot::BOOT_FAILSAFE`] if boot never finished. + BootFailsafe, + /// [`update_flow::AppUpdater::start`] after [`UPDATE_CHECK_DELAY`]. + UpdaterCheck, +} + +/// The boot order. The brief's fixed part (legacy read, storage, theme, +/// telemetry, session, maintenance, updater) keeps its order; the plugin +/// steps sit where Tauri runs them: single instance and the launch URL +/// before anything (plugin `init`), scheme registration and the failsafe +/// after the window exists (`setup`). +pub const BOOT_ORDER: [BootStep; 11] = [ + BootStep::SingleInstance, + BootStep::LaunchUrl, + BootStep::LegacyStorageRead, + BootStep::StorageInitialize, + BootStep::Theme, + BootStep::TelemetryStart, + BootStep::SessionBegin, + BootStep::Maintenance, + BootStep::SchemeRegistration, + BootStep::BootFailsafe, + BootStep::UpdaterCheck, +]; + +/// One shutdown step, in [`SHUTDOWN_ORDER`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShutdownStep { + /// `spawn_window_geometry_save`. + StartGeometrySave, + HideWindow, + /// `RealBackend::end_telemetry_session`: `session_ended`, then the + /// flush bounded by 2.5 s. + EndTelemetrySession, + /// Wait for the geometry save, at most [`GEOMETRY_SAVE_WAIT`]. + WaitGeometrySave, + /// The webview's close handler: the pending UI scale, the settings + /// flush, then the store saves (`flushAppState`). Errors are logged. + FlushSaves, + /// `RunEvent::Exit`: the single-instance `destroy`. + ReleaseSingleInstance, + /// The window is destroyed and the process ends. + Exit, +} + +/// The close order. The Rust `CloseRequested` handler runs first and +/// blocks (boot.rs lines 599-631), the webview's `onCloseRequested` flushes +/// next and destroys the window, the plugins' exit hooks run last. +pub const SHUTDOWN_ORDER: [ShutdownStep; 7] = [ + ShutdownStep::StartGeometrySave, + ShutdownStep::HideWindow, + ShutdownStep::EndTelemetrySession, + ShutdownStep::WaitGeometrySave, + ShutdownStep::FlushSaves, + ShutdownStep::ReleaseSingleInstance, + ShutdownStep::Exit, +]; + +/// The side effects of each step. The egui `AppModel` implements it on the +/// real backend, storage and window; tests record the calls. +pub trait LifecycleHost { + /// True when this process goes on as the first instance (or alone), + /// false when it handed its arguments to a running one. + fn acquire_single_instance(&mut self) -> bool; + fn launch_url(&mut self, url: Option); + fn read_legacy_storage(&mut self); + fn initialize_storage(&mut self); + fn apply_theme(&mut self); + fn start_telemetry(&mut self); + fn begin_session(&mut self); + fn spawn_maintenance(&mut self); + fn register_schemes(&mut self); + fn arm_boot_failsafe(&mut self, after: Duration); + fn schedule_update_check(&mut self, after: Duration); + + fn start_geometry_save(&mut self); + fn hide_window(&mut self); + fn end_telemetry_session(&mut self); + fn wait_geometry_save(&mut self, bound: Duration); + fn flush_saves(&mut self) -> Result<(), String>; + fn release_single_instance(&mut self); + /// `std::process::exit` for a forwarded launch, closing the viewport + /// on a normal close. Fakes record it. + fn exit(&mut self, code: i32); + /// A log line (`level`, `source`, `message`). + fn log(&mut self, level: &str, source: &str, message: &str); +} + +/// How [`Lifecycle::boot`] ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootOutcome { + /// Boot ran every step; the app is up. + Running, + /// Another instance took the arguments; `exit(0)` was called. + Forwarded, +} + +/// The entry point. +#[derive(Debug)] +pub struct Lifecycle { + args: Vec, + close: boot::CloseRequest, + booted: bool, +} + +impl Lifecycle { + /// `args`: this launch's command line, executable first + /// ([`single_instance::current_args`]). + pub fn new(args: Vec) -> Self { + Self { + args, + close: boot::CloseRequest::default(), + booted: false, + } + } + + pub fn args(&self) -> &[String] { + &self.args + } + + /// Runs [`BOOT_ORDER`] once. A second call does nothing. + pub fn boot(&mut self, host: &mut dyn LifecycleHost) -> BootOutcome { + if self.booted { + return BootOutcome::Running; + } + self.booted = true; + for step in BOOT_ORDER { + match step { + BootStep::SingleInstance => { + if !host.acquire_single_instance() { + host.exit(0); + return BootOutcome::Forwarded; + } + } + BootStep::LaunchUrl => host.launch_url(deep_link::handle_cli_arguments( + &self.args, + deep_link::SCHEMES, + )), + BootStep::LegacyStorageRead => host.read_legacy_storage(), + BootStep::StorageInitialize => host.initialize_storage(), + BootStep::Theme => host.apply_theme(), + BootStep::TelemetryStart => host.start_telemetry(), + BootStep::SessionBegin => host.begin_session(), + BootStep::Maintenance => host.spawn_maintenance(), + BootStep::SchemeRegistration => host.register_schemes(), + BootStep::BootFailsafe => host.arm_boot_failsafe(boot::BOOT_FAILSAFE), + BootStep::UpdaterCheck => host.schedule_update_check(UPDATE_CHECK_DELAY), + } + } + BootOutcome::Running + } + + /// Runs [`SHUTDOWN_ORDER`] on the first close request; false when a + /// close is already under way (`isClosing`). + pub fn shutdown(&mut self, host: &mut dyn LifecycleHost) -> bool { + if !self.close.request() { + return false; + } + for step in SHUTDOWN_ORDER { + match step { + ShutdownStep::StartGeometrySave => host.start_geometry_save(), + ShutdownStep::HideWindow => host.hide_window(), + ShutdownStep::EndTelemetrySession => host.end_telemetry_session(), + ShutdownStep::WaitGeometrySave => host.wait_geometry_save(GEOMETRY_SAVE_WAIT), + ShutdownStep::FlushSaves => { + if let Err(error) = host.flush_saves() { + host.log("error", "frontend.app", &boot::close_flush_error(&error)); + } + } + ShutdownStep::ReleaseSingleInstance => host.release_single_instance(), + ShutdownStep::Exit => host.exit(0), + } + } + true + } + + pub fn is_closing(&self) -> bool { + self.close.is_closing() + } +} diff --git a/crates/accshift-app/src/lifecycle/process.rs b/crates/accshift-app/src/lifecycle/process.rs new file mode 100644 index 00000000..85686f21 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/process.rs @@ -0,0 +1,95 @@ +//! `tauri-plugin-process` 2.3.1 (`exit`, `restart`) on Tauri 2.11.5's +//! `process::restart` (src/process.rs lines 20-89). The spawn and the exit +//! go through [`ProcessControl`], so tests never relaunch or exit. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// The two process side effects. +pub trait ProcessControl { + /// `Command::new(program).args(args).spawn()`. + fn spawn(&self, program: &Path, args: &[OsString]) -> std::io::Result<()>; + /// `std::process::exit(code)`. The real one never returns. + fn exit(&self, code: i32); +} + +/// The real one. +pub struct SystemProcess; + +impl ProcessControl for SystemProcess { + fn spawn(&self, program: &Path, args: &[OsString]) -> std::io::Result<()> { + std::process::Command::new(program) + .args(args) + .spawn() + .map(|_| ()) + } + + fn exit(&self, code: i32) { + std::process::exit(code); + } +} + +/// `process::current_binary` (lines 20-27): the AppImage on Linux when +/// `APPIMAGE` is set, else the canonical current executable. +pub fn current_binary() -> std::io::Result { + #[cfg(target_os = "linux")] + if let Some(appimage) = std::env::var_os("APPIMAGE") { + return Ok(PathBuf::from(appimage)); + } + std::fs::canonicalize(std::env::current_exe()?) +} + +/// `restart_macos_app` (lines 92-130): inside `X.app/Contents/MacOS`, the +/// binary `Info.plist` names, which an update may have renamed. +#[cfg(target_os = "macos")] +fn macos_bundle_binary(current_binary: &Path) -> Option { + let macos_directory = current_binary.parent()?; + if macos_directory.file_name()? != "MacOS" { + return None; + } + let contents_directory = macos_directory.parent()?; + if contents_directory.file_name()? != "Contents" { + return None; + } + let info: plist::Dictionary = plist::from_file(contents_directory.join("Info.plist")).ok()?; + let binary_name = info.get("CFBundleExecutable")?.as_string()?; + Some(macos_directory.join(binary_name)) +} + +/// The program a relaunch starts. +pub fn relaunch_program(current_binary: &Path) -> PathBuf { + #[cfg(target_os = "macos")] + if let Some(bundle_binary) = macos_bundle_binary(current_binary) { + return bundle_binary; + } + current_binary.to_path_buf() +} + +/// `relaunch()`: `app.request_restart()`, which ends the event loop (the +/// plugins' `RunEvent::Exit` runs, so the single-instance mutex and window +/// go first: `before_exit`), then `process::restart`: start the binary with +/// this launch's arguments minus the first, then `exit(0)`. A spawn error is +/// only logged upstream; it is returned here and the exit still happens. +pub fn restart( + control: &dyn ProcessControl, + binary: std::io::Result, + args_os: &[OsString], + before_exit: impl FnOnce(), +) -> Option { + before_exit(); + let mut error = None; + if let Ok(path) = binary { + let program = relaunch_program(&path); + if let Err(e) = control.spawn(&program, args_os.get(1..).unwrap_or_default()) { + error = Some(e); + } + } + control.exit(0); + error +} + +/// `exit(code)`: `app.exit(code)`, the same exit path with that code. +pub fn exit(control: &dyn ProcessControl, code: i32, before_exit: impl FnOnce()) { + before_exit(); + control.exit(code); +} diff --git a/crates/accshift-app/src/lifecycle/single_instance/linux.rs b/crates/accshift-app/src/lifecycle/single_instance/linux.rs new file mode 100644 index 00000000..a2c76feb --- /dev/null +++ b/crates/accshift-app/src/lifecycle/single_instance/linux.rs @@ -0,0 +1,77 @@ +//! platform_impl/linux.rs of `tauri-plugin-single-instance` 2.4.4: own the +//! D-Bus name `{id}.SingleInstance`, or call `ExecuteCallback` on whoever +//! owns it. + +use super::{dbus_name_and_path, Notify, PlatformLaunch}; +use zbus::blocking::Connection; +use zbus::interface; +use zbus::names::WellKnownName; + +struct SingleInstanceDBus { + notify: Notify, +} + +/// linux.rs lines 17-27. +#[interface(name = "org.SingleInstance.DBus")] +impl SingleInstanceDBus { + fn execute_callback(&mut self, argv: Vec, cwd: String) { + self.notify.deliver(argv, cwd); + } +} + +/// The connection that owns the name. Dropping it is `destroy` +/// (linux.rs lines 103-113): release the name. +pub struct Guard { + connection: Option, + name: String, +} + +impl Drop for Guard { + fn drop(&mut self) { + if let Some(connection) = &self.connection { + if let Ok(name) = WellKnownName::try_from(self.name.clone()) { + let _ = connection.release_name(name); + } + } + } +} + +/// linux.rs lines 29-99. +pub(super) fn acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + let (dbus_name, dbus_path) = dbus_name_and_path(identifier); + // Upstream unwraps these three: no session bus panics the app. Here + // that case runs without single instance. + let built = zbus::blocking::connection::Builder::session() + .and_then(|builder| builder.name(dbus_name.as_str())) + .map(|builder| { + builder + .replace_existing_names(false) + .allow_name_replacements(false) + }) + .and_then(|builder| builder.serve_at(dbus_path.as_str(), SingleInstanceDBus { notify })) + .and_then(|builder| builder.build()); + match built { + Ok(connection) => PlatformLaunch::Primary(Guard { + connection: Some(connection), + name: dbus_name, + }), + Err(zbus::Error::NameTaken) => { + if let Ok(connection) = Connection::session() { + let _ = connection.call_method( + Some(dbus_name.as_str()), + dbus_path.as_str(), + Some("org.SingleInstance.DBus"), + "ExecuteCallback", + &(args.to_vec(), cwd), + ); + } + PlatformLaunch::Forwarded + } + Err(_) => PlatformLaunch::Alone(None), + } +} diff --git a/crates/accshift-app/src/lifecycle/single_instance/macos.rs b/crates/accshift-app/src/lifecycle/single_instance/macos.rs new file mode 100644 index 00000000..3f131c0f --- /dev/null +++ b/crates/accshift-app/src/lifecycle/single_instance/macos.rs @@ -0,0 +1,74 @@ +//! platform_impl/macos.rs of `tauri-plugin-single-instance` 2.4.4: a Unix +//! socket in `/tmp`. Upstream listens on a tokio task; a plain thread here, +//! same bytes. + +use super::{macos_socket_path, Notify, PlatformLaunch}; +use std::io::{BufWriter, ErrorKind, Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; + +/// The socket. Dropping it is `destroy` (macos.rs lines 56-59): remove the +/// file. The accept thread is left blocked, as upstream's task is. +pub struct Guard { + socket: PathBuf, +} + +impl Drop for Guard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.socket); + } +} + +/// macos.rs lines 21-46. +pub(super) fn acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + let socket = macos_socket_path(identifier); + match notify_singleton(&socket, args, cwd) { + Ok(()) => PlatformLaunch::Forwarded, + Err(error) => match error.kind() { + ErrorKind::NotFound | ErrorKind::ConnectionRefused => { + let _ = std::fs::remove_file(&socket); + listen_for_other_instances(socket.clone(), notify); + PlatformLaunch::Primary(Guard { socket }) + } + _ => PlatformLaunch::Alone(None), + }, + } +} + +/// macos.rs lines 78-93: `cwd`, two NULs, the arguments joined by NUL. +fn notify_singleton(socket: &Path, args: &[String], cwd: &str) -> std::io::Result<()> { + let stream = UnixStream::connect(socket)?; + let mut writer = BufWriter::new(&stream); + writer.write_all(cwd.as_bytes())?; + writer.write_all(b"\0\0")?; + writer.write_all(args.join("\0").as_bytes())?; + writer.flush()?; + Ok(()) +} + +/// macos.rs lines 95-133: the socket is bound before `acquire` returns, so +/// a launch right after this one reaches it; only the accepts run on the +/// thread. +fn listen_for_other_instances(socket: PathBuf, notify: Notify) { + let Ok(listener) = UnixListener::bind(socket) else { + return; + }; + let _ = std::thread::Builder::new() + .name("accshift-single-instance".into()) + .spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut text = String::new(); + if stream.read_to_string(&mut text).is_ok() { + let (cwd, args) = text.split_once("\0\0").unwrap_or_default(); + let args: Vec = args.split('\0').map(String::from).collect(); + notify.deliver(args, cwd.to_string()); + } + } + }); +} diff --git a/crates/accshift-app/src/lifecycle/single_instance/mod.rs b/crates/accshift-app/src/lifecycle/single_instance/mod.rs new file mode 100644 index 00000000..2c0ea288 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/single_instance/mod.rs @@ -0,0 +1,232 @@ +//! The port of `tauri-plugin-single-instance` 2.4.4 (built with its +//! `deep-link` feature and without `semver`, as the Tauri app builds it). +//! +//! Same names on every OS, so a Tauri instance and an egui instance of the +//! same identifier find each other: on Windows the named mutex +//! `{id}-sim` and the hidden message window of class `{id}-sic` titled +//! `{id}-siw`, reached with `WM_COPYDATA` (`dwData` 1542, `"cwd|arg0|arg1\0"`); +//! on Linux the D-Bus name `{id}.SingleInstance` at `/{id with . as /}/SingleInstance` +//! (`-` as `_`), method `org.SingleInstance.DBus.ExecuteCallback(argv, cwd)`; +//! on macOS the socket `/tmp/{id with . and - as _}_si.sock`, `cwd\0\0arg0\0arg1`. +//! +//! [`acquire`] never exits the process: it answers [`Launch::Forwarded`] and +//! the caller exits (upstream calls `std::process::exit(0)` itself, after +//! `cleanup_before_exit` on Windows and Linux). Second launches arrive on +//! [`SingleInstance::try_recv`], from a thread; `wake` runs after each one +//! (the egui shell passes `request_repaint`). + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod windows; + +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; + +/// A second launch: its whole `argv` (executable first) and its working +/// folder, as the plugin callback receives them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecondInstance { + pub args: Vec, + pub cwd: String, +} + +pub type Wake = Arc; + +/// Where a second launch goes: the channel and the repaint hook. +#[derive(Clone)] +pub(crate) struct Notify { + sender: Sender, + wake: Option, +} + +impl Notify { + pub(crate) fn deliver(&self, args: Vec, cwd: String) { + let _ = self.sender.send(SecondInstance { args, cwd }); + if let Some(wake) = &self.wake { + wake(); + } + } +} + +/// How this launch goes on. +pub enum Launch { + /// First instance: keep the guard alive for the whole run. + Primary(SingleInstance), + /// Another instance got this launch's arguments: exit with code 0. + Forwarded, + /// Neither: run without single-instance protection, as upstream does + /// when the mutex exists but its window does not (Windows), the D-Bus + /// session refuses the name for another reason (Linux) or the socket + /// fails otherwise (macOS). The value keeps what upstream leaks for the + /// process lifetime (the Windows mutex handle). + Alone(Option), +} + +impl std::fmt::Debug for Launch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Primary(_) => "Primary", + Self::Forwarded => "Forwarded", + Self::Alone(_) => "Alone", + }) + } +} + +/// The running single-instance service. Dropping it is the plugin's +/// `destroy` (`RunEvent::Exit`): release the mutex and destroy the window, +/// release the D-Bus name, or remove the socket. +pub struct SingleInstance { + receiver: Receiver, + #[allow(dead_code)] + guard: Option, +} + +impl SingleInstance { + /// The next second launch, if one arrived. + pub fn try_recv(&self) -> Option { + self.receiver.try_recv().ok() + } + + /// The channel itself (blocking waits in tests). + pub fn receiver(&self) -> &Receiver { + &self.receiver + } +} + +#[cfg(windows)] +type PlatformGuard = windows::Guard; +#[cfg(target_os = "linux")] +type PlatformGuard = linux::Guard; +#[cfg(target_os = "macos")] +type PlatformGuard = macos::Guard; +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] +type PlatformGuard = (); + +/// What a platform module answers. +pub(crate) enum PlatformLaunch { + Primary(PlatformGuard), + Forwarded, + Alone(Option), +} + +/// The plugin's `setup` for `identifier`, forwarding `args` and `cwd` when +/// another instance runs. Tests pass their own identifier and arguments; +/// the app passes [`current_args`] and [`current_cwd`]. +pub fn acquire(identifier: &str, args: &[String], cwd: &str, wake: Option) -> Launch { + let (sender, receiver) = mpsc::channel(); + let notify = Notify { sender, wake }; + let launch = platform_acquire(identifier, args, cwd, notify); + match launch { + PlatformLaunch::Primary(guard) => Launch::Primary(SingleInstance { + receiver, + guard: Some(guard), + }), + PlatformLaunch::Forwarded => Launch::Forwarded, + PlatformLaunch::Alone(guard) => Launch::Alone(guard.map(|guard| SingleInstance { + receiver, + guard: Some(guard), + })), + } +} + +#[cfg(windows)] +fn platform_acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + windows::acquire(identifier, args, cwd, notify) +} + +#[cfg(target_os = "linux")] +fn platform_acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + linux::acquire(identifier, args, cwd, notify) +} + +#[cfg(target_os = "macos")] +fn platform_acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + macos::acquire(identifier, args, cwd, notify) +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] +fn platform_acquire( + _identifier: &str, + _args: &[String], + _cwd: &str, + _notify: Notify, +) -> PlatformLaunch { + PlatformLaunch::Alone(None) +} + +/// `std::env::args()` as upstream forwards it, lossy instead of panicking +/// on an argument that is not UTF-8. +pub fn current_args() -> Vec { + std::env::args_os() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() +} + +/// `std::env::current_dir().unwrap_or_default().to_str().unwrap_or_default()`. +pub fn current_cwd() -> String { + std::env::current_dir() + .unwrap_or_default() + .to_str() + .unwrap_or_default() + .to_string() +} + +/// The Windows names, `{id}-sic`, `{id}-siw`, `{id}-sim` +/// (platform_impl/windows.rs lines 65-67 without the `semver` suffix). +pub fn windows_names(identifier: &str) -> (String, String, String) { + ( + format!("{identifier}-sic"), + format!("{identifier}-siw"), + format!("{identifier}-sim"), + ) +} + +/// The `WM_COPYDATA` payload, `"{cwd}|{args joined by |}\0"` +/// (platform_impl/windows.rs lines 76-82). +pub fn windows_payload(args: &[String], cwd: &str) -> String { + format!("{cwd}|{}\0", args.join("|")) +} + +/// The receiving split of that payload (lines 145-155): text up to the first +/// NUL, cut on `|`, the first piece is the cwd. +pub fn parse_windows_payload(data: &[u8]) -> (String, Vec) { + let end = data.iter().position(|b| *b == 0).unwrap_or(data.len()); + let text = String::from_utf8_lossy(&data[..end]); + let mut pieces = text.split('|'); + let cwd = pieces.next().unwrap_or_default().to_string(); + (cwd, pieces.map(str::to_string).collect()) +} + +/// The D-Bus name and object path (platform_impl/linux.rs lines 37-49). +pub fn dbus_name_and_path(identifier: &str) -> (String, String) { + let name = format!("{identifier}.SingleInstance"); + let mut path = name.replace('.', "/").replace('-', "_"); + if !path.starts_with('/') { + path = format!("/{path}"); + } + (name, path) +} + +/// The macOS socket (platform_impl/macos.rs lines 61-72). +pub fn macos_socket_path(identifier: &str) -> std::path::PathBuf { + let identifier = identifier.replace(['.', '-'], "_"); + std::path::PathBuf::from(format!("/tmp/{identifier}_si.sock")) +} diff --git a/crates/accshift-app/src/lifecycle/single_instance/windows.rs b/crates/accshift-app/src/lifecycle/single_instance/windows.rs new file mode 100644 index 00000000..1debaf3b --- /dev/null +++ b/crates/accshift-app/src/lifecycle/single_instance/windows.rs @@ -0,0 +1,270 @@ +//! platform_impl/windows.rs of `tauri-plugin-single-instance` 2.4.4. +//! +//! Upstream creates the mutex and the message window on Tauri's main thread, +//! whose event loop pumps the window's messages. The egui shell owns no +//! Win32 loop it can lend, so here one dedicated thread creates the mutex +//! (it must own it to release it), creates the same window and pumps it. +//! Names, styles, the `WM_COPYDATA` payload and the release order on exit +//! are upstream's. + +use super::{parse_windows_payload, windows_names, windows_payload, Notify, PlatformLaunch}; +use std::sync::mpsc; +use std::thread::JoinHandle; +use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE, HWND, LPARAM, LRESULT, WPARAM, +}; +use windows_sys::Win32::System::DataExchange::COPYDATASTRUCT; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; +use windows_sys::Win32::System::Threading::{CreateMutexW, ReleaseMutex}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, FindWindowW, GetMessageW, + GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassExW, SendMessageW, + SetWindowLongPtrW, TranslateMessage, CREATESTRUCTW, GWLP_USERDATA, GWL_STYLE, MSG, WM_APP, + WM_COPYDATA, WM_CREATE, WM_DESTROY, WNDCLASSEXW, WS_EX_LAYERED, WS_EX_NOACTIVATE, + WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_OVERLAPPED, WS_POPUP, WS_VISIBLE, +}; + +/// windows.rs line 41. +const WMCOPYDATA_SINGLE_INSTANCE_DATA: usize = 1542; + +/// Ours: asks the window thread to run `destroy` (release the mutex, then +/// destroy the window, upstream's order). +const WM_SINGLE_INSTANCE_DESTROY: u32 = WM_APP + 0x1542; + +struct UserData { + notify: Notify, + mutex: HANDLE, +} + +/// The running service: the window thread and its window. +pub struct Guard { + hwnd: isize, + /// A handle kept open for the run when upstream leaks it (mutex found, + /// window not found). + leaked_mutex: isize, + thread: Option>, +} + +impl Drop for Guard { + fn drop(&mut self) { + if self.hwnd != 0 { + // SAFETY: posting to a window this guard created; a dead handle + // only makes the call fail. + unsafe { PostMessageW(self.hwnd as HWND, WM_SINGLE_INSTANCE_DESTROY, 0, 0) }; + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + if self.leaked_mutex != 0 { + // SAFETY: a handle this guard owns, closed once. + unsafe { CloseHandle(self.leaked_mutex as HANDLE) }; + } + } +} + +enum ThreadAnswer { + Primary(isize), + Forwarded, + Alone(isize), +} + +fn encode_wide(text: &str) -> Vec { + text.encode_utf16().chain(std::iter::once(0)).collect() +} + +pub(super) fn acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + let (class, window, mutex) = windows_names(identifier); + let payload = windows_payload(args, cwd); + let (answer_tx, answer_rx) = mpsc::sync_channel::(1); + let thread = std::thread::Builder::new() + .name("accshift-single-instance".into()) + .spawn(move || run(class, window, mutex, payload, notify, answer_tx)); + let Ok(thread) = thread else { + return PlatformLaunch::Alone(None); + }; + match answer_rx.recv() { + Ok(ThreadAnswer::Primary(hwnd)) => PlatformLaunch::Primary(Guard { + hwnd, + leaked_mutex: 0, + thread: Some(thread), + }), + Ok(ThreadAnswer::Forwarded) => { + let _ = thread.join(); + PlatformLaunch::Forwarded + } + Ok(ThreadAnswer::Alone(mutex)) => { + let _ = thread.join(); + PlatformLaunch::Alone(Some(Guard { + hwnd: 0, + leaked_mutex: mutex, + thread: None, + })) + } + Err(_) => PlatformLaunch::Alone(None), + } +} + +/// windows.rs lines 54-108 (`setup`), then the message loop. +fn run( + class: String, + window: String, + mutex: String, + payload: String, + notify: Notify, + answer: mpsc::SyncSender, +) { + let class_name = encode_wide(&class); + let window_name = encode_wide(&window); + let mutex_name = encode_wide(&mutex); + // SAFETY: plain Win32 calls on NUL-terminated buffers that outlive them. + unsafe { + let hmutex = CreateMutexW(std::ptr::null(), 1, mutex_name.as_ptr()); + if GetLastError() == ERROR_ALREADY_EXISTS { + let hwnd = FindWindowW(class_name.as_ptr(), window_name.as_ptr()); + if !hwnd.is_null() { + let bytes = payload.as_bytes(); + let cds = COPYDATASTRUCT { + dwData: WMCOPYDATA_SINGLE_INSTANCE_DATA, + cbData: bytes.len() as _, + lpData: bytes.as_ptr() as _, + }; + SendMessageW(hwnd, WM_COPYDATA, 0, &cds as *const _ as LPARAM); + // Upstream exits right here, which closes the handle. + CloseHandle(hmutex); + let _ = answer.send(ThreadAnswer::Forwarded); + return; + } + let _ = answer.send(ThreadAnswer::Alone(hmutex as isize)); + return; + } + + let userdata = Box::into_raw(Box::new(UserData { + notify, + mutex: hmutex, + })); + let hwnd = create_event_target_window(&class_name, &window_name, userdata); + if hwnd.is_null() { + drop(Box::from_raw(userdata)); + ReleaseMutex(hmutex); + CloseHandle(hmutex); + let _ = answer.send(ThreadAnswer::Alone(0)); + return; + } + let _ = answer.send(ThreadAnswer::Primary(hwnd as isize)); + + let mut msg: MSG = std::mem::zeroed(); + while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } +} + +/// windows.rs lines 119-130 (`destroy`), on the thread that owns the mutex. +unsafe fn destroy(hwnd: HWND) { + let userdata = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut UserData; + if !userdata.is_null() && !(*userdata).mutex.is_null() { + ReleaseMutex((*userdata).mutex); + CloseHandle((*userdata).mutex); + (*userdata).mutex = std::ptr::null_mut(); + } + DestroyWindow(hwnd); +} + +/// windows.rs lines 132-167, plus the destroy request and the end of the +/// loop this thread runs. +unsafe extern "system" fn single_instance_window_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_CREATE => { + let create_struct = &*(lparam as *const CREATESTRUCTW); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, create_struct.lpCreateParams as isize); + 0 + } + WM_COPYDATA => { + let cds = &*(lparam as *const COPYDATASTRUCT); + if cds.dwData == WMCOPYDATA_SINGLE_INSTANCE_DATA { + let userdata = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *const UserData; + if !userdata.is_null() && !cds.lpData.is_null() { + let data = + std::slice::from_raw_parts(cds.lpData as *const u8, cds.cbData as usize); + let (cwd, args) = parse_windows_payload(data); + (*userdata).notify.deliver(args, cwd); + } + } + 1 + } + WM_SINGLE_INSTANCE_DESTROY => { + destroy(hwnd); + 0 + } + WM_DESTROY => { + let userdata = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut UserData; + if !userdata.is_null() { + SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); + let userdata = Box::from_raw(userdata); + // A window closed some other way still frees the mutex. + if !userdata.mutex.is_null() { + ReleaseMutex(userdata.mutex); + CloseHandle(userdata.mutex); + } + } + PostQuitMessage(0); + 0 + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +/// windows.rs lines 169-208: a 0x0 layered, transparent, non-activating +/// tool window, restyled to `WS_VISIBLE | WS_POPUP` like upstream. +unsafe fn create_event_target_window( + class_name: &[u16], + window_name: &[u16], + userdata: *mut UserData, +) -> HWND { + let class = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: 0, + lpfnWndProc: Some(single_instance_window_proc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: GetModuleHandleW(std::ptr::null()), + hIcon: std::ptr::null_mut(), + hCursor: std::ptr::null_mut(), + hbrBackground: std::ptr::null_mut(), + lpszMenuName: std::ptr::null(), + lpszClassName: class_name.as_ptr(), + hIconSm: std::ptr::null_mut(), + }; + // Upstream ignores the result too: a class left registered by an + // earlier instance in the same process is reused. + RegisterClassExW(&class); + let hwnd = CreateWindowExW( + WS_EX_NOACTIVATE | WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW, + class_name.as_ptr(), + window_name.as_ptr(), + WS_OVERLAPPED, + 0, + 0, + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + userdata as *const _, + ); + if !hwnd.is_null() { + SetWindowLongPtrW(hwnd, GWL_STYLE, (WS_VISIBLE | WS_POPUP) as isize); + } + hwnd +} diff --git a/crates/accshift-app/src/lifecycle/ui_scale.rs b/crates/accshift-app/src/lifecycle/ui_scale.rs new file mode 100644 index 00000000..40e2e967 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/ui_scale.rs @@ -0,0 +1,136 @@ +//! `src/lib/app/useUiScale.svelte.ts` and the decisions of +//! `src/lib/app/useThemeApplication.svelte.ts` that are not drawing. + +use crate::platforms::RuntimeOs; +use std::time::{Duration, Instant}; + +pub const UI_SCALE_STEP_PERCENT: i64 = 5; +pub const UI_SCALE_MIN_PERCENT: i64 = 75; +pub const UI_SCALE_MAX_PERCENT: i64 = 150; +pub const WHEEL_ZOOM_THRESHOLD: f64 = 80.0; +/// The persist debounce of `queuePersistUiScalePercent`. +pub const PERSIST_DELAY: Duration = Duration::from_millis(180); + +/// `clampUiScalePercent`: round to the 5 % step (JS `Math.round`, halves +/// up), then clamp to 75..150. +pub fn clamp_ui_scale_percent(value: f64) -> i64 { + let step = UI_SCALE_STEP_PERCENT as f64; + let rounded = ((value / step) + 0.5).floor() * step; + (rounded as i64).clamp(UI_SCALE_MIN_PERCENT, UI_SCALE_MAX_PERCENT) +} + +/// The wheel's `deltaMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WheelDeltaMode { + Pixel, + Line, + Page, +} + +/// `createUiScale`: the zoom shortcuts and the debounced save. +#[derive(Debug, Default)] +pub struct UiScale { + wheel_accumulator: f64, + persist_at: Option, + persist_value: i64, +} + +impl UiScale { + pub fn new() -> Self { + Self::default() + } + + /// `setUiScalePercent`: the new live value, or `None` when unchanged. + /// A changed value schedules its save [`PERSIST_DELAY`] later. + pub fn set(&mut self, current: i64, value: f64, now: Instant) -> Option { + let next = clamp_ui_scale_percent(value); + if next == current { + return None; + } + self.persist_at = Some(now + PERSIST_DELAY); + self.persist_value = next; + Some(next) + } + + /// `handleCtrlWheelZoom`. `page_height` is `window.innerHeight`. + pub fn wheel( + &mut self, + ctrl: bool, + delta_y: f64, + mode: WheelDeltaMode, + page_height: f64, + current: i64, + now: Instant, + ) -> Option { + if !ctrl { + self.wheel_accumulator = 0.0; + return None; + } + let unit = match mode { + WheelDeltaMode::Line => 16.0, + WheelDeltaMode::Page => page_height, + WheelDeltaMode::Pixel => 1.0, + }; + self.wheel_accumulator += delta_y * unit; + if self.wheel_accumulator.abs() < WHEEL_ZOOM_THRESHOLD { + return None; + } + let direction = if self.wheel_accumulator < 0.0 { 1 } else { -1 }; + self.wheel_accumulator = 0.0; + self.set( + current, + (current + direction * UI_SCALE_STEP_PERCENT) as f64, + now, + ) + } + + pub fn zoom_in(&mut self, current: i64, now: Instant) -> Option { + self.wheel_accumulator = 0.0; + self.set(current, (current + UI_SCALE_STEP_PERCENT) as f64, now) + } + + pub fn zoom_out(&mut self, current: i64, now: Instant) -> Option { + self.wheel_accumulator = 0.0; + self.set(current, (current - UI_SCALE_STEP_PERCENT) as f64, now) + } + + pub fn reset(&mut self, current: i64, now: Instant) -> Option { + self.wheel_accumulator = 0.0; + self.set(current, 100.0, now) + } + + /// When the debounced save is due. + pub fn next_persist(&self) -> Option { + self.persist_at + } + + /// The value to save now (`persistUiScalePercent`: save only when the + /// stored one differs), once the debounce elapsed. + pub fn take_due(&mut self, now: Instant) -> Option { + match self.persist_at { + Some(at) if now >= at => { + self.persist_at = None; + Some(clamp_ui_scale_percent(self.persist_value as f64)) + } + _ => None, + } + } + + /// `destroy`: a pending save is flushed with the live value, not dropped. + pub fn destroy(&mut self, current: i64) -> Option { + self.persist_at + .take() + .map(|_| clamp_ui_scale_percent(current as f64)) + } +} + +/// `liquidBackdropActive`: a Liquid Glass theme on Windows. +pub fn liquid_backdrop_active(theme_uses_liquid_glass: bool, os: RuntimeOs) -> bool { + theme_uses_liquid_glass && os == RuntimeOs::Windows +} + +/// `backdropAvailable`: never on Linux; with Liquid Glass, only once a +/// wallpaper snapshot exists. +pub fn backdrop_available(os: RuntimeOs, liquid_active: bool, wallpaper_present: bool) -> bool { + os != RuntimeOs::Linux && (!liquid_active || wallpaper_present) +} diff --git a/crates/accshift-app/src/lifecycle/update_flow.rs b/crates/accshift-app/src/lifecycle/update_flow.rs new file mode 100644 index 00000000..e8f26717 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/update_flow.rs @@ -0,0 +1,412 @@ +//! `src/lib/app/useAppUpdater.svelte.ts` as a state machine: the background +//! check, download and "restart to update" button. +//! +//! No async: each blocking half (check, download, install, relaunch) is an +//! [`UpdateCommand`] the shell runs on a [`crate::jobs::Jobs`] thread +//! ([`run_command`] does it with the real updater), then hands the result +//! back to the matching `on_*` method. Every method answers the outputs in +//! the order the TS code produced them: telemetry, toasts, log lines and the +//! next command. + +use super::updater::{InstallerHost, Update, Updater}; +use crate::text::Text; + +/// `UpdateState`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UpdateState { + #[default] + Idle, + Checking, + Downloading, + Ready, + Applying, +} + +/// `UpdateCheckErrorCode`, plus the three codes of the later stages. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateErrorCode { + TargetMissing, + ManifestInvalid, + CheckFailed, + DownloadFailed, + InstallFailed, + RelaunchFailed, +} + +impl UpdateErrorCode { + pub fn as_str(self) -> &'static str { + match self { + Self::TargetMissing => "update_target_missing", + Self::ManifestInvalid => "update_manifest_invalid", + Self::CheckFailed => "check_failed", + Self::DownloadFailed => "download_failed", + Self::InstallFailed => "install_failed", + Self::RelaunchFailed => "relaunch_failed", + } + } +} + +/// `TARGET_MISSING_RE`. +const TARGET_MISSING: &str = "found in the response `platforms` object"; + +/// `MANIFEST_INVALID_PATTERNS`, all case-insensitive, lowercased here. The +/// last regex, `/while parsing (major|minor|patch) version number/i`, is its +/// three spellings. +const MANIFEST_INVALID: [&str; 10] = [ + "could not fetch a valid release json", + "could not be decoded", + "invalid updater binary format", + "missing field", + "expected value at line", + "invalid type:", + "trailing characters at line", + "while parsing major version number", + "while parsing minor version number", + "while parsing patch version number", +]; + +/// `classifyUpdateCheckError`: sorts a failed check by its `Display` text. +/// `None` is the TS `undefined`. +pub fn classify_update_check_error(error: Option<&str>) -> UpdateErrorCode { + let text = error.unwrap_or("undefined"); + if text.contains(TARGET_MISSING) { + return UpdateErrorCode::TargetMissing; + } + let lower = text.to_lowercase(); + if MANIFEST_INVALID + .iter() + .any(|pattern| lower.contains(pattern)) + { + return UpdateErrorCode::ManifestInvalid; + } + UpdateErrorCode::CheckFailed +} + +/// `trackUpdate` stages. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateStage { + Available, + Downloaded, + Applied, + Failed, +} + +impl UpdateStage { + pub fn as_str(self) -> &'static str { + match self { + Self::Available => "available", + Self::Downloaded => "downloaded", + Self::Applied => "applied", + Self::Failed => "failed", + } + } +} + +/// A pending update as the state machine needs it: its version. +pub trait PendingUpdate: Clone { + fn version(&self) -> String; +} + +impl PendingUpdate for Update { + fn version(&self) -> String { + self.version.clone() + } +} + +/// The blocking work the shell runs next. +#[derive(Debug, Clone, PartialEq)] +pub enum UpdateCommand

{ + /// `check()`; answer with [`AppUpdater::on_check_result`]. + Check, + /// `update.download()`; answer with [`AppUpdater::on_download_result`]. + Download(P), + /// `beforeRelaunch` (flush the stores and settings), then + /// `update.install()`; answer with [`AppUpdater::on_install_result`]. + /// On Windows a real install exits the process here. + Install { update: P, bytes: Vec }, + /// `relaunch()`; answer only on failure, with + /// [`AppUpdater::on_relaunch_failed`]. + Relaunch, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + Info, + Error, +} + +/// What the flow asks of the shell, in order. +#[derive(Debug, Clone, PartialEq)] +pub enum UpdateOutput

{ + /// `trackUpdate(stage, targetVersion, errorCode)`; `None` is `undefined`. + Track { + stage: UpdateStage, + target_version: Option, + error_code: Option, + }, + Toast(Text), + /// `console.info` or `console.error` with the error text. + Log { + level: LogLevel, + message: String, + }, + Run(UpdateCommand

), +} + +/// `createAppUpdater`. +#[derive(Debug, Clone)] +pub struct AppUpdater

{ + state: UpdateState, + version: String, + pending: Option

, + bytes: Option>, + check_started: bool, + /// `import.meta.env.DEV`: the flow never runs in a dev build. + dev: bool, +} + +impl AppUpdater

{ + /// `dev`: true skips every check, as `import.meta.env.DEV` did. The + /// egui shell passes `cfg!(debug_assertions)`. + pub fn new(dev: bool) -> Self { + Self { + state: UpdateState::Idle, + version: String::new(), + pending: None, + bytes: None, + check_started: false, + dev, + } + } + + pub fn state(&self) -> UpdateState { + self.state + } + + pub fn version(&self) -> &str { + &self.version + } + + /// `ctaLabel`: the titlebar button text, only while ready or applying. + pub fn cta_label(&self) -> Option { + match self.state { + UpdateState::Ready => Some(Text::key("update.ctaAvailable")), + UpdateState::Applying => Some(Text::key("update.ctaInstalling")), + _ => None, + } + } + + /// `ctaTitle`. + pub fn cta_title(&self) -> Text { + if self.version.is_empty() { + Text::key("update.restartToApply") + } else { + Text::with( + "update.restartToApplyVersion", + [("version", self.version.clone())], + ) + } + } + + /// `ctaDisabled`. + pub fn cta_disabled(&self) -> bool { + self.state == UpdateState::Applying + } + + /// `startBackgroundUpdateFlow`, up to the `check()` call. App.svelte + /// calls it 3500 ms after mount ([`super::UPDATE_CHECK_DELAY`]). + pub fn start(&mut self) -> Vec> { + if self.dev || self.check_started { + return Vec::new(); + } + self.check_started = true; + self.state = UpdateState::Checking; + vec![UpdateOutput::Run(UpdateCommand::Check)] + } + + /// The `check()` result, the error as its `Display` text. + pub fn on_check_result(&mut self, result: Result, String>) -> Vec> { + match result { + Ok(None) => { + self.state = UpdateState::Idle; + Vec::new() + } + Ok(Some(update)) => { + self.version = update.version(); + self.pending = Some(update.clone()); + self.state = UpdateState::Downloading; + vec![ + UpdateOutput::Track { + stage: UpdateStage::Available, + target_version: Some(self.version.clone()), + error_code: None, + }, + UpdateOutput::Run(UpdateCommand::Download(update)), + ] + } + Err(error) => { + let code = classify_update_check_error(Some(&error)); + self.flow_failed(code, error) + } + } + } + + /// The `update.download()` result. + pub fn on_download_result(&mut self, result: Result, String>) -> Vec> { + match result { + Ok(bytes) => { + self.bytes = Some(bytes); + self.state = UpdateState::Ready; + let toast = if self.version.is_empty() { + Text::key("update.readyToast") + } else { + Text::with( + "update.readyToastVersion", + [("version", self.version.clone())], + ) + }; + vec![ + UpdateOutput::Track { + stage: UpdateStage::Downloaded, + target_version: Some(self.version.clone()), + error_code: None, + }, + UpdateOutput::Toast(toast), + ] + } + Err(error) => self.flow_failed(UpdateErrorCode::DownloadFailed, error), + } + } + + /// The catch of `startBackgroundUpdateFlow`. + fn flow_failed(&mut self, code: UpdateErrorCode, error: String) -> Vec> { + let log = if code == UpdateErrorCode::TargetMissing { + UpdateOutput::Log { + level: LogLevel::Info, + message: format!("Updater: this platform is not in the release manifest: {error}"), + } + } else { + UpdateOutput::Log { + level: LogLevel::Error, + message: format!("Updater check/download failed: {error}"), + } + }; + let track = UpdateOutput::Track { + stage: UpdateStage::Failed, + target_version: self.version_or_undefined(), + error_code: Some(code), + }; + self.reset(); + vec![log, track] + } + + fn version_or_undefined(&self) -> Option { + (!self.version.is_empty()).then(|| self.version.clone()) + } + + fn reset(&mut self) { + self.pending = None; + self.bytes = None; + self.version.clear(); + self.state = UpdateState::Idle; + self.check_started = false; + } + + /// `applyReadyUpdate`, up to `install()`: only from `Ready`. + pub fn apply(&mut self) -> Vec> { + if self.state != UpdateState::Ready { + return Vec::new(); + } + let Some(update) = self.pending.clone() else { + return Vec::new(); + }; + self.state = UpdateState::Applying; + let bytes = self.bytes.clone().unwrap_or_default(); + vec![UpdateOutput::Run(UpdateCommand::Install { update, bytes })] + } + + /// The `beforeRelaunch` plus `install()` result. Success tracks + /// `applied` before the relaunch, as TS: there is no "after". + pub fn on_install_result(&mut self, result: Result<(), String>) -> Vec> { + match result { + Ok(()) => vec![ + UpdateOutput::Track { + stage: UpdateStage::Applied, + target_version: Some(self.version.clone()), + error_code: None, + }, + UpdateOutput::Run(UpdateCommand::Relaunch), + ], + Err(error) => self.apply_failed(UpdateErrorCode::InstallFailed, error), + } + } + + /// A `relaunch()` that came back with an error. + pub fn on_relaunch_failed(&mut self, error: String) -> Vec> { + self.apply_failed(UpdateErrorCode::RelaunchFailed, error) + } + + /// The catch of `applyReadyUpdate`: track, reset, toast, check again. + fn apply_failed(&mut self, code: UpdateErrorCode, error: String) -> Vec> { + let mut outputs = vec![ + UpdateOutput::Log { + level: LogLevel::Error, + message: format!("Failed to restart for update: {error}"), + }, + UpdateOutput::Track { + stage: UpdateStage::Failed, + target_version: self.version_or_undefined(), + error_code: Some(code), + }, + ]; + self.reset(); + outputs.push(UpdateOutput::Toast(Text::key("update.restartFailed"))); + outputs.extend(self.start()); + outputs + } +} + +/// What [`run_command`] hands back, for the matching `on_*` method. +#[derive(Debug)] +pub enum CommandResult { + /// Boxed: an [`Update`] is far larger than the other variants. + Checked(Box, String>>), + Downloaded(Result, String>), + Installed(Result<(), String>), + /// Only seen when the relaunch failed: a successful one never returns. + RelaunchFailed(String), +} + +/// The side effects around an install: the flush before it and the +/// relaunch after it. +pub trait ApplyHost: InstallerHost { + /// `beforeRelaunch` (App.svelte `flushAppState`: the settings flush, + /// then the pending store saves; the first error wins). + fn before_relaunch(&self) -> Result<(), String>; + /// `relaunch()`; the real one does not return. + fn relaunch(&self) -> Result<(), String>; +} + +/// Runs one command with the real updater. Blocking: call it on a job +/// thread. +pub fn run_command( + updater: &Updater, + command: UpdateCommand, + host: &dyn ApplyHost, +) -> Option { + match command { + UpdateCommand::Check => Some(CommandResult::Checked(Box::new( + updater.check().map_err(|e| e.to_string()), + ))), + UpdateCommand::Download(update) => Some(CommandResult::Downloaded( + update.download(|_, _| {}, || {}).map_err(|e| e.to_string()), + )), + UpdateCommand::Install { update, bytes } => { + let result = host + .before_relaunch() + .and_then(|()| update.install(&bytes, host).map_err(|e| e.to_string())); + Some(CommandResult::Installed(result)) + } + UpdateCommand::Relaunch => host.relaunch().err().map(CommandResult::RelaunchFailed), + } +} diff --git a/crates/accshift-app/src/lifecycle/updater.rs b/crates/accshift-app/src/lifecycle/updater.rs new file mode 100644 index 00000000..10b0db5a --- /dev/null +++ b/crates/accshift-app/src/lifecycle/updater.rs @@ -0,0 +1,1160 @@ +//! The port of `tauri-plugin-updater` 2.11.0, the version the Tauri app +//! builds (Cargo.lock), with the settings of `crates/accshift-egui/tauri.conf.json`. +//! +//! Same contract on the wire and on disk: the same `latest.json` shapes +//! (`platforms` map or a single `url` and `signature`), the same target keys +//! (`windows-x86_64-nsis` then `windows-x86_64`), the same minisign check +//! with the same public key, the same installer launch (`ShellExecuteW` on +//! the NSIS setup with `/P /UPDATE /R /ARGS ...`, then exit). Source lines +//! cited below are in the plugin's `src/updater.rs` unless named otherwise. +//! +//! Blocking: [`Updater::check`] and [`Update::download`] use +//! `reqwest::blocking`, so call them from a [`crate::jobs::Jobs`] thread, +//! never from the UI thread and never inside a tokio runtime. +//! [`Update::install`] reaches the OS through [`InstallerHost`]: tests pass +//! a fake and no installer or exit ever runs. + +pub mod error; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod unix; + +pub use error::{UpdaterError, UpdaterResult}; + +use base64::Engine; +use percent_encoding::{AsciiSet, CONTROLS}; +use semver::Version; +use serde::de::Error as DeError; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use time::OffsetDateTime; +use url::Url; + +/// The plugin version this port follows (Cargo.lock of the Tauri app). +pub const PLUGIN_VERSION: &str = "2.11.0"; +/// updater.rs line 44: `tauri-plugin-updater/{CARGO_PKG_VERSION}`. The +/// release host sees the same client. +pub const UPDATER_USER_AGENT: &str = "tauri-plugin-updater/2.11.0"; +/// `plugins.updater.pubkey` of tauri.conf.json: the base64 of the minisign +/// public key file. +pub const PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY5QjgxOUY2RjJFMjU5NTMKUldSVFdlTHk5aG00YVVla0RIQ25oZGlMc2tWRVVMTUQrSG96bUtiQ2ZLNFlSRkpmS1pGcVlKSzcK"; +/// `plugins.updater.endpoints` of tauri.conf.json. +pub const ENDPOINTS: &[&str] = + &["https://github.com/klNuno/accshift/releases/latest/download/latest.json"]; +/// The `current_version` the check compares against: the workspace version, +/// which `version` of crates/accshift-egui/tauri.conf.json repeats. +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// `productName`: `package_info().name`, the prefix of the installer temp +/// folder. crates/accshift-egui/tauri.linux.conf.json overrides it. +#[cfg(target_os = "linux")] +pub const PRODUCT_NAME: &str = "accshift"; +#[cfg(not(target_os = "linux"))] +pub const PRODUCT_NAME: &str = "Accshift"; + +/// config.rs lines 11-25: `plugins.updater.windows.installMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WindowsUpdateInstallMode { + BasicUi, + Quiet, + #[default] + Passive, +} + +impl WindowsUpdateInstallMode { + /// config.rs lines 27-33. + pub fn msiexec_args(self) -> &'static [&'static str] { + match self { + Self::BasicUi => &["/qb+"], + Self::Quiet => &["/quiet"], + Self::Passive => &["/passive"], + } + } + + /// config.rs lines 35-38. + pub fn msi_restart_after_install_args(self) -> &'static [&'static str] { + &["AUTOLAUNCHAPP=True"] + } + + /// config.rs lines 41-50. + pub fn nsis_args(self) -> &'static [&'static str] { + match self { + Self::Passive => &["/P"], + Self::Quiet => &["/S"], + Self::BasicUi => &[], + } + } + + /// config.rs lines 52-58. + pub fn nsis_restart_after_install_args(self) -> &'static [&'static str] { + match self { + Self::BasicUi => &[], + _ => &["/R"], + } + } +} + +/// updater.rs lines 47-69. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Installer { + AppImage, + Deb, + Rpm, + App, + Msi, + Nsis, +} + +impl Installer { + pub fn name(self) -> &'static str { + match self { + Self::AppImage => "appimage", + Self::Deb => "deb", + Self::Rpm => "rpm", + Self::App => "app", + Self::Msi => "msi", + Self::Nsis => "nsis", + } + } +} + +/// tauri-utils 2.9.3 `platform::BundleType`, the kinds the bundler patches +/// into the binary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BundleType { + Deb, + Rpm, + AppImage, + Msi, + Nsis, + App, +} + +/// tauri-utils 2.9.3 `src/platform.rs` line 349. The Tauri bundler finds +/// this exact byte string in the executable and overwrites its last three +/// letters with the bundle kind (`NSS`, `MSI`, `DEB`, `RPM`, `APP`), so the +/// egui binary bundled by the same tool learns it was installed by NSIS and +/// asks for `windows-x86_64-nsis` first, like the Tauri one. `static mut` +/// for the same reason as upstream: an immutable static is folded away. +#[used] +static mut TAURI_BUNDLE_TYPE: &str = "__TAURI_BUNDLE_TYPE_VAR_UNK"; + +/// tauri-utils 2.9.3 `platform::bundle_type`, lines 353-370. +pub fn bundle_type() -> Option { + // SAFETY: read only; the value is only ever changed in the file on disk. + let marker: &str = unsafe { std::ptr::read_volatile(&raw const TAURI_BUNDLE_TYPE) }; + bundle_type_from_marker(marker) +} + +/// The match of [`bundle_type`], on any marker (tests). +pub fn bundle_type_from_marker(marker: &str) -> Option { + match marker { + "__TAURI_BUNDLE_TYPE_VAR_DEB" => Some(BundleType::Deb), + "__TAURI_BUNDLE_TYPE_VAR_RPM" => Some(BundleType::Rpm), + "__TAURI_BUNDLE_TYPE_VAR_APP" => Some(BundleType::AppImage), + "__TAURI_BUNDLE_TYPE_VAR_MSI" => Some(BundleType::Msi), + "__TAURI_BUNDLE_TYPE_VAR_NSS" => Some(BundleType::Nsis), + _ => { + if cfg!(target_os = "macos") { + Some(BundleType::App) + } else { + None + } + } + } +} + +/// updater.rs lines 1502-1512. +pub fn installer_for_bundle_type(bundle: Option) -> Option { + Some(match bundle? { + BundleType::Deb => Installer::Deb, + BundleType::Rpm => Installer::Rpm, + BundleType::AppImage => Installer::AppImage, + BundleType::Msi => Installer::Msi, + BundleType::Nsis => Installer::Nsis, + BundleType::App => Installer::App, + }) +} + +/// updater.rs lines 1395-1406 (`darwin`, not `macos`, as upstream). +pub fn updater_os() -> Option<&'static str> { + if cfg!(target_os = "linux") { + Some("linux") + } else if cfg!(target_os = "macos") { + Some("darwin") + } else if cfg!(target_os = "windows") { + Some("windows") + } else { + None + } +} + +/// updater.rs lines 1408-1422. +pub fn updater_arch() -> Option<&'static str> { + if cfg!(target_arch = "x86") { + Some("i686") + } else if cfg!(target_arch = "x86_64") { + Some("x86_64") + } else if cfg!(target_arch = "arm") { + Some("armv7") + } else if cfg!(target_arch = "aarch64") { + Some("aarch64") + } else if cfg!(target_arch = "riscv64") { + Some("riscv64") + } else { + None + } +} + +/// updater.rs lines 1387-1393: `{os}-{arch}`. +pub fn target() -> Option { + Some(format!("{}-{}", updater_os()?, updater_arch()?)) +} + +/// updater.rs lines 71-77. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ReleaseManifestPlatform { + pub url: Url, + pub signature: String, +} + +/// updater.rs lines 79-86. +#[derive(Debug, Clone, PartialEq)] +pub enum RemoteReleaseInner { + Dynamic(ReleaseManifestPlatform), + Static { + platforms: HashMap, + }, +} + +/// updater.rs lines 88-127: a parsed `latest.json`. +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteRelease { + pub version: Version, + pub notes: Option, + pub pub_date: Option, + pub data: RemoteReleaseInner, +} + +impl RemoteRelease { + /// updater.rs lines 105-114. + pub fn download_url(&self, target: &str) -> UpdaterResult<&Url> { + match &self.data { + RemoteReleaseInner::Dynamic(platform) => Ok(&platform.url), + RemoteReleaseInner::Static { platforms } => platforms + .get(target) + .map(|platform| &platform.url) + .ok_or_else(|| UpdaterError::TargetNotFound(target.to_string())), + } + } + + /// updater.rs lines 117-126. + pub fn signature(&self, target: &str) -> UpdaterResult<&String> { + match &self.data { + RemoteReleaseInner::Dynamic(platform) => Ok(&platform.signature), + RemoteReleaseInner::Static { platforms } => platforms + .get(target) + .map(|platform| &platform.signature) + .ok_or_else(|| UpdaterError::TargetNotFound(target.to_string())), + } + } + + /// The `platforms` keys, sorted (empty for the dynamic shape). + pub fn platform_keys(&self) -> Vec { + match &self.data { + RemoteReleaseInner::Dynamic(_) => Vec::new(), + RemoteReleaseInner::Static { platforms } => { + let mut keys: Vec = platforms.keys().cloned().collect(); + keys.sort(); + keys + } + } + } +} + +/// updater.rs lines 1454-1500, with the same error messages. +impl<'de> Deserialize<'de> for RemoteRelease { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct InnerRemoteRelease { + #[serde(alias = "name", deserialize_with = "parse_version")] + version: Version, + notes: Option, + pub_date: Option, + platforms: Option>, + url: Option, + signature: Option, + } + + let release = InnerRemoteRelease::deserialize(deserializer)?; + let pub_date = match release.pub_date { + Some(date) => Some( + OffsetDateTime::parse(&date, &time::format_description::well_known::Rfc3339) + .map_err(|e| DeError::custom(format!("invalid value for `pub_date`: {e}")))?, + ), + None => None, + }; + Ok(RemoteRelease { + version: release.version, + notes: release.notes, + pub_date, + data: match release.platforms { + Some(platforms) => RemoteReleaseInner::Static { platforms }, + None => RemoteReleaseInner::Dynamic(ReleaseManifestPlatform { + url: release.url.ok_or_else(|| { + DeError::custom("the `url` field was not set on the updater response") + })?, + signature: release.signature.ok_or_else(|| { + DeError::custom("the `signature` field was not set on the updater response") + })?, + }), + }, + }) + } +} + +/// updater.rs lines 1514-1521: a leading `v` is dropped. +fn parse_version<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let text = String::deserialize(deserializer)?; + Version::parse(text.trim_start_matches('v')).map_err(DeError::custom) +} + +/// Parses a manifest body the way the check does (`serde_json::from_value`). +pub fn parse_manifest(json: serde_json::Value) -> UpdaterResult { + serde_json::from_value(json).map_err(|e| UpdaterError::Serialization(e.to_string())) +} + +/// updater.rs lines 1524-1534: base64 of the key file, base64 of the `.sig` +/// file, legacy (non-prehashed) signatures accepted. +pub fn verify_signature(data: &[u8], release_signature: &str, pub_key: &str) -> UpdaterResult<()> { + let pub_key_decoded = base64_to_string(pub_key)?; + let public_key = minisign_verify::PublicKey::decode(&pub_key_decoded) + .map_err(|e| UpdaterError::Minisign(e.to_string()))?; + let signature_decoded = base64_to_string(release_signature)?; + let signature = minisign_verify::Signature::decode(&signature_decoded) + .map_err(|e| UpdaterError::Minisign(e.to_string()))?; + public_key + .verify(data, &signature, true) + .map_err(|e| UpdaterError::Minisign(e.to_string())) +} + +/// updater.rs lines 1536-1543. +fn base64_to_string(base64_string: &str) -> UpdaterResult { + let decoded = base64::engine::general_purpose::STANDARD + .decode(base64_string) + .map_err(|e| UpdaterError::Base64(e.to_string()))?; + String::from_utf8(decoded).map_err(|_| UpdaterError::SignatureUtf8(base64_string.into())) +} + +/// What `UpdaterBuilder` collects (updater.rs lines 150-405) and the plugin +/// fills from its config and the app (lib.rs lines 79-123). +#[derive(Clone)] +pub struct UpdaterConfig { + pub pubkey: String, + pub endpoints: Vec, + pub install_mode: WindowsUpdateInstallMode, + /// `plugins.updater.windows.installerArgs`; none in tauri.conf.json. + pub installer_args: Vec, + pub current_version: Version, + pub app_name: String, + /// `UpdaterBuilder::target`, unset by the app. + pub target: Option, + /// `None`: no timeout, as the async client the plugin uses. + pub timeout: Option, + pub headers: Vec<(String, String)>, + /// `env().args_os`, the whole command line including the executable. + pub current_exe_args: Vec, + pub restart_after_install: bool, + /// `bundle_type()`, read once. + pub bundle_type: Option, + /// The current executable (`tauri_utils::platform::current_exe`), or the + /// `APPIMAGE` file on Linux. + pub executable_path: Option, + /// `version_comparator`: `None` is `release > current`. The JS `check` + /// with `allowDowngrades` passes `release != current`. + pub allow_downgrades: bool, +} + +impl UpdaterConfig { + /// The app's settings: tauri.conf.json, this process's arguments and + /// bundle marker. + pub fn accshift() -> Self { + Self { + pubkey: PUBKEY.to_string(), + endpoints: ENDPOINTS + .iter() + .map(|endpoint| Url::parse(endpoint).expect("tauri.conf.json endpoint parses")) + .collect(), + install_mode: WindowsUpdateInstallMode::Passive, + installer_args: Vec::new(), + current_version: Version::parse(APP_VERSION).expect("APP_VERSION is semver"), + app_name: PRODUCT_NAME.to_string(), + target: None, + timeout: None, + headers: Vec::new(), + current_exe_args: std::env::args_os().collect(), + restart_after_install: true, + bundle_type: bundle_type(), + executable_path: default_executable_path(), + allow_downgrades: false, + } + } + + /// config.rs `validate_endpoints`: anything but https is refused in a + /// release build and only warned about in a debug one. + pub fn validate(&self) -> UpdaterResult<()> { + if self.endpoints.is_empty() { + return Err(UpdaterError::EmptyEndpoints); + } + for endpoint in &self.endpoints { + if endpoint.scheme() != "https" && !cfg!(debug_assertions) { + return Err(UpdaterError::InsecureTransportProtocol); + } + } + Ok(()) + } +} + +/// lib.rs lines 105-113 and updater.rs lines 376-383: the AppImage on Linux +/// when `APPIMAGE` is set, else the canonical current executable. +fn default_executable_path() -> Option { + #[cfg(target_os = "linux")] + if let Some(appimage) = std::env::var_os("APPIMAGE") { + return Some(PathBuf::from(appimage)); + } + std::env::current_exe().and_then(std::fs::canonicalize).ok() +} + +/// updater.rs lines 1424-1452: the folder the update replaces. The +/// executable's folder, or the `.app` on macOS; Linux uses the file itself. +pub fn extract_path_from_executable(executable_path: &Path) -> UpdaterResult { + let extract_path = executable_path + .parent() + .map(PathBuf::from) + .ok_or(UpdaterError::FailedToDetermineExtractPath)?; + #[cfg(target_os = "macos")] + if extract_path + .display() + .to_string() + .contains("Contents/MacOS") + { + return extract_path + .parent() + .and_then(Path::parent) + .map(PathBuf::from) + .ok_or(UpdaterError::FailedToDetermineExtractPath); + } + Ok(extract_path) +} + +/// `CONTROLS` plus `+`, updater.rs line 468. +const VERSION_ENCODE_SET: &AsciiSet = &CONTROLS.add(b'+'); + +/// One configured updater. +pub struct Updater { + config: UpdaterConfig, + arch: &'static str, +} + +impl Updater { + /// `UpdaterBuilder::build` (updater.rs lines 365-405). + pub fn new(config: UpdaterConfig) -> UpdaterResult { + config.validate()?; + let arch = updater_arch().ok_or(UpdaterError::UnsupportedArch)?; + Ok(Self { config, arch }) + } + + pub fn config(&self) -> &UpdaterConfig { + &self.config + } + + /// updater.rs lines 459-488: `{{current_version}}`, `{{target}}`, + /// `{{arch}}` and `{{bundle_type}}`, raw and percent-encoded. + pub fn endpoint_url(&self, endpoint: &Url, target: &str) -> UpdaterResult { + let version = self.config.current_version.to_string(); + let encoded_version = + percent_encoding::percent_encode(version.as_bytes(), VERSION_ENCODE_SET).to_string(); + let installer = installer_for_bundle_type(self.config.bundle_type) + .map(Installer::name) + .unwrap_or("unknown"); + endpoint + .to_string() + .replace("%7B%7Bcurrent_version%7D%7D", &encoded_version) + .replace("%7B%7Btarget%7D%7D", target) + .replace("%7B%7Barch%7D%7D", self.arch) + .replace("%7B%7Bbundle_type%7D%7D", installer) + .replace("{{current_version}}", &encoded_version) + .replace("{{target}}", target) + .replace("{{arch}}", self.arch) + .replace("{{bundle_type}}", installer) + .parse() + .map_err(|e: url::ParseError| UpdaterError::UrlParse(e.to_string())) + } + + fn client(&self) -> UpdaterResult { + accshift_core::http::blocking_client_builder() + .user_agent(UPDATER_USER_AGENT) + .timeout(self.config.timeout) + .build() + .map_err(|e| UpdaterError::Reqwest(e.to_string())) + } + + fn headers(&self, accept: &'static str) -> UpdaterResult { + headers_with_accept(&self.config.headers, accept) + } + + /// `Updater::check`, updater.rs lines 432-606. `Ok(None)`: no update + /// (204, or the release is not newer). + pub fn check(&self) -> UpdaterResult> { + let headers = self.headers("application/json")?; + + #[cfg(target_os = "linux")] + { + if std::env::var_os("SSL_CERT_FILE").is_none() { + std::env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); + } + if std::env::var_os("SSL_CERT_DIR").is_none() { + std::env::set_var("SSL_CERT_DIR", "/etc/ssl/certs"); + } + } + + let target = match &self.config.target { + Some(target) => target.clone(), + None => updater_os().ok_or(UpdaterError::UnsupportedOs)?.to_string(), + }; + + let mut remote_release: Option = None; + let mut raw_json: Option = None; + let mut last_error: Option = None; + for endpoint in &self.config.endpoints { + let url = self.endpoint_url(endpoint, &target)?; + let response = self.client()?.get(url).headers(headers.clone()).send(); + match response { + Ok(response) => { + if response.status().is_success() { + if response.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(None); + } + let body: serde_json::Value = response + .json() + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + raw_json = Some(body.clone()); + match parse_manifest(body) { + Ok(release) => { + last_error = None; + remote_release = Some(release); + break; + } + Err(error) => last_error = Some(error), + } + } + // A non-success status only logs upstream (line 545): + // it is not kept as the last error. + } + Err(error) => last_error = Some(UpdaterError::Reqwest(error.to_string())), + } + } + + if let Some(error) = last_error { + return Err(error); + } + let release = remote_release.ok_or(UpdaterError::ReleaseNotFound)?; + + let should_update = if self.config.allow_downgrades { + release.version != self.config.current_version + } else { + release.version > self.config.current_version + }; + + // Resolved before the version test, as upstream (lines 581-582): a + // manifest without this platform fails even when nothing is newer. + let installer = installer_for_bundle_type(self.config.bundle_type); + let (download_url, signature) = self.get_urls(&release, installer)?; + + if !should_update { + return Ok(None); + } + Ok(Some(Update { + body: release.notes.clone(), + current_version: self.config.current_version.to_string(), + version: release.version.to_string(), + date: release.pub_date, + target, + download_url: download_url.clone(), + signature: signature.clone(), + raw_json: raw_json.unwrap_or_default(), + timeout: None, + headers: self.config.headers.clone(), + install: InstallContext { + app_name: self.config.app_name.clone(), + install_mode: self.config.install_mode, + installer_args: self.config.installer_args.clone(), + current_exe_args: self.config.current_exe_args.clone(), + restart_after_install: self.config.restart_after_install, + bundle_type: self.config.bundle_type, + executable_path: self.config.executable_path.clone(), + pubkey: self.config.pubkey.clone(), + }, + })) + } + + /// The release a check would read, without the version test or the + /// target lookup (acceptance check against the real endpoint). + pub fn fetch_release(&self) -> UpdaterResult> { + let target = updater_os().ok_or(UpdaterError::UnsupportedOs)?; + let mut last_error = None; + for endpoint in &self.config.endpoints { + let url = self.endpoint_url(endpoint, target)?; + match self + .client()? + .get(url) + .headers(self.headers("application/json")?) + .send() + { + Ok(response) if response.status() == reqwest::StatusCode::NO_CONTENT => { + return Ok(None) + } + Ok(response) if response.status().is_success() => { + let body: serde_json::Value = response + .json() + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + return parse_manifest(body).map(Some); + } + Ok(_) => {} + Err(error) => last_error = Some(UpdaterError::Reqwest(error.to_string())), + } + } + Err(last_error.unwrap_or(UpdaterError::ReleaseNotFound)) + } + + /// updater.rs lines 608-638. + pub fn get_urls<'a>( + &self, + release: &'a RemoteRelease, + installer: Option, + ) -> UpdaterResult<(&'a Url, &'a String)> { + if let Some(target) = &self.config.target { + return Ok((release.download_url(target)?, release.signature(target)?)); + } + let os = updater_os().ok_or(UpdaterError::UnsupportedOs)?; + let targets = fallback_targets(os, self.arch, installer); + for target in &targets { + if let (Ok(url), Ok(signature)) = + (release.download_url(target), release.signature(target)) + { + return Ok((url, signature)); + } + } + Err(UpdaterError::TargetsNotFound(targets)) + } +} + +/// `[{os}-{arch}-{installer}, {os}-{arch}]`, updater.rs lines 618-626. +pub fn fallback_targets(os: &str, arch: &str, installer: Option) -> Vec { + let mut targets = Vec::new(); + if let Some(installer) = installer { + targets.push(format!("{os}-{arch}-{}", installer.name())); + } + targets.push(format!("{os}-{arch}")); + targets +} + +fn headers_with_accept( + headers: &[(String, String)], + accept: &'static str, +) -> UpdaterResult { + use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT}; + let mut map = HeaderMap::new(); + for (name, value) in headers { + let name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + let value = + HeaderValue::from_str(value).map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + map.insert(name, value); + } + if !map.contains_key(ACCEPT) { + map.insert(ACCEPT, HeaderValue::from_static(accept)); + } + Ok(map) +} + +/// What the install needs from the updater that found the release. +#[derive(Debug, Clone)] +pub struct InstallContext { + pub app_name: String, + pub install_mode: WindowsUpdateInstallMode, + pub installer_args: Vec, + pub current_exe_args: Vec, + pub restart_after_install: bool, + pub bundle_type: Option, + pub executable_path: Option, + pub pubkey: String, +} + +/// The JS `DownloadEvent` (commands.rs lines 121-133): `Started` on the +/// first chunk, then one `Progress` per chunk, then `Finished`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DownloadEvent { + Started { content_length: Option }, + Progress { chunk_length: usize }, + Finished, +} + +/// updater.rs lines 640-668: an available update. +#[derive(Debug, Clone)] +pub struct Update { + pub body: Option, + pub current_version: String, + pub version: String, + pub date: Option, + pub target: String, + pub download_url: Url, + pub signature: String, + pub raw_json: serde_json::Value, + pub timeout: Option, + pub headers: Vec<(String, String)>, + pub install: InstallContext, +} + +/// The side effects of an install: the hook before exit, the installer +/// launch, the exit, the macOS main-thread hop. [`SystemInstaller`] is the +/// real one; tests record calls instead. +pub trait InstallerHost { + /// `on_before_exit`: the plugin runs `app.cleanup_before_exit()` (hides + /// the windows on Windows). + fn before_exit(&self); + /// `ShellExecuteW(null, "open", file, parameters, null, SW_SHOW)`; + /// an `Err` when it answers 32 or less. + fn shell_execute(&self, file: &OsStr, parameters: &OsStr) -> std::io::Result<()>; + /// `std::process::exit(code)`. The real one never returns. + fn exit(&self, code: i32); + /// `run_on_main_thread` (macOS admin prompt). Inline by default. + fn run_on_main_thread(&self, task: Box) -> Result<(), String> { + task(); + Ok(()) + } +} + +/// The real [`InstallerHost`]. +pub struct SystemInstaller { + pub before_exit: Box, +} + +impl InstallerHost for SystemInstaller { + fn before_exit(&self) { + (self.before_exit)(); + } + + #[cfg(windows)] + fn shell_execute(&self, file: &OsStr, parameters: &OsStr) -> std::io::Result<()> { + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW; + let operation = encode_wide("open"); + let file = encode_wide(file); + let parameters = encode_wide(parameters); + // SAFETY: every pointer is a NUL-terminated UTF-16 buffer that + // outlives the call. + let result = unsafe { + ShellExecuteW( + std::ptr::null_mut(), + operation.as_ptr(), + file.as_ptr(), + parameters.as_ptr(), + std::ptr::null(), + SW_SHOW, + ) + }; + if result as isize <= 32 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + } + + #[cfg(not(windows))] + fn shell_execute(&self, _file: &OsStr, _parameters: &OsStr) -> std::io::Result<()> { + Err(std::io::Error::from(std::io::ErrorKind::Unsupported)) + } + + fn exit(&self, code: i32) { + std::process::exit(code); + } +} + +#[cfg(windows)] +fn encode_wide(text: impl AsRef) -> Vec { + use std::os::windows::ffi::OsStrExt; + text.as_ref() + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +impl Update { + /// The `pub_date` as RFC 3339, as the JS `Update.date` carries it. + pub fn date_rfc3339(&self) -> Option { + self.date.and_then(|date| { + date.format(&time::format_description::well_known::Rfc3339) + .ok() + }) + } + + /// `Update::download`, updater.rs lines 680-743: the bytes, verified + /// against the release signature. `on_chunk(len, content_length)` per + /// chunk read, `on_download_finish` once the body is read. + pub fn download( + &self, + mut on_chunk: impl FnMut(usize, Option), + on_download_finish: impl FnOnce(), + ) -> UpdaterResult> { + let headers = headers_with_accept(&self.headers, "application/octet-stream")?; + let client = accshift_core::http::blocking_client_builder() + .user_agent(UPDATER_USER_AGENT) + .timeout(self.timeout) + .build() + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + let mut response = client + .get(self.download_url.clone()) + .headers(headers) + .send() + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + if !response.status().is_success() { + return Err(UpdaterError::Network(format!( + "Download request failed with status: {}", + response.status() + ))); + } + let content_length: Option = response + .headers() + .get("Content-Length") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()); + + let mut buffer = Vec::new(); + let mut chunk = vec![0u8; 64 * 1024]; + loop { + let read = response + .read(&mut chunk) + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + if read == 0 { + break; + } + on_chunk(read, content_length); + buffer.extend_from_slice(&chunk[..read]); + } + on_download_finish(); + + verify_signature(&buffer, &self.signature, &self.install.pubkey)?; + Ok(buffer) + } + + /// [`Self::download`] reporting [`DownloadEvent`]s, as the JS command. + pub fn download_with_events( + &self, + mut on_event: impl FnMut(DownloadEvent), + ) -> UpdaterResult> { + let mut first_chunk = true; + let events = std::cell::RefCell::new(&mut on_event); + self.download( + |chunk_length, content_length| { + let mut on_event = events.borrow_mut(); + if first_chunk { + first_chunk = false; + on_event(DownloadEvent::Started { content_length }); + } + on_event(DownloadEvent::Progress { chunk_length }); + }, + || (events.borrow_mut())(DownloadEvent::Finished), + ) + } + + /// `Update::install`, updater.rs lines 751-753. Windows: launches the + /// installer then calls `host.exit(0)`. Linux and macOS: replaces the + /// app in place; the caller relaunches. + pub fn install(&self, bytes: &[u8], host: &dyn InstallerHost) -> UpdaterResult<()> { + self.install_inner(bytes, host) + } + + #[cfg(windows)] + fn install_inner(&self, bytes: &[u8], host: &dyn InstallerHost) -> UpdaterResult<()> { + let updater_type = self.extract(bytes)?; + host.before_exit(); + let file = windows_installer_file(&updater_type, std::env::var("SYSTEMROOT").ok()); + let parameters = self.updater_parameters(&updater_type); + host.shell_execute(&file, ¶meters) + .map_err(UpdaterError::Io)?; + host.exit(0); + Ok(()) + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn install_inner(&self, bytes: &[u8], host: &dyn InstallerHost) -> UpdaterResult<()> { + let executable = self + .install + .executable_path + .clone() + .ok_or(UpdaterError::FailedToDetermineExtractPath)?; + // updater.rs lines 379-383: Linux replaces the file itself. + let extract_path = if cfg!(target_os = "linux") { + executable + } else { + extract_path_from_executable(&executable)? + }; + let main_thread = |task: Box| host.run_on_main_thread(task); + unix::install_files( + installer_for_bundle_type(self.install.bundle_type), + &extract_path, + bytes, + &main_thread, + ) + } + + #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] + fn install_inner(&self, _bytes: &[u8], _host: &dyn InstallerHost) -> UpdaterResult<()> { + Err(UpdaterError::UnsupportedOs) + } + + /// The installer command line, updater.rs lines 879-939. + pub fn updater_parameters(&self, updater_type: &WindowsUpdaterType) -> OsString { + let context = &self.install; + let mode = context.install_mode; + let current_args = context.current_exe_args.get(1..).unwrap_or_default(); + let mut args: Vec = Vec::new(); + match updater_type { + WindowsUpdaterType::Nsis { .. } => { + args.extend(mode.nsis_args().iter().map(OsString::from)); + args.push("/UPDATE".into()); + if context.restart_after_install { + args.extend( + mode.nsis_restart_after_install_args() + .iter() + .map(OsString::from), + ); + args.push("/ARGS".into()); + args.extend(current_args.iter().map(escape_nsis_current_exe_arg)); + } + args.extend(context.installer_args.iter().cloned()); + } + WindowsUpdaterType::Msi { path, .. } => { + args.push("/i".into()); + args.push(path.as_os_str().to_os_string()); + args.extend(mode.msiexec_args().iter().map(OsString::from)); + args.push("/promptrestart".into()); + args.extend(context.installer_args.iter().cloned()); + if context.restart_after_install { + args.extend( + mode.msi_restart_after_install_args() + .iter() + .map(OsString::from), + ); + let escaped = current_args + .iter() + .map(escape_msi_property_arg) + .collect::>() + .join(" "); + args.push(format!("LAUNCHAPPARGS=\"{escaped}\"").into()); + } + } + } + join_os(&args, " ") + } + + /// updater.rs lines 947-954 without the plugin's `zip` feature, off in + /// the Tauri app too: the release feed serves the installer itself. + pub fn extract(&self, bytes: &[u8]) -> UpdaterResult { + self.extract_exe(bytes) + } + + /// updater.rs lines 956-965: kept on purpose (`keep`), the installer + /// runs after this process exits. + fn make_temp_dir(&self) -> UpdaterResult { + Ok(tempfile::Builder::new() + .prefix(&format!( + "{}-{}-updater-", + self.install.app_name, self.version + )) + .tempdir()? + .keep()) + } + + /// updater.rs lines 988-998. + fn extract_exe(&self, bytes: &[u8]) -> UpdaterResult { + if infer::app::is_exe(bytes) { + let (path, temp) = self.write_to_temp(bytes, ".exe")?; + Ok(WindowsUpdaterType::nsis(path, temp)) + } else if infer::archive::is_msi(bytes) { + let (path, temp) = self.write_to_temp(bytes, ".msi")?; + Ok(WindowsUpdaterType::msi(path, temp)) + } else { + Err(UpdaterError::InvalidUpdaterFormat) + } + } + + /// updater.rs lines 1000-1021: `{app}-{version}-installer{ext}` in a + /// kept temp folder; the file itself is deleted when the value drops. + fn write_to_temp( + &self, + bytes: &[u8], + ext: &str, + ) -> UpdaterResult<(PathBuf, Option)> { + use std::io::Write; + let temp_dir = self.make_temp_dir()?; + let mut temp_file = tempfile::Builder::new() + .prefix(&format!( + "{}-{}-installer", + self.install.app_name, self.version + )) + .suffix(ext) + .rand_bytes(0) + .tempfile_in(temp_dir)?; + temp_file.write_all(bytes)?; + let temp = temp_file.into_temp_path(); + Ok((temp.to_path_buf(), Some(temp))) + } +} + +/// updater.rs lines 786-810. The MSI path is stored quoted, as upstream. +#[derive(Debug)] +pub enum WindowsUpdaterType { + Nsis { + path: PathBuf, + temp: Option, + }, + Msi { + path: PathBuf, + temp: Option, + }, +} + +impl WindowsUpdaterType { + pub fn nsis(path: PathBuf, temp: Option) -> Self { + Self::Nsis { path, temp } + } + + pub fn msi(path: PathBuf, temp: Option) -> Self { + let mut quoted = OsString::from("\""); + quoted.push(path.as_os_str()); + quoted.push("\""); + Self::Msi { + path: PathBuf::from(quoted), + temp, + } + } + + pub fn path(&self) -> &Path { + match self { + Self::Nsis { path, .. } | Self::Msi { path, .. } => path, + } + } +} + +/// updater.rs lines 848-852: the NSIS setup itself, or +/// `%SYSTEMROOT%\System32\msiexec.exe` (`msiexec.exe` without the variable). +pub fn windows_installer_file( + updater_type: &WindowsUpdaterType, + system_root: Option, +) -> OsString { + match updater_type { + WindowsUpdaterType::Nsis { path, .. } => path.as_os_str().to_os_string(), + WindowsUpdaterType::Msi { .. } => match system_root { + Some(root) => OsString::from(format!("{root}\\System32\\msiexec.exe")), + None => OsString::from("msiexec.exe"), + }, + } +} + +fn join_os(parts: &[OsString], separator: &str) -> OsString { + let mut joined = OsString::new(); + for (index, part) in parts.iter().enumerate() { + if index > 0 { + joined.push(separator); + } + joined.push(part); + } + joined +} + +/// updater.rs lines 1572-1609: std's Windows argument quoting, plus `/` +/// forcing quotes so NSIS does not read an option. Works on UTF-16 units on +/// Windows and on the lossy text elsewhere. +pub fn escape_nsis_current_exe_arg(arg: impl AsRef) -> OsString { + let arg = arg.as_ref(); + let quote = arg + .as_encoded_bytes() + .iter() + .any(|c| *c == b' ' || *c == b'\t' || *c == b'/') + || arg.is_empty(); + let units = os_to_units(arg); + let mut cmd: Vec = Vec::with_capacity(units.len() + 2); + if quote { + cmd.push('"' as u16); + } + let mut backslashes: usize = 0; + for x in units { + if x == '\\' as u16 { + backslashes += 1; + } else { + if x == '"' as u16 { + cmd.extend((0..=backslashes).map(|_| '\\' as u16)); + } + backslashes = 0; + } + cmd.push(x); + } + if quote { + cmd.extend((0..backslashes).map(|_| '\\' as u16)); + cmd.push('"' as u16); + } + units_to_os(cmd) +} + +#[cfg(windows)] +fn os_to_units(text: &OsStr) -> Vec { + use std::os::windows::ffi::OsStrExt; + text.encode_wide().collect() +} + +#[cfg(not(windows))] +fn os_to_units(text: &OsStr) -> Vec { + text.to_string_lossy().encode_utf16().collect() +} + +#[cfg(windows)] +fn units_to_os(units: Vec) -> OsString { + use std::os::windows::ffi::OsStringExt; + OsString::from_wide(&units) +} + +#[cfg(not(windows))] +fn units_to_os(units: Vec) -> OsString { + OsString::from(String::from_utf16_lossy(&units)) +} + +/// updater.rs lines 1612-1635: one argument inside `LAUNCHAPPARGS="..."`. +pub fn escape_msi_property_arg(arg: impl AsRef) -> String { + let mut arg = arg.as_ref().to_string_lossy().to_string(); + if arg.is_empty() { + return "\"\"\"\"".to_string(); + } else if !arg.contains(' ') && !arg.contains('"') { + return arg; + } + if arg.contains('"') { + arg = arg.replace('"', r#""""""#); + } + if arg.starts_with('-') { + if let Some((a1, a2)) = arg.split_once('=') { + format!("{a1}=\"\"{a2}\"\"") + } else { + format!("\"\"{arg}\"\"") + } + } else { + format!("\"\"{arg}\"\"") + } +} diff --git a/crates/accshift-app/src/lifecycle/updater/error.rs b/crates/accshift-app/src/lifecycle/updater/error.rs new file mode 100644 index 00000000..f310abd1 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/updater/error.rs @@ -0,0 +1,125 @@ +//! `tauri-plugin-updater` 2.11.0 `src/error.rs`, the variants this port can +//! produce, with the same `Display` strings. +//! +//! The TS side never saw a variant: the plugin serialized the error through +//! `Display` and `useAppUpdater` sorted the string with regular expressions +//! ([`super::super::update_flow::classify_update_check_error`]). The strings +//! are therefore the contract. The plugin's `#[error(transparent)]` variants +//! keep the source's own message here; they hold it as text so this file +//! needs nothing but `std` (it is shared with the Linux and macOS installers). + +use std::fmt; + +#[derive(Debug)] +#[non_exhaustive] +pub enum UpdaterError { + /// error.rs line 13. + EmptyEndpoints, + /// Transparent `std::io::Error`. + Io(std::io::Error), + /// Transparent `semver::Error`. + Semver(String), + /// Transparent `serde_json::Error`. + Serialization(String), + /// error.rs line 25. + ReleaseNotFound, + /// error.rs line 28. + UnsupportedArch, + /// error.rs line 31. + UnsupportedOs, + /// error.rs line 34. + FailedToDetermineExtractPath, + /// Transparent `url::ParseError`. + UrlParse(String), + /// Transparent `reqwest::Error`. + Reqwest(String), + /// error.rs line 43. + TargetNotFound(String), + /// error.rs lines 46-49. + TargetsNotFound(Vec), + /// error.rs line 51, "`{0}`". + Network(String), + /// Transparent `minisign_verify::Error`. + Minisign(String), + /// Transparent `base64::DecodeError`. + Base64(String), + /// error.rs line 60. + SignatureUtf8(String), + /// error.rs line 67. + TempDirNotOnSameMountPoint, + /// error.rs line 69. + BinaryNotFoundInArchive, + /// error.rs line 71. + TempDirNotFound, + /// error.rs line 73. + AuthenticationFailed, + /// error.rs line 77. + PackageInstallFailed, + /// error.rs line 79. + InvalidUpdaterFormat, + /// error.rs line 90. + InsecureTransportProtocol, +} + +impl fmt::Display for UpdaterError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyEndpoints => f.write_str("Updater does not have any endpoints set."), + Self::Io(error) => error.fmt(f), + Self::Semver(text) + | Self::Serialization(text) + | Self::UrlParse(text) + | Self::Reqwest(text) + | Self::Minisign(text) + | Self::Base64(text) => f.write_str(text), + Self::ReleaseNotFound => { + f.write_str("Could not fetch a valid release JSON from the remote") + } + Self::UnsupportedArch => f.write_str( + "Unsupported application architecture, expected one of `x86`, `x86_64`, `arm` or `aarch64`.", + ), + Self::UnsupportedOs => { + f.write_str("Unsupported OS, expected one of `linux`, `darwin` or `windows`.") + } + Self::FailedToDetermineExtractPath => { + f.write_str("Failed to determine updater package extract path.") + } + Self::TargetNotFound(target) => write!( + f, + "the platform `{target}` was not found in the response `platforms` object" + ), + Self::TargetsNotFound(targets) => write!( + f, + "None of the fallback platforms `{targets:?}` were found in the response `platforms` object" + ), + Self::Network(text) => write!(f, "`{text}`"), + Self::SignatureUtf8(signature) => write!( + f, + "The signature {signature} could not be decoded, please check if it is a valid base64 string. The signature must be the contents of the `.sig` file generated by the Tauri bundler, as a string." + ), + Self::TempDirNotOnSameMountPoint => { + f.write_str("temp directory is not on the same mount point as the AppImage") + } + Self::BinaryNotFoundInArchive => { + f.write_str("binary for the current target not found in the archive") + } + Self::TempDirNotFound => f.write_str("failed to create temporary directory"), + Self::AuthenticationFailed => f.write_str("Authentication failed or was cancelled"), + Self::PackageInstallFailed => f.write_str("Failed to install package"), + Self::InvalidUpdaterFormat => f.write_str("invalid updater binary format"), + Self::InsecureTransportProtocol => f.write_str( + "The configured updater endpoint must use a secure protocol like `https`.", + ), + } + } +} + +impl std::error::Error for UpdaterError {} + +impl From for UpdaterError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +pub type UpdaterResult = Result; diff --git a/crates/accshift-app/src/lifecycle/updater/unix.rs b/crates/accshift-app/src/lifecycle/updater/unix.rs new file mode 100644 index 00000000..ff70125d --- /dev/null +++ b/crates/accshift-app/src/lifecycle/updater/unix.rs @@ -0,0 +1,305 @@ +//! The Linux and macOS installs of `tauri-plugin-updater` 2.11.0 +//! (`src/updater.rs` lines 1023-1385). Unlike Windows, nothing exits: the +//! caller relaunches once the files are replaced. +//! +//! Only `std`, `tempfile`, `infer`, `flate2`, `tar`, `dirs` and `osakit`: +//! the file is type-checked for Linux and macOS from Windows by a stub crate +//! that includes it by path (see the M5 result's commands). + +use super::error::{UpdaterError, UpdaterResult}; +use super::Installer; +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +/// `run_on_main_thread` of the app; the macOS admin prompt runs there. +pub type MainThread<'a> = &'a dyn Fn(Box) -> Result<(), String>; + +/// updater.rs lines 1039-1045 (Linux): deb and rpm bundles go through the +/// package manager, everything else is an AppImage. +#[cfg(target_os = "linux")] +pub fn install_files( + installer: Option, + extract_path: &Path, + bytes: &[u8], + _main_thread: MainThread<'_>, +) -> UpdaterResult<()> { + match installer { + Some(Installer::Deb) => install_deb(extract_path, bytes), + Some(Installer::Rpm) => install_rpm(extract_path, bytes), + _ => install_appimage(extract_path, bytes), + } +} + +/// updater.rs lines 1047-1118. +#[cfg(target_os = "linux")] +fn install_appimage(extract_path: &Path, bytes: &[u8]) -> UpdaterResult<()> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let extract_path_metadata = extract_path.metadata()?; + + for location in tmp_dir_locations(extract_path) { + let Some(location) = location else { continue }; + let tmp_dir = tempfile::Builder::new() + .prefix("tauri_current_app") + .tempdir_in(location)?; + let tmp_dir_metadata = tmp_dir.path().metadata()?; + if extract_path_metadata.dev() != tmp_dir_metadata.dev() { + continue; + } + let mut perms = tmp_dir_metadata.permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(tmp_dir.path(), perms)?; + + let tmp_app_image = tmp_dir.path().join("current_app.AppImage"); + let permissions = std::fs::metadata(extract_path)?.permissions(); + std::fs::rename(extract_path, &tmp_app_image)?; + + if infer::archive::is_gz(bytes) { + let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes)); + let mut archive = tar::Archive::new(decoder); + for mut entry in archive.entries()?.flatten() { + if let Ok(path) = entry.path() { + if path.extension() == Some(std::ffi::OsStr::new("AppImage")) { + if let Err(err) = entry.unpack(extract_path) { + std::fs::rename(&tmp_app_image, extract_path)?; + return Err(err.into()); + } + return Ok(()); + } + } + } + std::fs::rename(&tmp_app_image, extract_path)?; + return Err(UpdaterError::BinaryNotFoundInArchive); + } + + return match std::fs::write(extract_path, bytes) + .and_then(|_| std::fs::set_permissions(extract_path, permissions)) + { + Err(err) => { + std::fs::rename(&tmp_app_image, extract_path)?; + Err(err.into()) + } + Ok(()) => Ok(()), + }; + } + Err(UpdaterError::TempDirNotOnSameMountPoint) +} + +/// The three temp locations, in order (lines 1051-1055). +#[cfg(target_os = "linux")] +fn tmp_dir_locations(extract_path: &Path) -> [Option; 3] { + [ + Some(std::env::temp_dir()), + dirs::cache_dir(), + extract_path.parent().map(Path::to_path_buf), + ] +} + +/// updater.rs lines 1120-1128. +#[cfg(target_os = "linux")] +fn install_deb(extract_path: &Path, bytes: &[u8]) -> UpdaterResult<()> { + if !infer::archive::is_deb(bytes) { + return Err(UpdaterError::InvalidUpdaterFormat); + } + try_tmp_locations(extract_path, bytes, "dpkg", "-i", "deb") +} + +/// updater.rs lines 1130-1136. +#[cfg(target_os = "linux")] +fn install_rpm(extract_path: &Path, bytes: &[u8]) -> UpdaterResult<()> { + if !infer::archive::is_rpm(bytes) { + return Err(UpdaterError::InvalidUpdaterFormat); + } + try_tmp_locations(extract_path, bytes, "rpm", "-U", "rpm") +} + +/// updater.rs lines 1138-1172. +#[cfg(target_os = "linux")] +fn try_tmp_locations( + extract_path: &Path, + bytes: &[u8], + install_cmd: &str, + install_arg: &str, + package_extension: &str, +) -> UpdaterResult<()> { + for location in tmp_dir_locations(extract_path).into_iter().flatten() { + let prefix = format!("tauri_{package_extension}_update"); + if let Ok(tmp_dir) = tempfile::Builder::new() + .prefix(&prefix) + .tempdir_in(location) + { + let pkg_path = tmp_dir.path().join(format!("package.{package_extension}")); + if std::fs::write(&pkg_path, bytes).is_ok() { + return try_install_with_privileges(&pkg_path, install_cmd, install_arg); + } + } + } + Err(UpdaterError::TempDirNotFound) +} + +/// updater.rs lines 1174-1211: pkexec, then a zenity or kdialog password +/// fed to `sudo -S`, then terminal sudo. +#[cfg(target_os = "linux")] +fn try_install_with_privileges( + pkg_path: &Path, + install_cmd: &str, + install_arg: &str, +) -> UpdaterResult<()> { + use std::process::Command; + if let Ok(status) = Command::new("pkexec") + .arg(install_cmd) + .arg(install_arg) + .arg(pkg_path) + .status() + { + if status.success() { + return Ok(()); + } + } + if let Ok(password) = get_password_graphically() { + if install_with_sudo(pkg_path, &password, install_cmd, install_arg)? { + return Ok(()); + } + } + let status = Command::new("sudo") + .arg(install_cmd) + .arg(install_arg) + .arg(pkg_path) + .status()?; + if status.success() { + Ok(()) + } else { + Err(UpdaterError::PackageInstallFailed) + } +} + +/// updater.rs lines 1213-1241. +#[cfg(target_os = "linux")] +fn get_password_graphically() -> UpdaterResult { + use std::process::Command; + if let Ok(output) = Command::new("zenity") + .args([ + "--password", + "--title=Authentication Required", + "--text=Enter your password to install the update:", + ]) + .output() + { + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + } + if let Ok(output) = Command::new("kdialog") + .args(["--password", "Enter your password to install the update:"]) + .output() + { + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + } + Err(UpdaterError::AuthenticationFailed) +} + +/// updater.rs lines 1243-1272. +#[cfg(target_os = "linux")] +fn install_with_sudo( + pkg_path: &Path, + password: &str, + install_cmd: &str, + install_arg: &str, +) -> UpdaterResult { + use std::io::Write; + use std::process::{Command, Stdio}; + let mut child = Command::new("sudo") + .arg("-S") + .arg(install_cmd) + .arg(install_arg) + .arg(pkg_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + if let Some(mut stdin) = child.stdin.take() { + writeln!(stdin, "{password}")?; + } + Ok(child.wait()?.success()) +} + +/// updater.rs lines 1288-1384 (macOS): unpack the `.app.tar.gz` without +/// its first path component, move the current app aside, move the new one +/// in, asking for admin rights through AppleScript when the move is denied. +#[cfg(target_os = "macos")] +pub fn install_files( + _installer: Option, + extract_path: &Path, + bytes: &[u8], + main_thread: MainThread<'_>, +) -> UpdaterResult<()> { + use flate2::read::GzDecoder; + + let tmp_backup_dir = tempfile::Builder::new() + .prefix("tauri_current_app") + .tempdir()?; + let tmp_extract_dir = tempfile::Builder::new() + .prefix("tauri_updated_app") + .tempdir()?; + + let mut archive = tar::Archive::new(GzDecoder::new(Cursor::new(bytes))); + for entry in archive.entries()? { + let mut entry = entry?; + let collected_path: PathBuf = entry.path()?.iter().skip(1).collect(); + let extraction_path = tmp_extract_dir.path().join(&collected_path); + if let Some(parent) = extraction_path.parent() { + std::fs::create_dir_all(parent)?; + } + if let Err(err) = entry.unpack(&extraction_path) { + std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); + return Err(err.into()); + } + } + + let move_result = std::fs::rename(extract_path, tmp_backup_dir.path().join("current_app")); + let need_authorization = match move_result { + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => true, + Err(err) => { + std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); + return Err(err.into()); + } + Ok(()) => false, + }; + + if need_authorization { + let apple_script = format!( + "do shell script \"rm -rf '{src}' && mv -f '{new}' '{src}'\" with administrator privileges", + src = extract_path.display(), + new = tmp_extract_dir.path().display() + ); + let (tx, rx) = std::sync::mpsc::channel(); + let res = main_thread(Box::new(move || { + let mut script = + osakit::Script::new_from_source(osakit::Language::AppleScript, &apple_script); + let result = script + .compile() + .map_err(|e| e.to_string()) + .and_then(|()| script.execute().map(|_| ()).map_err(|e| e.to_string())); + let _ = tx.send(result); + })); + let result = rx.recv().unwrap_or_else(|_| Err("no answer".into())); + if res.is_err() || result.is_err() { + std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); + return Err(UpdaterError::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Failed to move the new app into place", + ))); + } + } else { + if extract_path.exists() { + std::fs::remove_dir_all(extract_path)?; + } + std::fs::rename(tmp_extract_dir.path(), extract_path)?; + } + + let _ = std::process::Command::new("touch") + .arg(extract_path) + .status(); + Ok(()) +} diff --git a/crates/accshift-app/src/menu/account.rs b/crates/accshift-app/src/menu/account.rs new file mode 100644 index 00000000..18dc4504 --- /dev/null +++ b/crates/accshift-app/src/menu/account.rs @@ -0,0 +1,220 @@ +//! The account card menu, ported from +//! `src/lib/shared/contextMenu/{accountAppearanceActions,accountFolderActions, +//! accountMenuBuilder}.ts`. + +use super::{ + build_context_menu_items, InputRequest, MenuAction, MenuActionBody, MenuCommand, MenuEntry, + MenuSwatch, ACCOUNT_CONTEXT_GROUP_ORDER, +}; +use crate::platforms::adapter::MenuContext; +use crate::platforms::{PlatformAccount, PlatformAdapter}; +use crate::stores::ACCOUNT_CARD_COLOR_PRESETS; +use crate::text::Text; + +/// `COLOR_LABEL_KEYS`: preset id to its label key. +pub const COLOR_LABEL_KEYS: [(&str, &str); 11] = [ + ("none", "color.none"), + ("blue", "color.blue"), + ("cyan", "color.cyan"), + ("green", "color.green"), + ("lime", "color.lime"), + ("yellow", "color.yellow"), + ("orange", "color.orange"), + ("red", "color.red"), + ("pink", "color.pink"), + ("violet", "color.violet"), + ("gray", "color.gray"), +]; + +/// The label key of a colour preset. +pub fn color_label_key(preset_id: &str) -> Option<&'static str> { + COLOR_LABEL_KEYS + .iter() + .find(|(id, _)| *id == preset_id) + .map(|(_, key)| *key) +} + +/// What the appearance entries read (`getCurrentColor`, `getExistingNote`). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AppearanceInput { + pub current_color: String, + pub existing_note: String, +} + +/// `getAccountAppearanceContextActions`: the note and colour submenu. +pub fn account_appearance_actions( + account: &PlatformAccount, + input: &AppearanceInput, +) -> Vec { + let note_label = if input.existing_note.is_empty() { + "context.menu.addNote" + } else { + "context.menu.editNote" + }; + let swatches = ACCOUNT_CARD_COLOR_PRESETS + .iter() + .map(|preset| MenuSwatch { + id: preset.id.to_string(), + label: Text::key(color_label_key(preset.id).unwrap_or(preset.id)), + color: preset.color.to_string(), + active: input.current_color == preset.color, + command: MenuCommand::SetCardColor { + account_id: account.id.clone(), + color: preset.color.to_string(), + }, + }) + .collect(); + vec![MenuAction::submenu( + format!("account.appearance.{}", account.id), + Some("account.appearance"), + Text::key("context.menu.editCardAndColor"), + vec![ + MenuAction::item( + format!("account.appearance.note.{}", account.id), + None, + Text::key(note_label), + MenuCommand::EditNote { + account_id: account.id.clone(), + initial_note: input.existing_note.clone(), + }, + ), + MenuAction { + id: format!("account.appearance.color.{}", account.id), + group: None, + label: Text::key("context.menu.cardColor"), + body: MenuActionBody::Swatches(swatches), + }, + ], + )] +} + +/// `AccountFolderChoice`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountFolderChoice { + pub id: String, + /// Full path of the folder, deepest last ("Parent / Child"). + pub label: String, +} + +/// What the folder entry reads (`getFolders`, `getCurrentFolderId`). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct FolderInput { + /// Every folder of the active platform, in listing order. + pub folders: Vec, + /// The folder the account is in, `None` at the platform root. + pub current_folder_id: Option, +} + +/// `getAccountFolderContextActions`: "Move to folder", minus wherever the +/// account already is; nothing when there is nowhere to go. +pub fn account_folder_actions(account: &PlatformAccount, input: &FolderInput) -> Vec { + let current = input.current_folder_id.as_deref(); + let mut destinations: Vec<(Option, Text)> = Vec::new(); + if current.is_some() { + destinations.push((None, Text::key("context.menu.moveToRoot"))); + } + for folder in &input.folders { + if Some(folder.id.as_str()) == current { + continue; + } + destinations.push((Some(folder.id.clone()), Text::raw(folder.label.clone()))); + } + if destinations.is_empty() { + return Vec::new(); + } + let submenu = destinations + .into_iter() + .map(|(folder_id, label)| { + MenuAction::item( + format!( + "account.folder.{}.{}", + account.id, + folder_id.as_deref().unwrap_or("root") + ), + None, + label, + MenuCommand::MoveToFolder { + account_id: account.id.clone(), + folder_id, + }, + ) + }) + .collect(); + vec![MenuAction::submenu( + format!("account.folder.{}", account.id), + Some("account.folder"), + Text::key("context.menu.moveToFolder"), + submenu, + )] +} + +/// `collapseCopyActions`: every `platform.copy` entry moves into one "Copy" +/// submenu placed after the others. +fn collapse_copy_actions(actions: Vec) -> Vec { + let (copy, mut rest): (Vec, Vec) = actions + .into_iter() + .partition(|action| action.group.as_deref() == Some("platform.copy")); + if copy.is_empty() { + return rest; + } + let submenu = copy + .into_iter() + .map(|mut action| { + action.group = None; + action + }) + .collect(); + rest.push(MenuAction::submenu( + "platform.copy.root", + Some("platform.copy"), + Text::key("context.menu.copy"), + submenu, + )); + rest +} + +/// `getRenameAction`: only when the platform can label accounts and the UI +/// offers an input dialog. +fn rename_action( + account: &PlatformAccount, + adapter: &PlatformAdapter, + ctx: &MenuContext<'_>, +) -> Option { + if !adapter.supports_account_labels() || !ctx.input_dialog { + return None; + } + Some(MenuAction::item( + format!("platform.rename.{}", account.id), + Some("platform.primary"), + Text::key("platform.rename"), + MenuCommand::Input(Box::new(InputRequest { + title: Text::key("platform.renameTitle"), + placeholder: Text::key("platform.renamePlaceholder"), + initial_value: account.display_name.clone(), + allow_empty: true, + platform_id: adapter.id().to_string(), + account_id: account.id.clone(), + })), + )) +} + +/// `buildAccountContextMenuItems`: rename, folder move, the platform's own +/// entries and the appearance submenu, copies collapsed, grouped in +/// [`ACCOUNT_CONTEXT_GROUP_ORDER`]. `folders` is `None` for callers with no +/// folder world. +pub fn build_account_context_menu( + account: &PlatformAccount, + adapter: &PlatformAdapter, + ctx: &MenuContext<'_>, + appearance: &AppearanceInput, + folders: Option<&FolderInput>, +) -> Vec { + let mut actions = Vec::new(); + actions.extend(rename_action(account, adapter, ctx)); + if let Some(folders) = folders { + actions.extend(account_folder_actions(account, folders)); + } + actions.extend(adapter.context_menu_actions(account, ctx)); + actions.extend(account_appearance_actions(account, appearance)); + build_context_menu_items(collapse_copy_actions(actions), &ACCOUNT_CONTEXT_GROUP_ORDER) +} diff --git a/crates/accshift-app/src/menu/actions.rs b/crates/accshift-app/src/menu/actions.rs new file mode 100644 index 00000000..47d0ae02 --- /dev/null +++ b/crates/accshift-app/src/menu/actions.rs @@ -0,0 +1,70 @@ +//! Safe actions, ported from `src/lib/shared/contextMenu/actions.ts`. +//! +//! `createSafeContextAction` runs a task and turns a failure into a toast of +//! the error string, except a PIN refusal: the lock screen is already up, so +//! no toast goes on top. Here a task returns a [`TaskOutcome`], the effects the +//! UI applies on its thread in order. + +use crate::error::BackendError; +use crate::stores::DefaultGame; +use crate::text::Text; + +/// One thing a finished task asks the UI to do. +#[derive(Debug, Clone, PartialEq)] +pub enum TaskEffect { + /// `callbacks.showToast(text)`. + Toast(Text), + /// `callbacks.refreshAccounts()`. + RefreshAccounts, + /// `callbacks.removeAccount(id)`. + RemoveAccount(String), + /// `setDefaultGame(steamId, game)` on the client stores. + SetDefaultGame { steam_id: String, game: DefaultGame }, + /// `clearDefaultGame(steamId)` on the client stores. + ClearDefaultGame { steam_id: String }, + /// `reportPinLockedError`: bring the lock screen up. + PinLocked, +} + +/// What a task did, as effects in the order TS performs them. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct TaskOutcome { + pub effects: Vec, +} + +impl TaskOutcome { + pub fn push(&mut self, effect: TaskEffect) { + self.effects.push(effect); + } + + /// The toast texts, for tests and logs. + pub fn toasts(&self) -> Vec<&Text> { + self.effects + .iter() + .filter_map(|effect| match effect { + TaskEffect::Toast(text) => Some(text), + _ => None, + }) + .collect() + } + + /// The `catch` of `createSafeContextAction`: a toast with the error + /// message (`String(error)`), nothing for a PIN refusal. + pub fn fail(&mut self, error: &BackendError) { + if error.is_pin_locked() { + return; + } + self.push(TaskEffect::Toast(Text::raw(error.message.clone()))); + } +} + +/// Runs `task` as a safe action: its `Ok` effects are already in `outcome`, +/// an `Err` adds the failure toast. +pub fn run_safe( + outcome: &mut TaskOutcome, + task: impl FnOnce(&mut TaskOutcome) -> Result<(), BackendError>, +) { + if let Err(error) = task(outcome) { + outcome.fail(&error); + } +} diff --git a/crates/accshift-app/src/menu/mod.rs b/crates/accshift-app/src/menu/mod.rs new file mode 100644 index 00000000..89abf500 --- /dev/null +++ b/crates/accshift-app/src/menu/mod.rs @@ -0,0 +1,352 @@ +//! The context menu model, ported from `src/lib/shared/contextMenu/*.ts` and +//! the `ContextMenuItem` type of `src/lib/shared/types.ts`. +//! +//! TS entries carry closures. Here an entry carries a [`MenuCommand`]: data +//! describing what a click does. The UI applies the UI-only commands itself +//! (copy, confirm, input dialog, folder move, note, colour) and hands a +//! [`PlatformTask`] to [`crate::platforms::PlatformAdapter::run_task`] on the +//! job pool, then applies the [`TaskEffect`]s it returns. Drawing the menu is +//! the UI's job; ids, grouping, order, separators, labels (as [`Text`]) and +//! enabled state are decided here. + +pub mod account; +pub mod actions; +pub mod platform; + +use crate::stores::DefaultGame; +use crate::text::Text; + +pub use actions::{TaskEffect, TaskOutcome}; + +/// `ACCOUNT_CONTEXT_GROUP_ORDER`. +pub const ACCOUNT_CONTEXT_GROUP_ORDER: [&str; 6] = [ + "platform.primary", + "account.folder", + "platform.copy", + "platform.data", + "platform.danger", + "account.appearance", +]; + +/// What clicking an entry asks for. +#[derive(Debug, Clone, PartialEq)] +pub enum MenuCommand { + /// Close the menu and do nothing (`action: () => {}`). + Close, + /// `copyToClipboard(value, label)`. + Copy { value: String, label: Text }, + /// `confirmAction`: on confirm, run the task as a safe action. + Confirm(Box), + /// `openInputDialog` (rename). + Input(Box), + /// `moveToFolder`: `None` is the platform root. + MoveToFolder { + account_id: String, + folder_id: Option, + }, + /// `openNoteEditor(initialNote)`. + EditNote { + account_id: String, + initial_note: String, + }, + /// `setColor(color)`: a preset colour, `""` clears. + SetCardColor { account_id: String, color: String }, + /// A safe action (`createSafeContextAction`) that talks to the backend. + Task(PlatformTask), +} + +/// `PlatformContextMenuConfirmConfig`. +#[derive(Debug, Clone, PartialEq)] +pub struct ConfirmRequest { + pub title: Text, + pub message: Text, + pub confirm_label: Option, + pub on_confirm: PlatformTask, +} + +/// `PlatformContextMenuInputConfig`. The confirmed value goes into +/// [`PlatformTask::SetAccountLabel`]. +#[derive(Debug, Clone, PartialEq)] +pub struct InputRequest { + pub title: Text, + pub placeholder: Text, + pub initial_value: String, + pub allow_empty: bool, + pub platform_id: String, + pub account_id: String, +} + +impl InputRequest { + /// The task to run with the value the user confirmed. + pub fn confirm(&self, value: &str) -> PlatformTask { + PlatformTask::SetAccountLabel { + platform_id: self.platform_id.clone(), + account_id: self.account_id.clone(), + label: value.to_string(), + } + } +} + +/// Work a menu entry hands to the job pool. Each variant is one TS closure. +#[derive(Debug, Clone, PartialEq)] +pub enum PlatformTask { + /// `switchAccountMode(username, steamId, mode)`: `online` or `invisible`. + SteamSwitchMode { + username: String, + steam_id: String, + mode: String, + }, + /// `switchAccountAndLaunchGame`, after remembering the game as the + /// account's default when `remember` is set. + SteamRunGame { + username: String, + steam_id: String, + app_id: String, + remember: Option, + }, + /// `clearDefaultGame`. + SteamClearDefaultGame { steam_id: String }, + /// `openUserdata`. + SteamOpenUserdata { steam_id: String }, + /// `clearIntegratedBrowserCache` then its toast. + SteamClearBrowserCache, + /// `copyGameSettings` then its toast. + SteamCopyGameSettings { + from_steam_id: String, + to_steam_id: String, + app_id: String, + game_name: String, + }, + /// `captureProfile`, its toast and a reload. + RiotCapture { + profile_id: String, + display_name: String, + }, + /// The forget entry of `buildPlatformContextMenu`: forget, toast, remove. + Forget { + platform_id: String, + account_id: String, + toast: Text, + }, + /// `setAccountLabel` then a reload (rename). + SetAccountLabel { + platform_id: String, + account_id: String, + label: String, + }, +} + +/// A submenu filled on open (`submenuLoader`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubmenuLoader { + /// The account's games, to switch and run one. + SteamAccountGames { username: String, steam_id: String }, + /// Games whose settings can be copied to the current account. + SteamCopyableGames { + from_steam_id: String, + to_steam_id: String, + }, +} + +/// `ContextMenuSwatchAction`. +#[derive(Debug, Clone, PartialEq)] +pub struct MenuSwatch { + pub id: String, + pub label: Text, + pub color: String, + pub active: bool, + pub command: MenuCommand, +} + +/// `ContextMenuAction`: an entry before grouping. +#[derive(Debug, Clone, PartialEq)] +pub struct MenuAction { + pub id: String, + pub group: Option, + pub label: Text, + pub body: MenuActionBody, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MenuActionBody { + /// `ContextMenuItemAction`. + Item { + command: Option, + submenu: Option>, + loader: Option, + }, + /// `ContextMenuSwatchesAction`. + Swatches(Vec), +} + +impl MenuAction { + /// An entry that runs `command`. + pub fn item( + id: impl Into, + group: Option<&str>, + label: Text, + command: MenuCommand, + ) -> Self { + Self { + id: id.into(), + group: group.map(str::to_string), + label, + body: MenuActionBody::Item { + command: Some(command), + submenu: None, + loader: None, + }, + } + } + + /// An entry that opens `submenu`. + pub fn submenu( + id: impl Into, + group: Option<&str>, + label: Text, + submenu: Vec, + ) -> Self { + Self { + id: id.into(), + group: group.map(str::to_string), + label, + body: MenuActionBody::Item { + command: None, + submenu: Some(submenu), + loader: None, + }, + } + } + + /// The click command of an item entry. + pub fn command(&self) -> Option<&MenuCommand> { + match &self.body { + MenuActionBody::Item { command, .. } => command.as_ref(), + MenuActionBody::Swatches(_) => None, + } + } + + /// The static submenu of an item entry. + pub fn submenu_actions(&self) -> Option<&[MenuAction]> { + match &self.body { + MenuActionBody::Item { submenu, .. } => submenu.as_deref(), + MenuActionBody::Swatches(_) => None, + } + } +} + +/// `ContextMenuItem`: what the menu draws, in order, separators included. +#[derive(Debug, Clone, PartialEq)] +pub enum MenuEntry { + Separator, + Item { + id: String, + label: Text, + command: Option, + submenu: Option>, + loader: Option, + }, + Swatches { + id: String, + label: Text, + swatches: Vec, + }, +} + +impl MenuEntry { + /// An entry with something behind it. TS has no disabled state; an item + /// with no action and no submenu does nothing when clicked. + pub fn is_enabled(&self) -> bool { + match self { + MenuEntry::Separator => false, + MenuEntry::Item { + command, + submenu, + loader, + .. + } => command.is_some() || submenu.is_some() || loader.is_some(), + MenuEntry::Swatches { swatches, .. } => !swatches.is_empty(), + } + } + + pub fn label(&self) -> Option<&Text> { + match self { + MenuEntry::Separator => None, + MenuEntry::Item { label, .. } | MenuEntry::Swatches { label, .. } => Some(label), + } + } + + pub fn id(&self) -> Option<&str> { + match self { + MenuEntry::Separator => None, + MenuEntry::Item { id, .. } | MenuEntry::Swatches { id, .. } => Some(id), + } + } +} + +fn to_entry(action: MenuAction, group_order: &[&str]) -> MenuEntry { + match action.body { + MenuActionBody::Swatches(swatches) => MenuEntry::Swatches { + id: action.id, + label: action.label, + swatches, + }, + MenuActionBody::Item { + command, + submenu, + loader, + } => MenuEntry::Item { + id: action.id, + label: action.label, + command, + submenu: submenu.map(|actions| build_context_menu_items(actions, group_order)), + loader, + }, + } +} + +/// `buildContextMenuItems`: actions bucketed by group, groups in +/// `group_order` first (in that order) then by first appearance, a separator +/// between groups. Loaded submenus go through this again with the same order. +pub fn build_context_menu_items(actions: Vec, group_order: &[&str]) -> Vec { + if actions.is_empty() { + return Vec::new(); + } + // (group key, first index, actions), in first-seen order. + let mut buckets: Vec<(String, usize, Vec)> = Vec::new(); + for (index, action) in actions.into_iter().enumerate() { + let key = action + .group + .clone() + .filter(|group| !group.is_empty()) + .unwrap_or_else(|| "__default__".to_string()); + match buckets.iter_mut().find(|(bucket, _, _)| *bucket == key) { + Some((_, _, list)) => list.push(action), + None => buckets.push((key, index, vec![action])), + } + } + let priority = |key: &str| group_order.iter().position(|group| *group == key); + buckets.sort_by( + |(a, a_first, _), (b, b_first, _)| match (priority(a), priority(b)) { + (Some(x), Some(y)) => x.cmp(&y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a_first.cmp(b_first), + }, + ); + let mut items = Vec::new(); + for (group_index, (_, _, group_actions)) in buckets.into_iter().enumerate() { + if group_actions.is_empty() { + continue; + } + if group_index > 0 { + items.push(MenuEntry::Separator); + } + items.extend( + group_actions + .into_iter() + .map(|action| to_entry(action, group_order)), + ); + } + items +} diff --git a/crates/accshift-app/src/menu/platform.rs b/crates/accshift-app/src/menu/platform.rs new file mode 100644 index 00000000..5b4a424a --- /dev/null +++ b/crates/accshift-app/src/menu/platform.rs @@ -0,0 +1,136 @@ +//! The copy and forget entries every platform shares, ported from +//! `src/lib/shared/contextMenu/platformMenuBuilder.ts`. + +use super::{ConfirmRequest, MenuAction, MenuCommand, PlatformTask}; +use crate::i18n::TranslationValue; +use crate::platforms::PlatformAccount; +use crate::text::Text; + +/// `CopyItemConfig`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CopyItemConfig { + pub field: String, + pub value: String, + pub label_key: String, + pub clipboard_label_key: String, +} + +impl CopyItemConfig { + /// The usual case: the same key labels the entry and the clipboard toast. + pub fn new(field: &str, value: impl Into, label_key: &str) -> Self { + Self { + field: field.to_string(), + value: value.into(), + label_key: label_key.to_string(), + clipboard_label_key: label_key.to_string(), + } + } +} + +/// `ForgetConfig`. The TS `action` closure is the forget task itself. +#[derive(Debug, Clone, PartialEq)] +pub struct ForgetConfig { + pub title_key: String, + pub title_params: Vec<(String, TranslationValue)>, + pub message_key: String, + pub confirm_label_key: String, + pub toast_key: String, + pub toast_params: Vec<(String, TranslationValue)>, +} + +impl ForgetConfig { + /// The four keys of a platform's forget flow, `.`. + pub fn keys(title: &str, message: &str, confirm: &str, toast: &str) -> Self { + Self { + title_key: title.to_string(), + title_params: Vec::new(), + message_key: message.to_string(), + confirm_label_key: confirm.to_string(), + toast_key: toast.to_string(), + toast_params: Vec::new(), + } + } + + pub fn toast_param(mut self, name: &str, value: impl Into) -> Self { + self.toast_params.push((name.to_string(), value.into())); + self + } +} + +/// `(account.displayName || account.username || account.id).trim() || account.id`. +pub fn default_display_value(account: &PlatformAccount) -> String { + let base = [&account.display_name, &account.username, &account.id] + .into_iter() + .find(|value| !value.is_empty()) + .map_or("", |value| value.as_str()); + let trimmed = crate::js::trim(base); + if trimmed.is_empty() { + account.id.clone() + } else { + trimmed.to_string() + } +} + +/// `{ display, ...extra }`: a later key replaces an earlier one, in place. +fn merged_params( + display: &str, + extra: &[(String, TranslationValue)], +) -> Vec<(String, TranslationValue)> { + let mut params = vec![("display".to_string(), TranslationValue::from(display))]; + for (name, value) in extra { + match params.iter_mut().find(|(existing, _)| existing == name) { + Some(slot) => slot.1 = value.clone(), + None => params.push((name.clone(), value.clone())), + } + } + params +} + +/// `buildPlatformContextMenu`: one copy entry per item (`platform.copy`), +/// then the forget entry (`platform.danger`) behind a confirmation. +pub fn build_platform_context_menu( + platform_id: &str, + account: &PlatformAccount, + copy_items: &[CopyItemConfig], + forget: &ForgetConfig, + display_value: Option, +) -> Vec { + let display = display_value.unwrap_or_else(|| default_display_value(account)); + let mut items: Vec = copy_items + .iter() + .map(|item| { + MenuAction::item( + format!("{platform_id}.copy.{}.{}", item.field, account.id), + Some("platform.copy"), + Text::key(&item.label_key), + MenuCommand::Copy { + value: item.value.clone(), + label: Text::key(&item.clipboard_label_key), + }, + ) + }) + .collect(); + let confirm = ConfirmRequest { + title: Text::with_optional( + &forget.title_key, + Some(merged_params(&display, &forget.title_params)), + ), + message: Text::key(&forget.message_key), + confirm_label: Some(Text::key(&forget.confirm_label_key)), + on_confirm: PlatformTask::Forget { + platform_id: platform_id.to_string(), + account_id: account.id.clone(), + toast: Text::with_optional( + &forget.toast_key, + Some(merged_params(&display, &forget.toast_params)), + ), + }, + }; + items.push(MenuAction::item( + format!("{platform_id}.forget.{}", account.id), + Some("platform.danger"), + Text::key(&forget.confirm_label_key), + MenuCommand::Confirm(Box::new(confirm)), + )); + items +} diff --git a/crates/accshift-app/src/mock.rs b/crates/accshift-app/src/mock.rs new file mode 100644 index 00000000..375a4a53 --- /dev/null +++ b/crates/accshift-app/src/mock.rs @@ -0,0 +1,1082 @@ +//! The mock backend: the TS mock's data, served through [`Backend`]. +//! +//! Built from `crates/accshift-egui/parity/data/.json`, which +//! `crates/accshift-egui/parity/dump-data.mjs` exports from the very fixtures +//! the reference app runs on (format in the header of that script). Recorded +//! results are served as recorded; the few commands with state are modelled +//! the way `src/lib/mock/fixtures.ts` and `src/lib/mock/runtime.ts` model them: +//! +//! - a switch waits the scenario's `switchDelayMs`, then changes the current +//! account, which `platform_get_current_account` and +//! `platform_get_startup_snapshot` report from then on; +//! - the PIN session starts locked when the scenario's settings carry a PIN, +//! and a switch is refused with the `pin_locked:` message until +//! `pin_unlock` gets the code; +//! - `save_client_storage_store` saves nothing; +//! - a guarded command (`platform_`, `steam_`, `riot_`, `roblox_`, +//! `cs2_bridge_`, `telemetry_`, `pin_`) the mock does not fake is refused +//! with `mock mode: is not available`; +//! - [`MockBackend::fail`] and [`MockBackend::latency`] are the `__mock.fail` +//! and `__mock.latency` overrides, ignored on the frozen `demo` scenario; +//! - [`MockBackend::set_streaming`] is `accshift.mock.overrides.streaming`: +//! `detect_streaming_software` answers true; +//! - [`MockBackend::set_cs2`] is `accshift.mock.overrides.cs2`: the CS2 +//! bridge reads as enabled and answers with the rows given; +//! - [`MockBackend::set_onboarding`] is `accshift.mock.overrides.onboarding`: +//! `telemetry_get_state` reports the first-run onboarding as never done; +//! - [`MockBackend::set_setup`] is `accshift.mock.overrides.setup`: an +//! add-account setup walking through the statuses given. +//! +//! Nothing here reaches Steam, the keyring, the registry or the disk outside +//! the scenario file. + +use crate::backend::Backend; +use crate::error::{BackendError, BackendResult}; +use crate::types::*; +use accshift_core::pin::{verify_pin_code, PinLock, PinSettings, PinVerdict, PIN_LOCKED_MESSAGE}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; + +/// The mock clock, in seconds: every invented date is relative to it. +/// `NOW` in `src/lib/mock/fixtures.ts`. +pub const NOW: u64 = 1_791_000_000; + +/// Version of the scenario files this reads. +pub const SCENARIO_FORMAT: u64 = 1; + +/// The scenario whose overrides are ignored (`FROZEN` in `runtime.ts`). +pub const FROZEN_SCENARIO: &str = "demo"; + +/// Where avatar paths in the scenario data point, relative to the repo root. +pub const AVATAR_DIR: &str = "crates/accshift-egui/parity/avatars/"; + +/// Scenario files, relative to the repo root. +pub const DATA_DIR: &str = "crates/accshift-egui/parity/data"; + +/// `GUARDED_PREFIXES` in `runtime.ts`. +const GUARDED_PREFIXES: [&str; 7] = [ + "platform_", + "steam_", + "riot_", + "roblox_", + "cs2_bridge_", + "telemetry_", + "pin_", +]; + +/// Scenario fields no command returns. `spec` in the scenario file. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MockSpec { + pub label: String, + pub runtime_os: String, + pub switch_delay_ms: u64, + pub steam_path: String, + pub has_steam_api_key: bool, + pub current_steam_account: String, + pub current_riot_profile: String, + pub current_roblox_account: String, +} + +#[derive(Deserialize)] +struct ScenarioFile { + format: u64, + scenario: String, + now: u64, + spec: MockSpec, + commands: BTreeMap>>, + #[serde(default)] + skipped: BTreeMap, +} + +/// One recorded call: its arguments and what the TS mock answered. +#[derive(Debug, Clone, PartialEq)] +pub struct RecordedCall { + pub args: Value, + /// `Ok(result)` or `Err(message)`. + pub outcome: Result, +} + +struct MockState { + runtime_os: String, + /// Platform id to current account id, for `steam`, `riot` and `roblox`. + current: BTreeMap, + /// Normalized PIN hash of the live settings, empty when no PIN is set. + pin_hash: String, + pin_unlocked: bool, + latency_ms: u64, + failures: BTreeMap, + /// `overrides.streaming`. + streaming: bool, + /// `overrides.cs2`: the bridge rows, `None` when the bridge is off. + cs2: Option>, + /// `overrides.onboarding`. + onboarding: bool, + /// `overrides.setup`: the statuses, and the one last answered. + setup: Option>, + setup_step: usize, + /// `overrides.wallpaper`: what `get_desktop_wallpaper` answers. + wallpaper: Option, +} + +/// See the module documentation. +pub struct MockBackend { + scenario: String, + now: u64, + spec: MockSpec, + calls: BTreeMap>, + skipped: BTreeMap, + repo_root: Option, + state: Mutex, +} + +impl MockBackend { + /// `/.json`. With `repo_root`, avatar paths become + /// paths of files under `/src/lib/mock/avatars/`; without it + /// they stay repo-relative, as recorded. + pub fn load(data_dir: &Path, scenario: &str, repo_root: Option<&Path>) -> Result { + Self::from_file(&data_dir.join(format!("{scenario}.json")), repo_root) + } + + pub fn from_file(path: &Path, repo_root: Option<&Path>) -> Result { + let text = + std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; + Self::from_json(&text, repo_root).map_err(|e| format!("{}: {e}", path.display())) + } + + pub fn from_json(text: &str, repo_root: Option<&Path>) -> Result { + let file: ScenarioFile = + serde_json::from_str(text).map_err(|e| format!("not a scenario file: {e}"))?; + if file.format != SCENARIO_FORMAT { + return Err(format!( + "scenario format {} (this build reads {SCENARIO_FORMAT})", + file.format + )); + } + let calls = file + .commands + .into_iter() + .map(|(command, calls)| { + let calls = calls + .into_iter() + .map(|mut call| { + let args = call.remove("args").unwrap_or_else(|| json!({})); + let outcome = match call.remove("error") { + Some(error) => Err(match error { + Value::String(s) => s, + other => other.to_string(), + }), + None => Ok(resolve_avatars( + call.remove("result").unwrap_or(Value::Null), + repo_root, + )), + }; + RecordedCall { args, outcome } + }) + .collect(); + (command, calls) + }) + .collect(); + let mut backend = Self { + scenario: file.scenario, + now: file.now, + calls, + skipped: file.skipped, + repo_root: repo_root.map(Path::to_path_buf), + state: Mutex::new(MockState { + runtime_os: file.spec.runtime_os.clone(), + current: BTreeMap::from([ + ("steam".into(), file.spec.current_steam_account.clone()), + ("riot".into(), file.spec.current_riot_profile.clone()), + ("roblox".into(), file.spec.current_roblox_account.clone()), + ]), + pin_hash: String::new(), + pin_unlocked: true, + latency_ms: 0, + failures: BTreeMap::new(), + streaming: false, + cs2: None, + onboarding: false, + setup: None, + setup_step: 0, + wallpaper: None, + }), + spec: file.spec, + }; + // The session starts locked when the scenario's settings carry a PIN, + // as `createHandlers` starts it. + let settings = backend.scenario_settings(); + let hash = pin_hash_of(&settings); + let state = backend.state.get_mut().unwrap_or_else(|e| e.into_inner()); + state.pin_unlocked = hash.is_empty(); + state.pin_hash = hash; + Ok(backend) + } + + /// Scenario ids with a data file in `data_dir`, sorted. + pub fn scenarios(data_dir: &Path) -> Vec { + let mut ids: Vec = std::fs::read_dir(data_dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| { + let path = entry.path(); + (path.extension()? == "json") + .then(|| path.file_stem()?.to_str().map(str::to_string)) + .flatten() + }) + .collect(); + ids.sort(); + ids + } + + /// `/crates/accshift-egui/parity/data`. + pub fn data_dir(repo_root: &Path) -> PathBuf { + repo_root.join(DATA_DIR) + } + + pub fn scenario(&self) -> &str { + &self.scenario + } + + pub fn spec(&self) -> &MockSpec { + &self.spec + } + + /// The mock clock, in seconds. + pub fn now(&self) -> u64 { + self.now + } + + /// True for the `demo` scenario: overrides do nothing. + pub fn frozen(&self) -> bool { + self.scenario == FROZEN_SCENARIO + } + + /// The recorded calls, avatar paths resolved like served values. + pub fn recorded(&self) -> &BTreeMap> { + &self.calls + } + + /// Commands the dump modelled instead of recording, with the reason. + pub fn skipped(&self) -> &BTreeMap { + &self.skipped + } + + /// True when the TS mock has a handler for `command`. + pub fn is_mocked(&self, command: &str) -> bool { + self.calls.contains_key(command) || self.skipped.contains_key(command) + } + + /// The file behind an avatar path from this backend's data, repo-relative + /// or already resolved. `None` for anything else (a URL, an empty value). + pub fn avatar_path(&self, value: &str) -> Option { + if let Some(root) = &self.repo_root { + let resolved = PathBuf::from(value); + if resolved.starts_with(root.join(AVATAR_DIR)) { + return Some(resolved); + } + return value + .starts_with(AVATAR_DIR) + .then(|| join_relative(root, value)); + } + value.starts_with(AVATAR_DIR).then(|| PathBuf::from(value)) + } + + /// `__mock.latency(ms)`: delays every mocked command. Returns the latency + /// in force, unchanged on the frozen scenario. + pub fn latency(&self, ms: u64) -> u64 { + let mut state = self.lock(); + if !self.frozen() { + state.latency_ms = ms; + } + state.latency_ms + } + + /// `__mock.fail(cmd, msg)`: `Some(message)` makes `command` fail with it, + /// `None` clears that. Returns false on the frozen scenario, which keeps + /// no override. + pub fn fail(&self, command: &str, message: Option<&str>) -> bool { + if self.frozen() { + return false; + } + let mut state = self.lock(); + match message { + Some(message) => state.failures.insert(command.into(), message.into()), + None => state.failures.remove(command), + }; + true + } + + /// `accshift.mock.overrides.streaming`, which a scene writes before the + /// page loads, as `runtimeOs`: `detect_streaming_software` reports + /// streaming software running. A failure set with [`Self::fail`] still + /// wins, as in `mockInvoke`. + pub fn set_streaming(&self, on: bool) { + self.lock().streaming = on; + } + + /// `accshift.mock.overrides.cs2`: the CS2 bridge reads as enabled and + /// answers `cs2_bridge_fetch` with `rows` and `cs2_bridge_check` with the + /// row of that Steam id. `None` leaves the scenario's own answers. Like + /// `streaming`, it holds on `demo` too: the bench serves that scenario + /// from a `VITE_MOCK=dev` build, where overrides are read. + pub fn set_cs2(&self, rows: Option>) { + self.lock().cs2 = rows; + } + + /// `accshift.mock.overrides.onboarding`: `telemetry_get_state` reports + /// `onboarding_completed: false`, so the first-run onboarding opens. + pub fn set_onboarding(&self, on: bool) { + self.lock().onboarding = on; + } + + /// `accshift.mock.overrides.setup`: `platform_begin_setup` answers the + /// first status, each `platform_get_setup_status` the next one, the last + /// one repeating; `platform_cancel_setup` succeeds. `None` or an empty + /// list leaves them refused. + pub fn set_setup(&self, statuses: Option>) { + let mut state = self.lock(); + state.setup = statuses.filter(|s| !s.is_empty()); + state.setup_step = 0; + } + + /// `accshift.mock.overrides.wallpaper`: `get_desktop_wallpaper` answers + /// this snapshot instead of null. + pub fn set_wallpaper(&self, snapshot: Option) { + self.lock().wallpaper = snapshot; + } + + /// `__mock.os(name)`: the OS `get_runtime_os` and the boot payload report. + /// Returns false on the frozen scenario. + pub fn set_runtime_os(&self, os: &str) -> bool { + if self.frozen() { + return false; + } + self.lock().runtime_os = os.into(); + true + } + + /// Feeds the live `client.settings` store, which the TS mock reads for the + /// PIN (`peekSettings`): saves are no-ops, so the PIN set in the window is + /// only known to the window. Leaves the lock state alone. + pub fn set_pin_settings(&self, settings: &Value) { + self.lock().pin_hash = pin_hash_of(settings); + } + + fn lock(&self) -> MutexGuard<'_, MockState> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn scenario_settings(&self) -> Value { + self.recorded_for("load_client_storage_snapshot", &json!({})) + .and_then(|outcome| outcome.ok()) + .and_then(|snapshot| snapshot.pointer("/stores/client.settings").cloned()) + .unwrap_or(Value::Null) + } + + /// The recorded outcome for these exact arguments, or the single + /// argument-less record of a command whose handler ignores its arguments. + fn recorded_for(&self, command: &str, args: &Value) -> Option> { + let calls = self.calls.get(command)?; + if let Some(call) = calls.iter().find(|call| &call.args == args) { + return Some(call.outcome.clone()); + } + match calls.as_slice() { + [only] if only.args.as_object().is_some_and(Map::is_empty) => { + Some(only.outcome.clone()) + } + _ => None, + } + } + + /// The recorded call whose `keys` argument is `id`. + fn recorded_by_id(&self, command: &str, keys: &[&str], id: &str) -> Option { + self.calls + .get(command)? + .iter() + .find(|call| arg_str(&call.args, keys).as_deref() == Some(id)) + .and_then(|call| call.outcome.clone().ok()) + } + + fn current(&self, platform_id: &str) -> String { + self.lock() + .current + .get(platform_id) + .cloned() + .unwrap_or_default() + } + + /// `mockInvoke`: override, latency, handler; else the guard. + fn call(&self, command: &str, args: Value) -> BackendResult { + let (failure, latency_ms) = { + let state = self.lock(); + (state.failures.get(command).cloned(), state.latency_ms) + }; + if let Some(message) = failure { + return Err(BackendError::new(message)); + } + let cs2 = self.lock().cs2.is_some() + && matches!( + command, + "cs2_bridge_get_settings" | "cs2_bridge_fetch" | "cs2_bridge_check" + ); + let setup = self.lock().setup.is_some() + && matches!( + command, + "platform_begin_setup" | "platform_get_setup_status" | "platform_cancel_setup" + ); + let value = if self.is_mocked(command) || cs2 || setup { + if latency_ms > 0 { + std::thread::sleep(Duration::from_millis(latency_ms)); + } + self.serve(command, &args)? + } else if GUARDED_PREFIXES + .iter() + .any(|prefix| command.starts_with(prefix)) + { + return Err(BackendError::mock_refused(command)); + } else { + // The TS mock forwards these to the real backend (log_app_event, + // finish_boot, save_custom_theme, delete_custom_theme). Here they + // do nothing, so a mock session never writes to the real config. + Value::Null + }; + serde_json::from_value(value) + .map_err(|e| BackendError::new(format!("mock data for {command} does not decode: {e}"))) + } + + /// The handler of a mocked command. + fn serve(&self, command: &str, args: &Value) -> Result { + let platform = arg_str(args, &["platformId"]).unwrap_or_default(); + let value = match command { + "get_runtime_os" => Value::String(self.lock().runtime_os.clone()), + "get_boot_payload" => { + let mut payload = self.answer(command, args)?; + payload["runtimeOs"] = Value::String(self.lock().runtime_os.clone()); + payload + } + "platform_get_accounts" => self + .recorded_for(command, args) + .transpose()? + .unwrap_or_else(|| json!([])), + "platform_get_current_account" => Value::String(self.current(&platform)), + "platform_get_startup_snapshot" => { + let mut snapshot = self + .recorded_for(command, args) + .transpose()? + .unwrap_or_else(|| json!({ "accounts": [], "currentAccount": "" })); + let key = match platform.as_str() { + "riot" => Some("currentProfile"), + "steam" | "roblox" => Some("currentAccount"), + _ => None, + }; + if let (Some(key), Some(object)) = (key, snapshot.as_object_mut()) { + object.insert(key.into(), Value::String(self.current(&platform))); + } + snapshot + } + "platform_switch_account" => { + self.switch(&platform, arg_str(args, &["accountId"]))?; + Value::Null + } + "pin_unlock" => self.pin_unlock_value(&arg_str(args, &["code"]).unwrap_or_default()), + "pin_lock" => { + self.lock().pin_unlocked = false; + Value::Null + } + "platform_dry_run" => json!({ + "platformId": platform, + "operation": "switch", + "accountId": arg_str(args, &["accountId"]).unwrap_or_default(), + "applied": false, + "roots": [], + "steps": [], + "warnings": [], + }), + "steam_get_profile_info" => { + let id = arg_str(args, &["steamId", "accountId"]).unwrap_or_default(); + self.steam_profile(&id).unwrap_or(Value::Null) + } + "steam_get_profile_infos" => { + let mut infos = Map::new(); + for id in arg_strings(args, &["steamIds", "accountIds"]) { + if let Some(info) = self.steam_profile(&id).filter(|v| !v.is_null()) { + infos.insert(id, info); + } + } + Value::Object(infos) + } + "steam_get_player_bans" => { + let known = self.all_bans(); + let bans = arg_strings(args, &["steamIds"]) + .iter() + .filter_map(|id| { + known + .iter() + .find(|ban| ban["steam_id"].as_str() == Some(id.as_str())) + .cloned() + }) + .collect(); + Value::Array(bans) + } + "roblox_get_profile_info" => { + let id = arg_str(args, &["userId", "accountId"]).unwrap_or_default(); + self.recorded_by_id(command, &["userId", "accountId"], &id) + .unwrap_or_else(|| json!({ "avatarUrl": null })) + } + "cs2_bridge_get_settings" if self.lock().cs2.is_some() => { + json!({ "enabled": true, "url": "", "tokenConfigured": true }) + } + "cs2_bridge_fetch" if self.lock().cs2.is_some() => { + Value::Array(self.lock().cs2.clone().unwrap_or_default()) + } + "cs2_bridge_check" if self.lock().cs2.is_some() => { + let id = arg_str(args, &["steamId"]).unwrap_or_default(); + let rows = self.lock().cs2.clone().unwrap_or_default(); + rows.into_iter() + .find(|row| row["steamId"].as_str() == Some(id.as_str())) + .unwrap_or(Value::Null) + } + "cs2_bridge_get_settings" => cs2_settings_from_fixture(self.answer(command, args)?), + "platform_begin_setup" | "platform_get_setup_status" if self.lock().setup.is_some() => { + let mut state = self.lock(); + let statuses = state.setup.clone().unwrap_or_default(); + state.setup_step = if command == "platform_begin_setup" { + 0 + } else { + (state.setup_step + 1).min(statuses.len().saturating_sub(1)) + }; + statuses + .get(state.setup_step) + .cloned() + .unwrap_or(Value::Null) + } + "platform_cancel_setup" if self.lock().setup.is_some() => Value::Null, + "get_desktop_wallpaper" if self.lock().wallpaper.is_some() => { + self.lock().wallpaper.clone().unwrap_or(Value::Null) + } + _ => self.answer(command, args)?, + }; + Ok(value) + } + + fn answer(&self, command: &str, args: &Value) -> Result { + match self.recorded_for(command, args) { + Some(outcome) => outcome.map_err(BackendError::new), + None => Err(BackendError::new(format!( + "mock data has no answer for {command} with {args}" + ))), + } + } + + fn steam_profile(&self, id: &str) -> Option { + self.recorded_by_id("steam_get_profile_info", &["steamId", "accountId"], id) + } + + /// Every ban record of the scenario: the batch call covers every account. + fn all_bans(&self) -> Vec { + self.calls + .get("steam_get_player_bans") + .into_iter() + .flatten() + .filter_map(|call| call.outcome.as_ref().ok()?.as_array().cloned()) + .flatten() + .collect() + } + + /// `platform_switch_account` in `createHandlers`: PIN gate, the + /// scenario's delay, then the new current account. + fn switch(&self, platform: &str, account_id: Option) -> Result<(), BackendError> { + { + let state = self.lock(); + if !state.pin_unlocked && !state.pin_hash.is_empty() { + return Err(BackendError::new(PIN_LOCKED_MESSAGE)); + } + } + std::thread::sleep(Duration::from_millis(self.spec.switch_delay_ms)); + if let Some(account_id) = account_id { + if matches!(platform, "steam" | "riot" | "roblox") { + self.lock().current.insert(platform.into(), account_id); + } + } + Ok(()) + } + + fn pin_unlock_value(&self, code: &str) -> Value { + let hash = self.lock().pin_hash.clone(); + let (status, legacy, retry_after_ms) = if hash.is_empty() { + ("not_configured", false, 0) + } else if verify_pin_code(code, &hash) == PinVerdict::Rejected { + ("invalid", false, 1000) + } else { + self.lock().pin_unlocked = true; + ("unlocked", !hash.contains(':'), 0) + }; + json!({ "status": status, "legacy": legacy, "retryAfterMs": retry_after_ms }) + } +} + +/// `pinHashOf` in `fixtures.ts`: the hash when the PIN is on and well formed. +fn pin_hash_of(settings: &Value) -> String { + match PinSettings::from_value(settings).lock() { + PinLock::On(hash) => hash, + _ => String::new(), + } +} + +/// The TS fixture answers `cs2_bridge_get_settings` with +/// `{ enabled, url, apiKey }`, a shape the real command stopped sending: it +/// sends `{ enabled, url, tokenConfigured }`. The typed answer is the real +/// shape, with `tokenConfigured` true when the fixture's key is non-empty. +pub fn cs2_settings_from_fixture(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() { + if let Some(key) = object.remove("apiKey") { + let configured = key.as_str().is_some_and(|k| !k.is_empty()); + object + .entry("tokenConfigured") + .or_insert(Value::Bool(configured)); + } + } + value +} + +/// Rewrites every repo-relative avatar path in `value` to the file under +/// `repo_root`. Unchanged without a root. +pub fn resolve_avatars(value: Value, repo_root: Option<&Path>) -> Value { + let Some(root) = repo_root else { + return value; + }; + match value { + Value::String(s) if s.starts_with(AVATAR_DIR) => { + Value::String(join_relative(root, &s).to_string_lossy().into_owned()) + } + Value::Array(items) => Value::Array( + items + .into_iter() + .map(|v| resolve_avatars(v, Some(root))) + .collect(), + ), + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(k, v)| (k, resolve_avatars(v, Some(root)))) + .collect(), + ), + other => other, + } +} + +/// `root` joined with a `/`-separated relative path, one component at a time +/// so the separators are the platform's. +fn join_relative(root: &Path, relative: &str) -> PathBuf { + relative + .split('/') + .filter(|part| !part.is_empty()) + .fold(root.to_path_buf(), |path, part| path.join(part)) +} + +fn arg_str(args: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| args.get(key)?.as_str().map(str::to_string)) +} + +fn arg_strings(args: &Value, keys: &[&str]) -> Vec { + keys.iter() + .find_map(|key| args.get(key)?.as_array()) + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +impl Backend for MockBackend { + fn get_boot_payload(&self) -> BackendResult { + self.call("get_boot_payload", json!({})) + } + fn get_runtime_os(&self) -> BackendResult { + self.call("get_runtime_os", json!({})) + } + fn detect_streaming_software(&self) -> BackendResult { + let recorded: bool = self.call("detect_streaming_software", json!({}))?; + Ok(recorded || self.lock().streaming) + } + fn migrate_legacy_config(&self) -> BackendResult { + self.call("migrate_legacy_config", json!({})) + } + fn load_client_storage_snapshot(&self) -> BackendResult { + self.call("load_client_storage_snapshot", json!({})) + } + fn save_client_storage_store( + &self, + store_id: &str, + value: &Value, + ) -> BackendResult> { + self.call( + "save_client_storage_store", + json!({ "storeId": store_id, "value": value }), + ) + } + fn get_storage_manifest(&self) -> BackendResult { + self.call("get_storage_manifest", json!({})) + } + fn log_app_event( + &self, + level: &str, + source: &str, + message: &str, + details: Option<&str>, + ) -> BackendResult<()> { + self.call( + "log_app_event", + json!({ "level": level, "source": source, "message": message, "details": details }), + ) + } + fn finish_boot(&self, source: &str, marks: Option<&Value>) -> BackendResult<()> { + self.call("finish_boot", json!({ "source": source, "marks": marks })) + } + fn pin_unlock(&self, code: &str) -> BackendResult { + self.call("pin_unlock", json!({ "code": code })) + } + fn pin_lock(&self) -> BackendResult<()> { + self.call("pin_lock", json!({})) + } + fn platform_get_accounts(&self, platform_id: &str) -> BackendResult { + self.call( + "platform_get_accounts", + json!({ "platformId": platform_id }), + ) + } + fn platform_get_startup_snapshot(&self, platform_id: &str) -> BackendResult { + self.call( + "platform_get_startup_snapshot", + json!({ "platformId": platform_id }), + ) + } + fn platform_get_current_account(&self, platform_id: &str) -> BackendResult { + self.call( + "platform_get_current_account", + json!({ "platformId": platform_id }), + ) + } + fn platform_switch_account( + &self, + platform_id: &str, + account_id: &str, + params: &Value, + ) -> BackendResult<()> { + self.call( + "platform_switch_account", + json!({ "platformId": platform_id, "accountId": account_id, "params": params }), + ) + } + fn platform_forget_account(&self, platform_id: &str, account_id: &str) -> BackendResult<()> { + self.call( + "platform_forget_account", + json!({ "platformId": platform_id, "accountId": account_id }), + ) + } + fn platform_begin_setup( + &self, + platform_id: &str, + params: &Value, + ) -> BackendResult { + self.call( + "platform_begin_setup", + json!({ "platformId": platform_id, "params": params }), + ) + } + fn platform_get_setup_status( + &self, + platform_id: &str, + setup_id: &str, + ) -> BackendResult { + self.call( + "platform_get_setup_status", + json!({ "platformId": platform_id, "setupId": setup_id }), + ) + } + fn platform_cancel_setup(&self, platform_id: &str, setup_id: &str) -> BackendResult<()> { + self.call( + "platform_cancel_setup", + json!({ "platformId": platform_id, "setupId": setup_id }), + ) + } + fn platform_get_path(&self, platform_id: &str) -> BackendResult { + self.call("platform_get_path", json!({ "platformId": platform_id })) + } + fn platform_set_path(&self, platform_id: &str, path: &str) -> BackendResult<()> { + self.call( + "platform_set_path", + json!({ "platformId": platform_id, "path": path }), + ) + } + fn platform_select_path(&self, platform_id: &str) -> BackendResult { + self.call("platform_select_path", json!({ "platformId": platform_id })) + } + fn platform_detect_installed(&self) -> BackendResult> { + self.call("platform_detect_installed", json!({})) + } + fn platform_set_account_label( + &self, + platform_id: &str, + account_id: &str, + label: &str, + ) -> BackendResult<()> { + self.call( + "platform_set_account_label", + json!({ "platformId": platform_id, "accountId": account_id, "label": label }), + ) + } + fn platform_dry_run(&self, platform_id: &str, account_id: &str) -> BackendResult { + self.call( + "platform_dry_run", + json!({ "platformId": platform_id, "accountId": account_id }), + ) + } + fn reload_user_platforms(&self) -> BackendResult { + self.call("reload_user_platforms", json!({})) + } + fn descriptor_select_file(&self) -> BackendResult { + self.call("descriptor_select_file", json!({})) + } + fn descriptor_preview_file(&self, path: &str) -> BackendResult { + self.call("descriptor_preview_file", json!({ "path": path })) + } + fn descriptor_install_file(&self, path: &str) -> BackendResult { + self.call("descriptor_install_file", json!({ "path": path })) + } + fn descriptor_remove(&self, platform_id: &str) -> BackendResult { + self.call("descriptor_remove", json!({ "platformId": platform_id })) + } + fn open_descriptors_folder(&self) -> BackendResult<()> { + self.call("open_descriptors_folder", json!({})) + } + fn open_url(&self, url: &str) -> BackendResult<()> { + self.call("open_url", json!({ "url": url })) + } + fn open_logs_folder(&self) -> BackendResult<()> { + self.call("open_logs_folder", json!({})) + } + fn get_desktop_wallpaper(&self) -> BackendResult> { + self.call("get_desktop_wallpaper", json!({})) + } + fn steam_set_api_key(&self, key: &str) -> BackendResult<()> { + self.call("steam_set_api_key", json!({ "key": key })) + } + fn steam_has_api_key(&self) -> BackendResult { + self.call("steam_has_api_key", json!({})) + } + fn steam_open_api_key_page(&self) -> BackendResult<()> { + self.call("steam_open_api_key_page", json!({})) + } + fn steam_switch_account_and_launch_game( + &self, + username: &str, + app_id: &str, + run_as_admin: bool, + launch_options: &str, + shutdown_mode: &str, + ) -> BackendResult<()> { + self.call( + "steam_switch_account_and_launch_game", + json!({ + "username": username, + "appId": app_id, + "runAsAdmin": run_as_admin, + "launchOptions": launch_options, + "shutdownMode": shutdown_mode, + }), + ) + } + fn steam_get_profile_info(&self, steam_id: &str) -> BackendResult> { + self.call("steam_get_profile_info", json!({ "steamId": steam_id })) + } + fn steam_get_profile_infos( + &self, + steam_ids: &[String], + ) -> BackendResult> { + self.call("steam_get_profile_infos", json!({ "steamIds": steam_ids })) + } + fn steam_get_player_bans(&self, steam_ids: &[String]) -> BackendResult> { + self.call("steam_get_player_bans", json!({ "steamIds": steam_ids })) + } + fn steam_copy_game_settings( + &self, + from_steam_id: &str, + to_steam_id: &str, + app_id: &str, + ) -> BackendResult<()> { + self.call( + "steam_copy_game_settings", + json!({ "fromSteamId": from_steam_id, "toSteamId": to_steam_id, "appId": app_id }), + ) + } + fn steam_get_copyable_games( + &self, + from_steam_id: &str, + to_steam_id: &str, + ) -> BackendResult> { + self.call( + "steam_get_copyable_games", + json!({ "fromSteamId": from_steam_id, "toSteamId": to_steam_id }), + ) + } + fn steam_open_userdata(&self, steam_id: &str) -> BackendResult<()> { + self.call("steam_open_userdata", json!({ "steamId": steam_id })) + } + fn steam_clear_browser_cache(&self) -> BackendResult<()> { + self.call("steam_clear_browser_cache", json!({})) + } + fn steam_bulk_edit(&self, request: BulkEditRequest) -> BackendResult { + let request = json!({ + "steamIds": request.steam_ids, + "newsPopup": request.news_popup, + "doNotDisturb": request.do_not_disturb, + "launchOptions": request + .launch_options + .iter() + .map(|edit| json!({ "appId": edit.app_id, "value": edit.value })) + .collect::>(), + }); + self.call("steam_bulk_edit", json!({ "request": request })) + } + fn steam_get_account_games(&self, steam_id: &str) -> BackendResult> { + self.call("steam_get_account_games", json!({ "steamId": steam_id })) + } + fn cs2_bridge_get_settings(&self) -> BackendResult { + self.call("cs2_bridge_get_settings", json!({})) + } + fn cs2_bridge_set_settings( + &self, + enabled: bool, + url: &str, + token: Option<&str>, + ) -> BackendResult<()> { + self.call( + "cs2_bridge_set_settings", + json!({ "enabled": enabled, "url": url, "token": token }), + ) + } + fn cs2_bridge_fetch(&self) -> BackendResult> { + self.call("cs2_bridge_fetch", json!({})) + } + fn cs2_bridge_check(&self, steam_id: &str) -> BackendResult> { + self.call("cs2_bridge_check", json!({ "steamId": steam_id })) + } + fn cs2_bridge_test(&self) -> BackendResult { + self.call("cs2_bridge_test", json!({})) + } + fn riot_capture_profile(&self, profile_id: &str) -> BackendResult<()> { + self.call("riot_capture_profile", json!({ "profileId": profile_id })) + } + fn roblox_add_account_by_cookie(&self, cookie: &str) -> BackendResult { + self.call("roblox_add_account_by_cookie", json!({ "cookie": cookie })) + } + fn roblox_get_profile_info(&self, user_id: &str) -> BackendResult { + self.call("roblox_get_profile_info", json!({ "userId": user_id })) + } + fn roblox_check_sessions(&self) -> BackendResult> { + self.call("roblox_check_sessions", json!({})) + } + fn list_custom_themes(&self) -> BackendResult> { + self.call("list_custom_themes", json!({})) + } + fn save_custom_theme(&self, theme: &CustomTheme) -> BackendResult<()> { + self.call("save_custom_theme", json!({ "theme": theme })) + } + fn delete_custom_theme(&self, theme_id: &str) -> BackendResult<()> { + self.call("delete_custom_theme", json!({ "themeId": theme_id })) + } + fn telemetry_get_state(&self) -> BackendResult { + let mut state: TelemetryUiState = self.call("telemetry_get_state", json!({}))?; + if self.lock().onboarding { + state.onboarding_completed = false; + } + Ok(state) + } + fn telemetry_set_mode_a(&self, enabled: bool) -> BackendResult<()> { + self.call("telemetry_set_mode_a", json!({ "enabled": enabled })) + } + fn telemetry_set_mode_b(&self, enabled: bool) -> BackendResult<()> { + self.call("telemetry_set_mode_b", json!({ "enabled": enabled })) + } + fn telemetry_retry_forget(&self) -> BackendResult<()> { + self.call("telemetry_retry_forget", json!({})) + } + fn telemetry_complete_onboarding( + &self, + mode_a_enabled: bool, + mode_b_enabled: bool, + ) -> BackendResult<()> { + self.call( + "telemetry_complete_onboarding", + json!({ "modeAEnabled": mode_a_enabled, "modeBEnabled": mode_b_enabled }), + ) + } + fn telemetry_track_persona_switch(&self, platforms: u64, succeeded: u64) -> BackendResult<()> { + self.call( + "telemetry_track_persona_switch", + json!({ "platforms": platforms, "succeeded": succeeded }), + ) + } + fn telemetry_track_account_added(&self, platform_id: &str) -> BackendResult<()> { + self.call( + "telemetry_track_account_added", + json!({ "platformId": platform_id }), + ) + } + fn telemetry_track_account_add_started(&self, platform_id: &str) -> BackendResult<()> { + self.call( + "telemetry_track_account_add_started", + json!({ "platformId": platform_id }), + ) + } + fn telemetry_track_account_add_cancelled(&self, platform_id: &str) -> BackendResult<()> { + self.call( + "telemetry_track_account_add_cancelled", + json!({ "platformId": platform_id }), + ) + } + fn telemetry_track_operation_failed( + &self, + operation: &str, + error_code: &str, + platform_id: Option<&str>, + ) -> BackendResult<()> { + self.call( + "telemetry_track_operation_failed", + json!({ "operation": operation, "errorCode": error_code, "platformId": platform_id }), + ) + } + fn telemetry_track_update( + &self, + stage: &str, + target_version: Option<&str>, + error_code: Option<&str>, + ) -> BackendResult<()> { + self.call( + "telemetry_track_update", + json!({ "stage": stage, "targetVersion": target_version, "errorCode": error_code }), + ) + } + fn telemetry_track_settings_snapshot(&self, snapshot: &SettingsSnapshot) -> BackendResult<()> { + self.call( + "telemetry_track_settings_snapshot", + serde_json::to_value(snapshot).unwrap_or(Value::Null), + ) + } + fn telemetry_track_streamer_mode(&self) -> BackendResult<()> { + self.call("telemetry_track_streamer_mode", json!({})) + } + fn telemetry_export(&self) -> BackendResult { + self.call("telemetry_export", json!({})) + } +} diff --git a/crates/accshift-app/src/personas.rs b/crates/accshift-app/src/personas.rs new file mode 100644 index 00000000..11e4f79a --- /dev/null +++ b/crates/accshift-app/src/personas.rs @@ -0,0 +1,263 @@ +//! The persona store, ported from `src/lib/features/personas/{types,store, +//! assignments}.ts`. +//! +//! A persona is a named identity bundle: activating it switches every +//! assigned platform to the persona's account in one action. + +use crate::js; +use crate::stores::StoreContext; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +pub const NAME_MAX: usize = 40; +/// Covers are downscaled before storage; anything longer is not a cover. +pub const IMAGE_MAX_CHARS: usize = 400_000; +const DEFAULT_NAME: &str = "Persona"; + +/// One platform slot of a persona: which account that platform switches to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonaAssignment { + pub platform_id: String, + pub account_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Persona { + pub id: String, + pub name: String, + /// A card tint, `""` for none. + pub color: String, + /// A small `data:image/...` cover, or none (the card shows a mosaic). + pub image: Option, + pub assignments: Vec, +} + +/// A persona without its id, for [`crate::stores::ClientStores::create_persona`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PersonaInput { + pub name: String, + pub color: String, + pub image: Option, + pub assignments: Vec, +} + +/// The fields `updatePersona` replaces; `None` keeps the current value. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PersonaPatch { + pub name: Option, + pub color: Option, + pub image: Option>, + pub assignments: Option>, +} + +impl Persona { + pub fn apply(&mut self, patch: &PersonaPatch) { + if let Some(name) = &patch.name { + self.name = name.clone(); + } + if let Some(color) = &patch.color { + self.color = color.clone(); + } + if let Some(image) = &patch.image { + self.image = image.clone(); + } + if let Some(assignments) = &patch.assignments { + self.assignments = assignments.clone(); + } + } +} + +fn sanitize_assignments(value: Option<&Value>, ctx: &StoreContext) -> Vec { + let Some(Value::Array(items)) = value else { + return Vec::new(); + }; + let mut seen = BTreeSet::new(); + let mut result = Vec::new(); + for raw in items { + // `typeof raw === "object"` lets arrays through; they hold no fields. + let Some(record) = js::as_record(Some(raw)) else { + continue; + }; + let (Some(platform_id), Some(account_id)) = ( + js::as_str(record.get("platformId")), + js::as_str(record.get("accountId")), + ) else { + continue; + }; + if !ctx.knows_platform(platform_id) || seen.contains(platform_id) { + continue; + } + if account_id.is_empty() { + continue; + } + seen.insert(platform_id.to_string()); + result.push(PersonaAssignment { + platform_id: platform_id.to_string(), + account_id: account_id.to_string(), + }); + } + result +} + +fn is_persona_color(value: &str) -> bool { + js::is_hex_color(value, &[6]) +} + +fn sanitize_persona(value: &Value, ctx: &StoreContext) -> Option { + let raw = js::as_record(Some(value))?; + let id = js::as_str(raw.get("id")).filter(|id| !id.is_empty())?; + let name = js::as_str(raw.get("name")) + .map(js::trim) + .filter(|name| !name.is_empty()) + .map_or_else( + || DEFAULT_NAME.to_string(), + |name| js::utf16_prefix(name, NAME_MAX), + ); + let color = js::as_str(raw.get("color")) + .filter(|color| is_persona_color(color)) + .unwrap_or_default() + .to_string(); + let image = js::as_str(raw.get("image")) + .filter(|image| image.starts_with("data:image/") && js::utf16_len(image) <= IMAGE_MAX_CHARS) + .map(str::to_string); + Some(Persona { + id: id.to_string(), + name, + color, + image, + assignments: sanitize_assignments(raw.get("assignments"), ctx), + }) +} + +/// `sanitizeList`: valid personas, first of each id. +pub fn sanitize_personas(value: &Value, ctx: &StoreContext) -> Vec { + let Value::Array(items) = value else { + return Vec::new(); + }; + let mut seen = BTreeSet::new(); + let mut result = Vec::new(); + for raw in items { + let Some(persona) = sanitize_persona(raw, ctx) else { + continue; + }; + if !seen.insert(persona.id.clone()) { + continue; + } + result.push(persona); + } + result +} + +/// The list as the JSON the store holds. +pub fn personas_to_value(personas: &[Persona]) -> Value { + serde_json::to_value(personas).unwrap_or(Value::Null) +} + +/// `newId`: a random UUID v4, as `crypto.randomUUID` gives. +pub fn generate_persona_id() -> String { + let high = js::random_u64(); + let low = js::random_u64(); + let mut bytes = [0u8; 16]; + bytes[..8].copy_from_slice(&high.to_be_bytes()); + bytes[8..].copy_from_slice(&low.to_be_bytes()); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) +} + +/// The wizard state [`persona_assignments_for_save`] reads. +#[derive(Debug, Clone, Copy)] +pub struct AssignmentsForSave<'a> { + pub original: &'a [PersonaAssignment], + pub offered_platform_ids: &'a [String], + /// Platform id to picked account id; `""` clears that platform. + pub selection: &'a BTreeMap, + /// The account ids each platform listed. + pub accounts_by_platform: &'a BTreeMap>, + pub loading: bool, + pub failed_platform_ids: &'a [String], +} + +/// `personaAssignmentsForSave`: the assignments to write when a persona is +/// saved. A platform the wizard is not offering, or whose account read +/// failed, keeps its stored assignment; one whose accounts loaded drops an id +/// no longer listed. Stored order first, new picks after in wizard order. +pub fn persona_assignments_for_save(input: &AssignmentsForSave<'_>) -> Vec { + let offered: BTreeSet<&str> = input + .offered_platform_ids + .iter() + .map(String::as_str) + .collect(); + let failed: BTreeSet<&str> = input + .failed_platform_ids + .iter() + .map(String::as_str) + .collect(); + let listed = |platform_id: &str, id: &str| { + input + .accounts_by_platform + .get(platform_id) + .is_some_and(|accounts| accounts.iter().any(|account| account == id)) + }; + let mut result = Vec::new(); + let mut seen = BTreeSet::new(); + + for assignment in input.original { + seen.insert(assignment.platform_id.as_str()); + let edited = input.selection.get(&assignment.platform_id); + if edited.is_some_and(String::is_empty) { + continue; + } + if !offered.contains(assignment.platform_id.as_str()) { + result.push(assignment.clone()); + continue; + } + let selected = edited.map_or("", String::as_str); + let id = if selected.is_empty() { + assignment.account_id.as_str() + } else { + selected + }; + if id.is_empty() { + continue; + } + if !input.loading + && !failed.contains(assignment.platform_id.as_str()) + && !listed(&assignment.platform_id, id) + { + continue; + } + result.push(PersonaAssignment { + platform_id: assignment.platform_id.clone(), + account_id: id.to_string(), + }); + } + + for platform_id in input.offered_platform_ids { + if seen.contains(platform_id.as_str()) { + continue; + } + let Some(id) = input.selection.get(platform_id).filter(|id| !id.is_empty()) else { + continue; + }; + if !input.loading && !failed.contains(platform_id.as_str()) && !listed(platform_id, id) { + continue; + } + result.push(PersonaAssignment { + platform_id: platform_id.clone(), + account_id: id.clone(), + }); + } + result +} diff --git a/crates/accshift-app/src/platform_add_flow.rs b/crates/accshift-app/src/platform_add_flow.rs new file mode 100644 index 00000000..ba8b4fcb --- /dev/null +++ b/crates/accshift-app/src/platform_add_flow.rs @@ -0,0 +1,769 @@ +//! The add-account flow controller, ported from +//! `src/lib/app/platformAddFlow.svelte.ts` with its App.svelte glue (lines +//! 127-138, 687-690). +//! +//! [`crate::accounts::loading::AccountLoading`] starts a setup and reports it +//! as `LoadingEvent::AddFlowStarted`; this controller then polls it every +//! 1.5 s through the platform adapter, draws the pending card and its card +//! extension, and ends it on ready, failure or cancel. Polls, cancels, +//! telemetry and the profile prime run on [`Jobs`]; what the app must do in +//! return comes back as [`AddFlowEvent`]s. + +use crate::accounts::loader::LoadOptions; +use crate::accounts::profile_cache::{CacheWrite, ProfileCaches}; +use crate::accounts::{Toast, ToastKind}; +use crate::error::{BackendError, BackendResult}; +use crate::i18n::Locale; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::platforms::card::{CardChip, CardLink, CardSection, ChipTone}; +use crate::platforms::{AddFlowState, AddFlowStatus, PlatformAccount, PlatformRegistry}; +use crate::stores::ClientStores; +use crate::text::Text; +use crate::time::Clock; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// The `setTimeout` of `schedulePoll` (line 155). +pub const POLL_DELAY: Duration = Duration::from_millis(1500); + +/// The kinds of `getSetupKey` (lines 37-59). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetupKey { + PendingLabel, + WaitingForClient, + WaitingForLogin, + Detected, + Connected, + Ready, + ReadyWithProfile, + Failed, + FailedMessage, +} + +impl SetupKey { + fn suffix(self) -> &'static str { + match self { + SetupKey::PendingLabel => "PendingLabel", + SetupKey::WaitingForClient => "WaitingForClient", + SetupKey::WaitingForLogin => "WaitingForLogin", + SetupKey::Detected => "Detected", + SetupKey::Connected => "Connected", + SetupKey::Ready => "Ready", + SetupKey::ReadyWithProfile => "ReadyWithProfile", + SetupKey::Failed => "Failed", + SetupKey::FailedMessage => "FailedMessage", + } + } +} + +/// `getSetupKey`: `.setup`, battle-net under `battlenet`. +pub fn setup_key(platform_id: &str, kind: SetupKey) -> String { + let prefix = match platform_id { + "battle-net" => "battlenet", + other => other, + }; + format!("{prefix}.setup{}", kind.suffix()) +} + +/// `PlatformAddFlowEntry`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddFlowEntry { + pub platform_id: String, + pub status: AddFlowStatus, +} + +/// What the flow reads from the app (`getActiveTab`, `getCurrentFolderId`, +/// `getIsSearching`). +#[derive(Debug, Clone, Copy)] +pub struct AddFlowView<'a> { + pub active_tab: &'a str, + pub current_folder_id: Option<&'a str>, + pub is_searching: bool, +} + +/// What the flow needs to run its jobs. +pub struct AddFlowContext<'a> { + pub jobs: &'a Jobs, + pub registry: &'a PlatformRegistry, + pub view: AddFlowView<'a>, + /// The profile caches the account loader uses, for the prime after an + /// add (`primeProfileAfterAdd`). + pub caches: &'a Arc, + pub clock: &'a Clock, + pub now: Instant, +} + +/// What the app does in return. +#[derive(Debug, Clone, PartialEq)] +pub enum AddFlowEvent { + /// `showToast(message)`. + Toast(Toast), + /// `loadAccounts(...)` on the active tab. + LoadAccounts(LoadOptions), + /// `onAccountAdded`: App.svelte opens the rename prompt + /// (`dialogs.promptRenameNewAccount`, lines 135-137). + AccountAdded { + platform_id: String, + account_id: String, + display_name: Option, + }, +} + +/// The pending card (`pendingSetupAccount`, lines 87-101). Texts resolve at +/// draw time; [`PendingSetupAccount::to_account`] gives the card's account. +#[derive(Debug, Clone, PartialEq)] +pub struct PendingSetupAccount { + pub id: String, + pub display_name: Text, + pub username: Text, +} + +impl PendingSetupAccount { + pub fn to_account(&self, locale: Locale) -> PlatformAccount { + PlatformAccount { + id: self.id.clone(), + display_name: self.display_name.resolve(locale), + username: self.username.resolve(locale), + last_login_at_sec: None, + } + } +} + +/// What a chip of the setup card does (the TS `onClick`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SetupChipAction { + /// The red Close chip: [`PlatformAddFlow::cancel`]. + Cancel, + /// Roblox's "Copy code": put the quick login code on the clipboard + /// (`copyToClipboard(text, text)`, App.svelte line 133). + CopyCode(String), +} + +/// `getSetupExtensionContent`: the sections and which chips act. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct SetupExtension { + pub sections: Vec, + /// `((section, chip), action)` for the chips that have an `onClick`. + pub chip_actions: Vec<((usize, usize), SetupChipAction)>, +} + +impl SetupExtension { + /// The action of chip `chip` in section `section`, if it has one. + pub fn chip_action(&self, section: usize, chip: usize) -> Option<&SetupChipAction> { + self.chip_actions + .iter() + .find(|(at, _)| *at == (section, chip)) + .map(|(_, action)| action) + } +} + +type PrimeJob = Pending<(String, Vec)>; + +/// `createPlatformAddFlowController`. +#[derive(Default)] +pub struct PlatformAddFlow { + flow: Option, + poll_deadline: Option, + /// The setup id the poll was sent for, and its answer. + poll: Option<(String, Pending>)>, + /// The flow being cancelled, and the cancel's answer. + cancel: Option<(AddFlowEntry, Pending>)>, + primes: Vec, + events: Vec, +} + +impl PlatformAddFlow { + pub fn new() -> Self { + Self::default() + } + + /// `flow`. + pub fn flow(&self) -> Option<&AddFlowEntry> { + self.flow.as_ref() + } + + /// The `add_flow_active` flag `AccountLoading::handle_add_account_click` + /// takes. + pub fn is_active(&self) -> bool { + self.flow.is_some() + } + + /// Jobs in flight. + pub fn is_busy(&self) -> bool { + self.poll.is_some() || self.cancel.is_some() || !self.primes.is_empty() + } + + /// When [`Self::tick`] next has work. + pub fn next_deadline(&self) -> Option { + self.poll_deadline + } + + /// `clearTimer` (lines 109-113). + pub fn clear_timer(&mut self) { + self.poll_deadline = None; + } + + /// `stop` (lines 115-118). + pub fn stop(&mut self) { + self.clear_timer(); + self.flow = None; + } + + fn schedule_poll(&mut self, now: Instant) { + self.poll_deadline = Some(now + POLL_DELAY); + } + + /// `start` (lines 228-245): a setup the account loader just began. + pub fn start(&mut self, ctx: &AddFlowContext<'_>, platform_id: &str, status: AddFlowStatus) { + self.flow = Some(AddFlowEntry { + platform_id: platform_id.to_string(), + status: status.clone(), + }); + // The opening is the denominator of the funnel. + track(ctx.jobs, platform_id, |backend, id| { + backend.telemetry_track_account_add_started(id) + }); + match status.state() { + // Some setups finish at once (Discord adopting the session). + AddFlowState::Ready => self.handle_ready(ctx, platform_id, &status), + AddFlowState::Failed => report_add_failure(ctx.jobs, platform_id), + _ => self.schedule_poll(ctx.now), + } + } + + /// `poll` (lines 158-194), up to the backend call: asks the adapter for + /// the setup's status now. Ends the flow when its platform has no + /// adapter. + pub fn poll_now(&mut self, ctx: &AddFlowContext<'_>) { + self.clear_timer(); + let Some(current) = self.flow.clone() else { + self.stop(); + return; + }; + let Some(adapter) = ctx.registry.adapter(¤t.platform_id) else { + self.stop(); + return; + }; + let setup_id = current.status.setup_id.clone(); + let id = setup_id.clone(); + self.poll = Some(( + setup_id, + ctx.jobs + .run(move |backend| adapter.poll_add_flow(backend, &id)), + )); + } + + /// The end of `poll`. + fn apply_poll( + &mut self, + ctx: &AddFlowContext<'_>, + setup_id: &str, + result: BackendResult, + ) { + let Some(flow) = self.flow.as_mut() else { + return; + }; + if flow.status.setup_id != setup_id { + return; + } + let platform_id = flow.platform_id.clone(); + match result { + Ok(next) => { + flow.status = next.clone(); + match next.state() { + AddFlowState::Ready => self.handle_ready(ctx, &platform_id, &next), + AddFlowState::Failed => report_add_failure(ctx.jobs, &platform_id), + _ => self.schedule_poll(ctx.now), + } + } + Err(error) => { + report_add_failure(ctx.jobs, &platform_id); + flow.status.state = "failed".to_string(); + flow.status.error_message = Some(error.message); + } + } + } + + /// `handleReady` (lines 206-226). + fn handle_ready( + &mut self, + ctx: &AddFlowContext<'_>, + platform_id: &str, + status: &AddFlowStatus, + ) { + track(ctx.jobs, platform_id, |backend, id| { + backend.telemetry_track_account_added(id) + }); + let adapter = ctx.registry.adapter(platform_id); + let prime = ctx + .registry + .definition(platform_id) + .is_some_and(|def| def.capabilities.prime_profile_after_add); + if let (true, Some(adapter), Some(account_id)) = + (prime, adapter.clone(), status.account_id.clone()) + { + let caches = Arc::clone(ctx.caches); + let clock = ctx.clock.clone(); + let id = platform_id.to_string(); + self.primes.push(ctx.jobs.run(move |backend| { + let fetched = + caches.profile_info(&adapter, backend, &account_id, &|| clock.now_ms()); + (id, fetched.writes) + })); + } + if ctx.view.active_tab == platform_id { + self.events.push(AddFlowEvent::LoadAccounts(LoadOptions { + silent: true, + ..LoadOptions::default() + })); + } + let text = match status.account_display_name.as_deref() { + Some(profile) if !profile.is_empty() => Text::with( + setup_key(platform_id, SetupKey::ReadyWithProfile), + [("profile", profile)], + ), + _ => Text::key(setup_key(platform_id, SetupKey::Ready)), + }; + self.events + .push(AddFlowEvent::Toast(Toast::new(text, ToastKind::Default))); + self.stop(); + if let (Some(adapter), Some(account_id)) = (adapter, status.account_id.as_deref()) { + if adapter.supports_account_labels() && !account_id.is_empty() { + self.events.push(AddFlowEvent::AccountAdded { + platform_id: platform_id.to_string(), + account_id: account_id.to_string(), + display_name: status.account_display_name.clone(), + }); + } + } + } + + /// `cancel` (lines 120-141), up to the backend call. The poll timer stops + /// at once, so no poll races the cancel. + pub fn cancel(&mut self, ctx: &AddFlowContext<'_>) { + let Some(current) = self.flow.clone() else { + self.stop(); + return; + }; + // Counted whether or not the adapter has anything to cancel: the + // funnel measures the user walking away. + track(ctx.jobs, ¤t.platform_id, |backend, id| { + backend.telemetry_track_account_add_cancelled(id) + }); + let Some(adapter) = ctx.registry.adapter(¤t.platform_id) else { + self.stop(); + return; + }; + if self.cancel.is_some() { + return; + } + self.clear_timer(); + let setup_id = current.status.setup_id.clone(); + self.cancel = Some(( + current, + ctx.jobs + .run(move |backend| adapter.cancel_add_flow(backend, &setup_id)), + )); + } + + /// `cancelIfConflicting` (lines 143-149): a switch, a back navigation or + /// a palette jump on the flow's platform drops it, unless the target is + /// the setup account itself. + pub fn cancel_if_conflicting( + &mut self, + ctx: &AddFlowContext<'_>, + target_platform_id: &str, + target_account_id: Option<&str>, + ) { + let Some(current) = self.flow.as_ref() else { + return; + }; + if current.platform_id != target_platform_id { + return; + } + if target_account_id.is_some_and(|id| !id.is_empty() && current.status.setup_id == id) { + return; + } + self.cancel(ctx); + } + + fn apply_cancel( + &mut self, + ctx: &AddFlowContext<'_>, + entry: AddFlowEntry, + result: BackendResult<()>, + ) { + if let Err(BackendError { message, .. }) = result { + self.events.push(AddFlowEvent::Toast(Toast::new( + Text::raw(message), + ToastKind::Default, + ))); + } + self.stop(); + if ctx.view.active_tab == entry.platform_id { + self.events.push(AddFlowEvent::LoadAccounts(LoadOptions { + silent: true, + ..LoadOptions::default() + })); + } + } + + /// One frame: applies finished polls, cancels and primes, then sends the + /// next poll when due. + pub fn tick( + &mut self, + ctx: &AddFlowContext<'_>, + stores: &mut ClientStores, + ) -> Vec { + if let Some((setup_id, pending)) = self.poll.as_mut() { + let answer = match pending.poll() { + Poll::Waiting => None, + Poll::Ready(result) => Some(result), + Poll::Gone => Some(Err(BackendError::new("Setup status check stopped"))), + }; + if let Some(result) = answer { + let setup_id = setup_id.clone(); + self.poll = None; + self.apply_poll(ctx, &setup_id, result); + } + } + if let Some((_, pending)) = self.cancel.as_mut() { + let answer = match pending.poll() { + Poll::Waiting => None, + Poll::Ready(result) => Some(result), + Poll::Gone => Some(Ok(())), + }; + if let Some(result) = answer { + if let Some((entry, _)) = self.cancel.take() { + self.apply_cancel(ctx, entry, result); + } + } + } + let caches = ctx.caches; + self.primes.retain_mut(|pending| match pending.poll() { + Poll::Waiting => true, + Poll::Ready((platform_id, writes)) => { + caches.apply_writes(&platform_id, stores.storage_mut(), writes); + false + } + Poll::Gone => false, + }); + if self.poll_deadline.is_some_and(|due| ctx.now >= due) && self.poll.is_none() { + self.poll_now(ctx); + } + std::mem::take(&mut self.events) + } + + // ------------------------------------------------------------ views + + fn shown_on(&self, view: &AddFlowView<'_>) -> Option<&AddFlowEntry> { + let flow = self.flow.as_ref()?; + if view.active_tab != flow.platform_id { + return None; + } + if view.is_searching || view.current_folder_id.is_some_and(|id| !id.is_empty()) { + return None; + } + Some(flow) + } + + /// `pendingSetupAccount` (lines 87-101): the placeholder card at the + /// root of the flow's tab, until the setup is ready. + pub fn pending_setup_account(&self, view: &AddFlowView<'_>) -> Option { + let flow = self.shown_on(view)?; + if flow.status.state() == AddFlowState::Ready { + return None; + } + let setup_id = crate::js::trim(&flow.status.setup_id); + if setup_id.is_empty() { + return None; + } + let detected = crate::js::trim( + flow.status + .account_display_name + .as_deref() + .unwrap_or_default(), + ); + let (display_name, username) = if detected.is_empty() { + ( + Text::key("platform.newAccountPending"), + pending_username(flow), + ) + } else { + ( + Text::raw(detected), + Text::key(setup_key(&flow.platform_id, SetupKey::Connected)), + ) + }; + Some(PendingSetupAccount { + id: setup_id.to_string(), + display_name, + username, + }) + } + + /// `isPendingSetupAccount` (lines 457-459). + pub fn is_pending_setup_account(&self, view: &AddFlowView<'_>, account_id: &str) -> bool { + self.pending_setup_account(view) + .is_some_and(|pending| pending.id == account_id) + } + + /// `pendingSetupAccountId` (App.svelte line 687). + pub fn pending_setup_account_id(&self, view: &AddFlowView<'_>) -> Option { + self.pending_setup_account(view).map(|pending| pending.id) + } + + /// `activePlatformAddSetupId` (App.svelte lines 688-690): the setup id + /// when the flow belongs to the active tab. + pub fn active_platform_setup_id(&self, active_tab: &str) -> Option<&str> { + self.flow + .as_ref() + .filter(|flow| flow.platform_id == active_tab) + .map(|flow| flow.status.setup_id.as_str()) + } + + /// `isForcedOpen` (lines 448-455): the setup card stays expanded. + pub fn is_forced_open(&self, active_tab: &str, account_id: &str) -> bool { + self.flow.as_ref().is_some_and(|flow| { + flow.platform_id == active_tab + && flow.status.setup_id == account_id + && flow.status.state() != AddFlowState::Ready + }) + } + + /// `getSetupExtensionContent` (lines 247-446). `locale` resolves the + /// plain `lines`, which the card model keeps as strings. + pub fn setup_extension_content( + &self, + account_id: &str, + locale: Locale, + ) -> Option { + let flow = self.flow.as_ref()?; + let setup_id = crate::js::trim(&flow.status.setup_id); + if setup_id.is_empty() || setup_id != account_id { + return None; + } + Some(setup_extension(flow, locale)).flatten() + } +} + +/// The one line the card shows while nothing is detected (lines 104-107). +fn pending_username(entry: &AddFlowEntry) -> Text { + if entry.status.state() == AddFlowState::Busy { + Text::key("platform.setupBusy") + } else { + Text::key(setup_key(&entry.platform_id, SetupKey::WaitingForLogin)) + } +} + +fn track(jobs: &Jobs, platform_id: &str, call: fn(&dyn crate::Backend, &str) -> BackendResult<()>) { + // Fire and forget (`telemetryClient.track`): the mock and an unconsented + // backend refuse, nobody cares. + let id = platform_id.to_string(); + drop(jobs.run(move |backend| { + let _ = call(backend, &id); + })); +} + +/// `reportAddFailure` (lines 202-204): the adapters report a message, not a +/// typed kind, so the code is `other`. +fn report_add_failure(jobs: &Jobs, platform_id: &str) { + let id = platform_id.to_string(); + drop(jobs.run(move |backend| { + let _ = backend.telemetry_track_operation_failed("account_add", "other", Some(&id)); + })); +} + +fn section_text(text: Text, loading: bool) -> CardSection { + CardSection { + text: Some(text), + loading, + ..CardSection::default() + } +} + +fn section_lines(lines: Vec) -> CardSection { + CardSection { + lines, + ..CardSection::default() + } +} + +fn failed_section(platform_id: &str, text: Text) -> CardSection { + CardSection { + title: Some(Text::key(setup_key(platform_id, SetupKey::Failed))), + text: Some(text), + ..CardSection::default() + } +} + +/// `createDetectedSection` (lines 61-72). +fn detected_section(platform_id: &str, display: &str) -> Option { + if display.is_empty() { + return None; + } + Some(CardSection { + title: Some(Text::key(setup_key(platform_id, SetupKey::Detected))), + text: Some(Text::raw(display)), + chips: vec![CardChip { + text: Text::key(setup_key(platform_id, SetupKey::Connected)), + tone: ChipTone::Green, + }], + ..CardSection::default() + }) +} + +fn close_chip() -> CardChip { + CardChip { + text: Text::key("common.close"), + tone: ChipTone::Red, + } +} + +/// The failure section with the error lines and the Close chip +/// (`{ ...(error ? { lines: [error] } : {}), chips: [closeChip] }`). +fn close_section(error: &str) -> CardSection { + CardSection { + lines: if error.is_empty() { + Vec::new() + } else { + vec![error.to_string()] + }, + chips: vec![close_chip()], + ..CardSection::default() + } +} + +fn setup_extension(flow: &AddFlowEntry, locale: Locale) -> Option { + let platform = flow.platform_id.as_str(); + let display = crate::js::trim( + flow.status + .account_display_name + .as_deref() + .unwrap_or_default(), + ); + let error = crate::js::trim(flow.status.error_message.as_deref().unwrap_or_default()); + let detected = detected_section(platform, display); + let state = flow.status.state(); + let line = |key: &str| Text::key(key).resolve(locale); + let mut actions = Vec::new(); + + // Before the per-platform branches: the cross-process lock is not a + // platform concern. Non-terminal, so the spinner stays on. + let sections: Vec = if state == AddFlowState::Busy { + std::iter::once(section_text(Text::key("platform.setupBusy"), true)) + .chain(detected) + .collect() + } else { + match (platform, state) { + (_, AddFlowState::Ready) => return None, + ("riot", AddFlowState::WaitingForClient) => vec![ + section_text(Text::key("riot.setupWaitingForClient"), true), + section_lines(vec![line("riot.setupStaySignedIn")]), + ], + ("riot", AddFlowState::Capturing) => { + std::iter::once(section_text(Text::key("riot.setupCapturing"), true)) + .chain(detected) + .collect() + } + ("riot", AddFlowState::Failed) => { + actions.push(((1, 0), SetupChipAction::Cancel)); + vec![ + failed_section(platform, Text::key("riot.setupFailedMessage")), + close_section(error), + ] + } + ("riot", _) => { + let first = if error.is_empty() { + section_text(Text::key("riot.setupWaitingForLogin"), true) + } else { + section_text(Text::raw(error), false) + }; + let second = + detected.unwrap_or_else(|| section_lines(vec![line("riot.setupStaySignedIn")])); + vec![first, second] + } + ("roblox", AddFlowState::Failed) => { + actions.push(((1, 0), SetupChipAction::Cancel)); + let text = if error.is_empty() { + Text::key("roblox.setupFailedMessage") + } else { + Text::raw(error) + }; + vec![ + failed_section(platform, text), + CardSection { + chips: vec![close_chip()], + ..CardSection::default() + }, + ] + } + ("roblox", _) => { + let mut chips = vec![CardChip { + text: Text::raw("Quick Login"), + tone: ChipTone::Green, + }]; + if !display.is_empty() { + actions.push(((0, 1), SetupChipAction::CopyCode(display.to_string()))); + chips.push(CardChip { + text: Text::key("roblox.copyCode"), + tone: ChipTone::Slate, + }); + } + vec![CardSection { + link: Some(CardLink { + label: Text::raw("roblox.com/quick-login"), + url: "https://www.roblox.com/crossdevicelogin/ConfirmCode".to_string(), + }), + chips, + loading: true, + ..CardSection::default() + }] + } + ("battle-net", AddFlowState::WaitingForClient) => vec![section_text( + Text::key("battlenet.setupWaitingForClient"), + true, + )], + ("battle-net", AddFlowState::Failed) => { + actions.push(((1, 0), SetupChipAction::Cancel)); + vec![ + failed_section(platform, Text::key("battlenet.setupFailedMessage")), + close_section(error), + ] + } + ("battle-net", _) => std::iter::once(section_text( + Text::key("battlenet.setupWaitingForLogin"), + true, + )) + .chain(detected) + .chain(std::iter::once(section_lines(vec![line( + "battlenet.setupKeepMeLoggedIn", + )]))) + .collect(), + (_, AddFlowState::WaitingForClient) => vec![section_text( + Text::key(setup_key(platform, SetupKey::WaitingForClient)), + true, + )], + (_, AddFlowState::Failed) => { + actions.push(((1, 0), SetupChipAction::Cancel)); + vec![ + failed_section( + platform, + Text::key(setup_key(platform, SetupKey::FailedMessage)), + ), + close_section(error), + ] + } + _ => std::iter::once(section_text( + Text::key(setup_key(platform, SetupKey::WaitingForLogin)), + true, + )) + .chain(detected) + .collect(), + } + }; + Some(SetupExtension { + sections, + chip_actions: actions, + }) +} diff --git a/crates/accshift-app/src/platform_shell.rs b/crates/accshift-app/src/platform_shell.rs new file mode 100644 index 00000000..574dfe45 --- /dev/null +++ b/crates/accshift-app/src/platform_shell.rs @@ -0,0 +1,194 @@ +//! The platform shell state, ported from `src/lib/app/platformShell.svelte.ts` +//! with the App.svelte effect that keeps the active tab usable (lines +//! 692-698). +//! +//! The Svelte `$derived` values are methods computed from the settings, the +//! runtime OS and the active tab; `adapterEpoch` only invalidated the derived +//! adapter, which [`PlatformShell::adapter`] reads fresh each call. + +use crate::accounts::loading::is_platform_usable; +use crate::i18n::Locale; +use crate::platforms::{PlatformAdapter, PlatformDef, PlatformRegistry, RuntimeOs}; +use crate::settings::AppSettings; +use std::collections::BTreeSet; +use std::sync::Arc; + +/// `isPlatformCompatibleWithOs` (lines 10-16). +pub fn is_platform_compatible_with_os(platform: Option<&PlatformDef>, os: RuntimeOs) -> bool { + platform.is_some_and(|def| def.supports(os)) +} + +/// `getInitialActiveTab` (lines 23-37): the default platform when enabled and +/// usable, else the first enabled usable one, else the first usable one, else +/// the first enabled one, else the first shipped one, else `steam`. +pub fn initial_active_tab( + registry: &PlatformRegistry, + settings: &AppSettings, + os: RuntimeOs, +) -> String { + let usable = |id: &str| is_platform_usable(registry, id, os); + if settings + .enabled_platforms + .contains(&settings.default_platform_id) + && usable(&settings.default_platform_id) + { + return settings.default_platform_id.clone(); + } + if let Some(id) = settings.enabled_platforms.iter().find(|id| usable(id)) { + return id.clone(); + } + if let Some(def) = registry.defs().iter().find(|def| usable(&def.id)) { + return def.id.clone(); + } + settings + .enabled_platforms + .first() + .filter(|id| !id.is_empty()) + .cloned() + .or_else(|| registry.defs().first().map(|def| def.id.clone())) + .unwrap_or_else(|| "steam".to_string()) +} + +/// `createPlatformShellState`. +#[derive(Debug, Clone)] +pub struct PlatformShell { + settings: AppSettings, + runtime_os: RuntimeOs, + active_tab: String, +} + +impl PlatformShell { + /// The OS starts `unknown` until the backend answers, as in TS (line 42). + pub fn new(registry: &PlatformRegistry, settings: AppSettings) -> Self { + let active_tab = initial_active_tab(registry, &settings, RuntimeOs::Unknown); + Self { + settings, + runtime_os: RuntimeOs::Unknown, + active_tab, + } + } + + pub fn settings(&self) -> &AppSettings { + &self.settings + } + + /// The `settings` setter. + pub fn set_settings(&mut self, settings: AppSettings) { + self.settings = settings; + } + + /// `refreshSettings`: takes the store's latest settings. + pub fn refresh_settings(&mut self, settings: &AppSettings) { + self.settings = settings.clone(); + } + + pub fn runtime_os(&self) -> RuntimeOs { + self.runtime_os + } + + /// `setRuntimeOs`. + pub fn set_runtime_os(&mut self, os: RuntimeOs) { + self.runtime_os = os; + } + + pub fn active_tab(&self) -> &str { + &self.active_tab + } + + /// `setActiveTab`. + pub fn set_active_tab(&mut self, tab: impl Into) { + self.active_tab = tab.into(); + } + + /// `locale`. + pub fn locale(&self) -> Locale { + self.settings.language + } + + /// `enabledPlatforms`: the shipped order, filtered by the settings. + pub fn enabled_platforms<'a>(&self, registry: &'a PlatformRegistry) -> Vec<&'a PlatformDef> { + registry + .defs() + .iter() + .filter(|def| self.settings.enabled_platforms.contains(&def.id)) + .collect() + } + + /// `compatiblePlatforms`: every usable platform on this OS. + pub fn compatible_platforms<'a>(&self, registry: &'a PlatformRegistry) -> Vec<&'a PlatformDef> { + registry + .defs() + .iter() + .filter(|def| is_platform_usable(registry, &def.id, self.runtime_os)) + .collect() + } + + /// `activePlatformDef`. + pub fn active_platform_def<'a>( + &self, + registry: &'a PlatformRegistry, + ) -> Option<&'a PlatformDef> { + registry.definition(&self.active_tab) + } + + /// `activeTabUsable`. + pub fn active_tab_usable(&self, registry: &PlatformRegistry) -> bool { + is_platform_usable(registry, &self.active_tab, self.runtime_os) + } + + /// `unavailablePlatformIds`: enabled platforms this OS cannot use. + pub fn unavailable_platform_ids(&self, registry: &PlatformRegistry) -> BTreeSet { + self.enabled_platforms(registry) + .into_iter() + .filter(|def| !is_platform_usable(registry, &def.id, self.runtime_os)) + .map(|def| def.id.clone()) + .collect() + } + + /// `accentColor`: the active platform's accent; `None` means the theme's + /// `--accent`. + pub fn accent_color(&self, registry: &PlatformRegistry) -> Option { + self.active_platform_def(registry) + .map(|def| def.accent.clone()) + .filter(|accent| !accent.is_empty()) + } + + /// The `themeId` `activeTheme` resolves. + pub fn theme_id(&self) -> &str { + &self.settings.theme_id + } + + /// `uiZoomFactor`: `uiScalePercent / 100`, held in 0.75..=1.5. + pub fn ui_zoom_factor(&self) -> f64 { + (self.settings.ui_scale_percent as f64 / 100.0).clamp(0.75, 1.5) + } + + /// `appStageStyle`: the stage scale, `None` at 100 %. + pub fn app_stage_zoom(&self) -> Option { + let zoom = self.ui_zoom_factor(); + ((zoom - 1.0).abs() >= 0.0001).then_some(zoom) + } + + /// `adapter`: the active tab's adapter while that tab is usable. + pub fn adapter(&self, registry: &PlatformRegistry) -> Option> { + if !self.active_tab_usable(registry) { + return None; + } + registry.adapter(&self.active_tab) + } + + /// `ensureActiveTab` (lines 93-99): moves off a tab this OS cannot use. + /// True when the tab changed. App.svelte runs it once the OS is known and + /// whenever the settings or the tab change (lines 692-698). + pub fn ensure_active_tab(&mut self, registry: &PlatformRegistry) -> bool { + if is_platform_usable(registry, &self.active_tab, self.runtime_os) { + return false; + } + let fallback = initial_active_tab(registry, &self.settings, self.runtime_os); + if fallback == self.active_tab { + return false; + } + self.active_tab = fallback; + true + } +} diff --git a/crates/accshift-app/src/platforms/adapter.rs b/crates/accshift-app/src/platforms/adapter.rs new file mode 100644 index 00000000..61235294 --- /dev/null +++ b/crates/accshift-app/src/platforms/adapter.rs @@ -0,0 +1,688 @@ +//! One platform's behaviour, ported from the `PlatformAdapter` interface of +//! `src/lib/shared/platform.ts` and the adapters that implement it +//! (`steam/adapter.ts`, `riot/adapter.ts`, `roblox/adapter.ts`, +//! `genericAdapter.ts` and the six `*/adapter.ts` built on it), plus +//! `addFlow.ts`. +//! +//! Why an enum and not a trait object: the set of adapters is closed (Steam, +//! Riot, Roblox and the generic family, user descriptors included), each +//! shares most methods with the generic case, and the UI needs the +//! platform-only calls (Steam's API key, the CS2 bridge, the split warning +//! loads) without downcasting. A match keeps every behaviour in sight in one +//! place and the adapter `Send + Sync` with no boxing. A user platform needs +//! no new code: it is [`AdapterKind::Generic`] with [`GenericConfig::user`]. +//! +//! Every method that talks to the backend takes `&dyn Backend` and blocks, so +//! the shell runs it on the job pool. Session state the TS modules keep in +//! module variables (ban checks, Roblox dead sessions, the Riot profile +//! cache, the CS2 bridge rows) lives behind a mutex inside the adapter. + +use super::generic::GenericConfig; +use super::riot::{self, RiotProfile, RiotProfileCache}; +use super::roblox::{self, RobloxSessions}; +use super::steam::api::{ApiKeyMemo, SteamLaunchConfig}; +use super::steam::cs2::Cs2Bridge; +use super::steam::warnings::{self as steam_warnings, BanFetch, BanPlan, BanSession, BanStep}; +use super::steam::{self, context_menu as steam_menu}; +use super::warnings::{WarningLoad, WarningPresentation}; +use super::{ + AddAccountResult, AddFlowStatus, PlatformAccount, PlatformProfileInfo, StartupSnapshot, + WarningLoadOptions, +}; +use crate::backend::Backend; +use crate::error::{BackendError, BackendResult}; +use crate::menu::platform::build_platform_context_menu; +use crate::menu::{ + actions::run_safe, MenuAction, PlatformTask, SubmenuLoader, TaskEffect, TaskOutcome, +}; +use crate::platforms::api::PlatformApi; +use crate::settings::AppSettings; +use crate::stores::ClientStores; +use crate::text::Text; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::sync::{Mutex, MutexGuard}; + +/// Which implementation backs a platform. +#[derive(Debug, Clone, PartialEq)] +pub enum AdapterKind { + Steam, + Riot, + Roblox, + /// Driven entirely by the shared `platform_*` commands. + Generic(GenericConfig), +} + +/// The session state of the TS modules. +#[derive(Debug, Default)] +pub struct AdapterState { + pub ban_session: BanSession, + pub api_key: ApiKeyMemo, + pub cs2: Cs2Bridge, + pub riot_profiles: RiotProfileCache, + pub roblox_sessions: RobloxSessions, +} + +/// What the menu builders read from the UI (`PlatformContextMenuCallbacks` +/// minus the actions, which are [`crate::menu::MenuCommand`]s now). +#[derive(Debug, Clone, Copy)] +pub struct MenuContext<'a> { + /// `getCurrentAccountId()`. + pub current_account_id: Option<&'a str>, + /// Steam's default games live in the client stores. + pub stores: &'a ClientStores, + /// The UI offers `openInputDialog` (rename). + pub input_dialog: bool, +} + +/// `PlatformAdapter`. +#[derive(Debug)] +pub struct PlatformAdapter { + id: String, + kind: AdapterKind, + state: Mutex, +} + +fn steam_config(settings: &AppSettings) -> SteamLaunchConfig { + SteamLaunchConfig::from_settings(&settings.platform_settings.steam) +} + +fn generic_accounts(config: &GenericConfig, raw: &Value) -> Vec { + raw.as_array() + .map(|rows| rows.iter().map(|row| config.to_account(row)).collect()) + .unwrap_or_default() +} + +impl PlatformAdapter { + pub fn new(id: impl Into, kind: AdapterKind) -> Self { + Self { + id: id.into(), + kind, + state: Mutex::new(AdapterState::default()), + } + } + + /// The adapter of a shipped platform, `None` for an unknown id. + pub fn shipped(id: &str) -> Option { + let kind = match id { + "steam" => AdapterKind::Steam, + "riot" => AdapterKind::Riot, + "roblox" => AdapterKind::Roblox, + other => AdapterKind::Generic(GenericConfig::shipped(other)?), + }; + Some(Self::new(id, kind)) + } + + /// `userAdapterLoader`: a platform from the user's descriptor folder. + pub fn user(id: &str, name: &str) -> Self { + Self::new(id, AdapterKind::Generic(GenericConfig::user(name))) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn kind(&self) -> &AdapterKind { + &self.kind + } + + /// The session state. A poisoned lock is recovered: the state is plain + /// data and stays usable. + pub fn state(&self) -> MutexGuard<'_, AdapterState> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn api<'a>(&'a self, backend: &'a dyn Backend) -> PlatformApi<'a> { + PlatformApi::new(backend, &self.id) + } + + /// `reloadAfterAdd`. + pub fn reload_after_add(&self) -> bool { + matches!(&self.kind, AdapterKind::Generic(config) if config.reload_after_add) + } + + /// `setAccountLabel` exists. + pub fn supports_account_labels(&self) -> bool { + matches!(&self.kind, AdapterKind::Generic(config) if config.supports_account_labels()) + } + + /// Whether `getCachedWarningStates` / `loadWarningStates` exist. + pub fn has_warnings(&self) -> bool { + matches!(self.kind, AdapterKind::Steam | AdapterKind::Roblox) + } + + // ------------------------------------------------------------ accounts + + /// Maps the raw `platform_get_accounts` list. + pub fn to_accounts(&self, raw: &Value) -> Vec { + match &self.kind { + AdapterKind::Steam => steam::to_steam_accounts(raw), + AdapterKind::Riot => { + let profiles = RiotProfile::list(raw); + self.state().riot_profiles.remember(&profiles); + profiles.iter().map(riot::to_riot_account).collect() + } + AdapterKind::Roblox => roblox::to_roblox_accounts(raw), + AdapterKind::Generic(config) => generic_accounts(config, raw), + } + } + + /// Maps the raw `platform_get_startup_snapshot` payload. + pub fn to_snapshot(&self, raw: &Value) -> StartupSnapshot { + match &self.kind { + AdapterKind::Steam => steam::to_steam_snapshot(raw), + AdapterKind::Riot => { + let (snapshot, profiles) = riot::to_riot_snapshot(raw); + self.state().riot_profiles.remember(&profiles); + snapshot + } + AdapterKind::Roblox => roblox::to_roblox_snapshot(raw), + AdapterKind::Generic(config) => StartupSnapshot { + accounts: raw + .get("accounts") + .map(|rows| generic_accounts(config, rows)) + .unwrap_or_default(), + current_account: raw + .get("currentAccount") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }, + } + } + + /// `loadAccounts`. + pub fn load_accounts(&self, backend: &dyn Backend) -> BackendResult> { + Ok(self.to_accounts(&self.api(backend).get_accounts()?)) + } + + /// `getCurrentAccount`. + pub fn current_account(&self, backend: &dyn Backend) -> BackendResult { + self.api(backend).get_current_account() + } + + /// `getStartupSnapshot`. + pub fn startup_snapshot(&self, backend: &dyn Backend) -> BackendResult { + Ok(self.to_snapshot(&self.api(backend).get_startup_snapshot()?)) + } + + /// `switchAccount`. Steam reads its launch settings; Roblox keeps its + /// dead-session marks in step with the answer. + pub fn switch_account( + &self, + backend: &dyn Backend, + account: &PlatformAccount, + settings: &AppSettings, + ) -> BackendResult<()> { + match &self.kind { + AdapterKind::Steam => { + steam::api::switch_account(backend, &account.username, &steam_config(settings)) + } + AdapterKind::Riot => self + .api(backend) + .switch_account(&account.id, &json!({}), None), + AdapterKind::Roblox => { + let result = self + .api(backend) + .switch_account(&account.id, &json!({}), None); + let mut state = self.state(); + match &result { + Ok(()) => state.roblox_sessions.clear_expired(&account.id), + Err(error) if roblox::is_session_expired_error(&error.message) => { + state.roblox_sessions.mark_expired(&account.id); + } + Err(_) => {} + } + result + } + AdapterKind::Generic(config) => { + let details = config + .mask_switch_log_id(&account.id) + .map(|masked| json!({ "accountId": masked })); + self.api(backend) + .switch_account(&account.id, &json!({}), details) + } + } + } + + // ------------------------------------------------------------ add flow + + /// `addAccount` (`createPlatformAddFlowHandlers`). + pub fn add_account( + &self, + backend: &dyn Backend, + settings: &AppSettings, + ) -> BackendResult { + let status = match &self.kind { + AdapterKind::Steam => { + steam::api::begin_account_setup(backend, &steam_config(settings))? + } + _ => self.api(backend).begin_setup(&json!({}))?, + }; + Ok(AddAccountResult { + setup_status: Some(status), + }) + } + + /// `pollAddFlow`. Roblox lifts a dead-session mark once a fresh cookie is + /// stored for the account. + pub fn poll_add_flow( + &self, + backend: &dyn Backend, + setup_id: &str, + ) -> BackendResult { + let status = self.api(backend).get_setup_status(setup_id)?; + if self.kind == AdapterKind::Roblox && status.state == "ready" { + if let Some(account_id) = status.account_id.as_deref().filter(|id| !id.is_empty()) { + self.state().roblox_sessions.clear_expired(account_id); + } + } + Ok(status) + } + + /// `cancelAddFlow`. + pub fn cancel_add_flow(&self, backend: &dyn Backend, setup_id: &str) -> BackendResult<()> { + self.api(backend).cancel_setup(setup_id) + } + + /// `setAccountLabel`; an error for a platform without labels. + pub fn set_account_label( + &self, + backend: &dyn Backend, + account_id: &str, + label: &str, + ) -> BackendResult<()> { + if !self.supports_account_labels() { + return Err(BackendError::new(format!( + "{} has no account labels", + self.id + ))); + } + self.api(backend).set_account_label(account_id, label) + } + + // ------------------------------------------------------------ menu + + /// `getContextMenuActions`. + pub fn context_menu_actions( + &self, + account: &PlatformAccount, + ctx: &MenuContext<'_>, + ) -> Vec { + match &self.kind { + AdapterKind::Steam => steam_menu::steam_context_menu_actions( + account, + ctx.current_account_id, + ctx.stores.default_game(&account.id), + ), + AdapterKind::Riot => { + let profile = self.state().riot_profiles.get(&account.id).cloned(); + riot::riot_context_menu_actions(account, profile.as_ref()) + } + AdapterKind::Roblox => roblox::roblox_context_menu_actions(account), + AdapterKind::Generic(config) => { + let base = if account.display_name.is_empty() { + &account.id + } else { + &account.display_name + }; + let trimmed = crate::js::trim(base); + let display = if trimmed.is_empty() { + account.id.clone() + } else { + trimmed.to_string() + }; + let mut forget = crate::menu::platform::ForgetConfig::keys( + &config.key("forgetConfirmTitle"), + &config.key("forgetConfirmMessage"), + &config.key("forget"), + &config.key("forgotAccount"), + ); + forget.toast_params = config.forget_toast_params(&display); + build_platform_context_menu( + &self.id, + account, + &config.copy_items(account), + &forget, + Some(display), + ) + } + } + } + + /// Fills a `submenuLoader` submenu. Runs on the job pool; the UI groups + /// the result with [`crate::menu::build_context_menu_items`]. + pub fn load_submenu( + &self, + loader: &SubmenuLoader, + backend: &dyn Backend, + ) -> BackendResult> { + match loader { + SubmenuLoader::SteamAccountGames { username, steam_id } => { + let games = backend.steam_get_account_games(steam_id)?; + Ok(steam_menu::account_games_submenu( + username, steam_id, &games, + )) + } + SubmenuLoader::SteamCopyableGames { + from_steam_id, + to_steam_id, + } => { + let games = backend.steam_get_copyable_games(from_steam_id, to_steam_id)?; + Ok(steam_menu::copyable_games_submenu( + from_steam_id, + to_steam_id, + &games, + )) + } + } + } + + /// Runs a menu task as a safe action (`createSafeContextAction`): the + /// effects to apply, in TS order, a failure turned into a toast. + pub fn run_task( + &self, + task: &PlatformTask, + backend: &dyn Backend, + settings: &AppSettings, + ) -> TaskOutcome { + let mut outcome = TaskOutcome::default(); + run_safe(&mut outcome, |outcome| { + self.task(task, backend, settings, outcome) + }); + outcome + } + + fn task( + &self, + task: &PlatformTask, + backend: &dyn Backend, + settings: &AppSettings, + outcome: &mut TaskOutcome, + ) -> BackendResult<()> { + match task { + PlatformTask::SteamSwitchMode { + username, + steam_id, + mode, + } => steam::api::switch_account_mode( + backend, + username, + steam_id, + mode, + &steam_config(settings), + ), + PlatformTask::SteamRunGame { + username, + steam_id, + app_id, + remember, + } => { + if let Some(game) = remember { + outcome.push(TaskEffect::SetDefaultGame { + steam_id: steam_id.clone(), + game: game.clone(), + }); + } + steam::api::switch_account_and_launch_game( + backend, + username, + steam_id, + app_id, + &steam_config(settings), + ) + } + PlatformTask::SteamClearDefaultGame { steam_id } => { + outcome.push(TaskEffect::ClearDefaultGame { + steam_id: steam_id.clone(), + }); + Ok(()) + } + PlatformTask::SteamOpenUserdata { steam_id } => backend.steam_open_userdata(steam_id), + PlatformTask::SteamClearBrowserCache => { + backend.steam_clear_browser_cache()?; + outcome.push(TaskEffect::Toast(Text::key( + "steam.clearedIntegratedBrowserCache", + ))); + Ok(()) + } + PlatformTask::SteamCopyGameSettings { + from_steam_id, + to_steam_id, + app_id, + game_name, + } => { + backend.steam_copy_game_settings(from_steam_id, to_steam_id, app_id)?; + outcome.push(TaskEffect::Toast(Text::with( + "steam.copiedSettingsToCurrent", + [("game", game_name.as_str())], + ))); + Ok(()) + } + PlatformTask::RiotCapture { + profile_id, + display_name, + } => { + backend.riot_capture_profile(profile_id)?; + outcome.push(TaskEffect::Toast(Text::with( + "riot.capturedSession", + [("profile", display_name.as_str())], + ))); + outcome.push(TaskEffect::RefreshAccounts); + Ok(()) + } + PlatformTask::Forget { + platform_id, + account_id, + toast, + } => { + if platform_id == "steam" { + outcome.push(TaskEffect::ClearDefaultGame { + steam_id: account_id.clone(), + }); + } + PlatformApi::new(backend, platform_id).forget_account(account_id)?; + if platform_id == "riot" { + self.state().riot_profiles.forget(account_id); + } + outcome.push(TaskEffect::Toast(toast.clone())); + outcome.push(TaskEffect::RemoveAccount(account_id.clone())); + Ok(()) + } + PlatformTask::SetAccountLabel { + account_id, label, .. + } => { + self.set_account_label(backend, account_id, label)?; + outcome.push(TaskEffect::RefreshAccounts); + Ok(()) + } + } + } + + // ------------------------------------------------------------ profiles + + /// `getProfileInfo` without the M2 caches: Steam and Roblox ask the + /// backend, Riot reads its profile cache, generic platforms have none. + pub fn profile_info( + &self, + backend: &dyn Backend, + account_id: &str, + ) -> BackendResult> { + match &self.kind { + AdapterKind::Steam => { + let profile = backend.steam_get_profile_info(account_id)?; + Ok(steam::to_platform_profile_info(profile.as_ref())) + } + AdapterKind::Riot => Ok(riot::riot_profile_info( + self.state().riot_profiles.get(account_id), + )), + AdapterKind::Roblox => { + let info = backend.roblox_get_profile_info(account_id)?; + Ok(roblox::to_roblox_profile_info(Some(&info))) + } + AdapterKind::Generic(_) => Ok(None), + } + } + + // ------------------------------------------------------------ warnings + + /// `getCachedWarningStates`. + pub fn cached_warning_states( + &self, + stores: &ClientStores, + ) -> BTreeMap { + match self.kind { + AdapterKind::Steam => steam_warnings::cached_warning_states(stores), + AdapterKind::Roblox => self.state().roblox_sessions.warning_map().warnings, + _ => BTreeMap::new(), + } + } + + /// Steam's load, first step (UI thread). `None` for other platforms. + pub fn steam_ban_plan( + &self, + stores: &ClientStores, + settings: &AppSettings, + accounts: &[PlatformAccount], + options: WarningLoadOptions, + now_ms: f64, + ) -> Option { + if self.kind != AdapterKind::Steam { + return None; + } + let state = self.state(); + Some(steam_warnings::plan( + &state.ban_session, + &state.api_key, + stores, + settings, + accounts, + options, + now_ms, + )) + } + + /// Steam's load, last step (UI thread), after + /// [`steam_warnings::fetch`] ran on the job pool. + pub fn steam_ban_finish( + &self, + plan: &BanPlan, + fetched: BanFetch, + stores: &mut ClientStores, + ) -> WarningLoad { + let mut state = self.state(); + let AdapterState { + ban_session, + api_key, + .. + } = &mut *state; + steam_warnings::finish(ban_session, api_key, plan, fetched, stores) + } + + /// `loadWarningStates` in one blocking call: Steam bans or the Roblox + /// session probe. `None` for platforms without warnings. + pub fn load_warning_states_blocking( + &self, + backend: &dyn Backend, + stores: &mut ClientStores, + settings: &AppSettings, + accounts: &[PlatformAccount], + options: WarningLoadOptions, + now_ms: f64, + ) -> Option { + match self.kind { + AdapterKind::Steam => { + let step = self.steam_ban_plan(stores, settings, accounts, options, now_ms)?; + Some(match step { + BanStep::Done(load) => load, + BanStep::Fetch(plan) => { + let fetched = steam_warnings::fetch(&plan, backend); + self.steam_ban_finish(&plan, fetched, stores) + } + }) + } + AdapterKind::Roblox => { + let needs_probe = self.state().roblox_sessions.needs_probe( + settings, + accounts, + options.force_refresh, + ); + if !needs_probe { + return Some(self.state().roblox_sessions.warning_map()); + } + let result = RobloxSessions::probe(backend); + Some(self.state().roblox_sessions.finish_probe(result)) + } + _ => None, + } + } + + // ------------------------------------------------------------ Steam key + + /// `hasApiKey`, memoized for the session. + pub fn steam_has_api_key(&self, backend: &dyn Backend) -> BackendResult { + let generation = { + let state = self.state(); + if let Some(known) = state.api_key.cached() { + return Ok(known); + } + state.api_key.generation() + }; + let value = backend.steam_has_api_key()?; + self.state().api_key.finish_read(generation, value); + Ok(value) + } + + /// `setApiKey`: stores the key and forgets the memo. + pub fn steam_set_api_key(&self, backend: &dyn Backend, key: &str) -> BackendResult<()> { + backend.steam_set_api_key(key)?; + self.state().api_key.invalidate(); + Ok(()) + } + + // ------------------------------------------------------------ toasts + + /// `getNoAccountsToastMessage`. + pub fn no_accounts_toast(&self) -> Option { + Some(match &self.kind { + AdapterKind::Steam => Text::key(steam::NO_ACCOUNTS_TOAST_KEY), + AdapterKind::Riot => Text::key(riot::NO_ACCOUNTS_TOAST_KEY), + AdapterKind::Roblox => Text::key(roblox::NO_ACCOUNTS_TOAST_KEY), + AdapterKind::Generic(config) => Text::with_optional( + config.no_accounts_toast_key.clone(), + config.message_params.clone(), + ), + }) + } + + /// `getNoAccountsHintMessage`. + pub fn no_accounts_hint(&self) -> Option { + match &self.kind { + AdapterKind::Roblox => Some(Text::key(roblox::NO_ACCOUNTS_HINT_KEY)), + AdapterKind::Generic(config) => config + .no_accounts_hint_key + .as_ref() + .map(|key| Text::with_optional(key.clone(), config.message_params.clone())), + _ => None, + } + } + + /// `getSwitchErrorToastMessage`. + pub fn switch_error_toast(&self, message: &str) -> Option { + match self.kind { + AdapterKind::Steam => steam::switch_error_toast(message), + AdapterKind::Roblox => roblox::switch_error_toast(message), + _ => None, + } + } + + /// `getLoadErrorToastMessage`. + pub fn load_error_toast(&self, message: &str) -> Option { + match self.kind { + AdapterKind::Steam => steam::load_error_toast(message), + _ => None, + } + } +} diff --git a/crates/accshift-app/src/platforms/add_flow.rs b/crates/accshift-app/src/platforms/add_flow.rs new file mode 100644 index 00000000..8a85ce1c --- /dev/null +++ b/crates/accshift-app/src/platforms/add_flow.rs @@ -0,0 +1,18 @@ +//! The add-account flow, ported from `src/lib/platforms/addFlow.ts`. + +use super::AddFlowStatus; +use crate::types::SetupStatus; + +/// `toPlatformAddFlowStatus`. The backend always sends every field; an empty +/// one is falsy in TS and becomes `None` here, which is how every caller +/// reads it (`status.accountId && ...`). +pub fn to_add_flow_status(setup_id: &str, payload: &SetupStatus) -> AddFlowStatus { + let optional = |value: &str| (!value.is_empty()).then(|| value.to_string()); + AddFlowStatus { + setup_id: setup_id.to_string(), + state: payload.state.clone(), + account_id: optional(&payload.account_id), + account_display_name: optional(&payload.account_display_name), + error_message: optional(&payload.error_message), + } +} diff --git a/crates/accshift-app/src/platforms/api.rs b/crates/accshift-app/src/platforms/api.rs new file mode 100644 index 00000000..e3107886 --- /dev/null +++ b/crates/accshift-app/src/platforms/api.rs @@ -0,0 +1,197 @@ +//! The shared `platform_*` calls, ported from `src/lib/platforms/platformApi.ts` +//! with the log redaction of `src/lib/shared/appLogger.ts` they rely on. +//! +//! A switch refused for want of the PIN comes back as an error whose +//! [`crate::BackendError::is_pin_locked`] is true; TS raises the lock screen +//! from here (`reportPinLockedError`), the egui shell does it from that flag. + +use super::add_flow::to_add_flow_status; +use super::AddFlowStatus; +use crate::backend::Backend; +use crate::error::BackendResult; +use serde_json::{json, Map, Value}; + +/// `REDACTED_KEYS` of the app logger, lowercase. +const REDACTED_KEYS: [&str; 14] = [ + "username", + "requestedusername", + "autologinuser", + "currentaccountfromloginusers", + "email", + "targetemail", + "currentaccount", + "steamid", + "accountid", + "profileid", + "targetprofileid", + "currentprofileid", + "uuid", + "userid", +]; + +fn mask_value(value: &Value) -> Value { + match value { + Value::String(text) => { + let units: Vec = text.encode_utf16().collect(); + if units.len() > 2 { + Value::String(format!("{}***", String::from_utf16_lossy(&units[..2]))) + } else { + Value::String("***".to_string()) + } + } + Value::Array(items) => Value::String(format!("[{} items]", items.len())), + _ => Value::String("***".to_string()), + } +} + +fn redact_object(object: &Map) -> Map { + object + .iter() + .map(|(key, value)| { + let redacted = if REDACTED_KEYS.contains(&key.to_lowercase().as_str()) { + mask_value(value) + } else if let Value::Object(inner) = value { + Value::Object(redact_object(inner)) + } else { + value.clone() + }; + (key.clone(), redacted) + }) + .collect() +} + +/// `serializeLogValue`: strings as is, objects redacted then JSON. +pub fn serialize_log_value(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Object(object) => Value::Object(redact_object(object)).to_string(), + other => other.to_string(), + } +} + +/// `logAppEvent`: failures are ignored so they never mask the action result. +pub fn log_app_event( + backend: &dyn Backend, + level: &str, + source: &str, + message: &str, + details: Option<&Value>, +) { + let details = details + .filter(|value| !value.is_null()) + .map(serialize_log_value); + let _ = backend.log_app_event(level, source, message, details.as_deref()); +} + +/// `createPlatformApi(platformId)`. +#[derive(Clone, Copy)] +pub struct PlatformApi<'a> { + backend: &'a dyn Backend, + platform_id: &'a str, +} + +/// `frontend.` with the first `-` made `_`, like `String.replace`. +pub fn log_prefix(platform_id: &str) -> String { + format!("frontend.{}", platform_id.replacen('-', "_", 1)) +} + +impl<'a> PlatformApi<'a> { + pub fn new(backend: &'a dyn Backend, platform_id: &'a str) -> Self { + Self { + backend, + platform_id, + } + } + + pub fn backend(&self) -> &'a dyn Backend { + self.backend + } + + /// `getAccounts`: the platform's raw account list. + pub fn get_accounts(&self) -> BackendResult { + self.backend.platform_get_accounts(self.platform_id) + } + + pub fn get_current_account(&self) -> BackendResult { + self.backend.platform_get_current_account(self.platform_id) + } + + pub fn get_startup_snapshot(&self) -> BackendResult { + self.backend.platform_get_startup_snapshot(self.platform_id) + } + + /// `switchAccount`, logged before and after. `log_details` defaults to + /// `{ accountId }`, redacted by the logger. + pub fn switch_account( + &self, + account_id: &str, + params: &Value, + log_details: Option, + ) -> BackendResult<()> { + let source = format!("{}.switch", log_prefix(self.platform_id)); + let details = log_details.unwrap_or_else(|| json!({ "accountId": account_id })); + log_app_event( + self.backend, + "info", + &source, + "Switch request started", + Some(&details), + ); + match self + .backend + .platform_switch_account(self.platform_id, account_id, params) + { + Ok(()) => { + log_app_event( + self.backend, + "info", + &source, + "Switch request completed", + Some(&details), + ); + Ok(()) + } + Err(error) => { + let mut failed = details.as_object().cloned().unwrap_or_default(); + failed.insert("error".to_string(), Value::String(error.message.clone())); + log_app_event( + self.backend, + "error", + &source, + "Switch request failed", + Some(&Value::Object(failed)), + ); + Err(error) + } + } + } + + pub fn begin_setup(&self, params: &Value) -> BackendResult { + let payload = self + .backend + .platform_begin_setup(self.platform_id, params)?; + Ok(to_add_flow_status(&payload.setup_id, &payload)) + } + + pub fn get_setup_status(&self, setup_id: &str) -> BackendResult { + let payload = self + .backend + .platform_get_setup_status(self.platform_id, setup_id)?; + Ok(to_add_flow_status(&payload.setup_id, &payload)) + } + + pub fn cancel_setup(&self, setup_id: &str) -> BackendResult<()> { + self.backend + .platform_cancel_setup(self.platform_id, setup_id) + } + + pub fn forget_account(&self, account_id: &str) -> BackendResult<()> { + self.backend + .platform_forget_account(self.platform_id, account_id) + } + + pub fn set_account_label(&self, account_id: &str, label: &str) -> BackendResult<()> { + self.backend + .platform_set_account_label(self.platform_id, account_id, label) + } +} diff --git a/crates/accshift-app/src/platforms/card.rs b/crates/accshift-app/src/platforms/card.rs new file mode 100644 index 00000000..de329d97 --- /dev/null +++ b/crates/accshift-app/src/platforms/card.rs @@ -0,0 +1,99 @@ +//! Card extension content, ported from `src/lib/shared/cardExtension.ts`: the +//! sections a card shows when it expands (the CS2 level and XP bar today). + +use super::warnings::{WarningChip, WarningTone}; +use crate::text::Text; + +/// `CardExtensionChipTone`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ChipTone { + Red, + Orange, + Blue, + Green, + Slate, +} + +impl From for ChipTone { + fn from(tone: WarningTone) -> Self { + match tone { + WarningTone::Red => ChipTone::Red, + WarningTone::Orange => ChipTone::Orange, + } + } +} + +/// `CardExtensionChip`. The TS `onClick` is a UI concern and stays there. +#[derive(Debug, Clone, PartialEq)] +pub struct CardChip { + pub text: Text, + pub tone: ChipTone, +} + +/// `CardExtensionLink`. +#[derive(Debug, Clone, PartialEq)] +pub struct CardLink { + pub label: Text, + pub url: String, +} + +/// `CardExtensionProgress`. +#[derive(Debug, Clone, PartialEq)] +pub struct CardProgress { + pub value: f64, + pub max: f64, + /// Small text right of the bar, e.g. "3579/5000". + pub label: Option, +} + +/// `CardExtensionSection`. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct CardSection { + pub title: Option, + pub text: Option, + pub lines: Vec, + pub link: Option, + pub chips: Vec, + pub progress: Option, + pub loading: bool, +} + +/// `AccountUsernameBadge`: the status icon next to the username. +#[derive(Debug, Clone, PartialEq)] +pub struct UsernameBadge { + /// `Green` or `Slate`. + pub tone: ChipTone, + pub label: Text, +} + +fn text_is_blank(text: &Option) -> bool { + match text { + None => true, + Some(Text::Raw(raw)) => raw.trim().is_empty(), + // A key always resolves to something. + Some(Text::Key { .. }) => false, + } +} + +/// `hasCardExtensionContent`. +pub fn has_card_extension_content(sections: &[CardSection]) -> bool { + sections.iter().any(|section| { + !text_is_blank(§ion.title) + || !text_is_blank(§ion.text) + || section.lines.iter().any(|line| !line.trim().is_empty()) + || !section.chips.is_empty() + || section.progress.is_some() + || section.loading + }) +} + +/// `warningChipsToExtensionChips`. +pub fn warning_chips_to_extension_chips(chips: &[WarningChip]) -> Vec { + chips + .iter() + .map(|chip| CardChip { + text: chip.text.clone(), + tone: chip.tone.into(), + }) + .collect() +} diff --git a/crates/accshift-app/src/platforms/descriptors.rs b/crates/accshift-app/src/platforms/descriptors.rs new file mode 100644 index 00000000..1dddcdd8 --- /dev/null +++ b/crates/accshift-app/src/platforms/descriptors.rs @@ -0,0 +1,233 @@ +//! The user's own platform descriptors, ported from +//! `src/lib/platforms/descriptors.ts` and the descriptor half of +//! `registry.ts` (`PlatformDescriptor`, `renderTemplate`, `placeholderFrom`). +//! +//! Every call that changes the descriptor folder hands its fresh report to +//! [`super::PlatformRegistry::register_user_platforms`] (`adopt`), so the tab +//! list and the settings list never disagree with the folder. + +use super::{PathPlaceholder, PlatformRegistry, RuntimeOs}; +use crate::backend::Backend; +use crate::error::BackendResult; +use crate::types::{DescriptorPreview, DryRunPlan, UserPlatformReport}; +use serde_json::Value; + +/// `PlatformDescriptor`: the part of a descriptor the UI reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformDescriptor { + pub id: String, + pub name: String, + /// Each system the descriptor covers, in declaration order, with the + /// template of its first `path` candidate. + pub os: Vec<(String, Option)>, +} + +/// The order `Object.keys` gives for a descriptor that went through the +/// engine: `BTreeMap` serializes Windows, macOS, Linux. +fn os_rank(os: &str) -> usize { + match os { + "windows" => 0, + "macos" => 1, + "linux" => 2, + _ => 3, + } +} + +impl PlatformDescriptor { + /// Reads a descriptor from JSON (a shipped file or a report entry). + /// `serde_json` maps are sorted, so the systems are put back in the + /// engine's order. + pub fn from_value(raw: &Value) -> Option { + let id = raw.get("id")?.as_str()?.to_string(); + let name = raw + .get("name") + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(); + let mut os: Vec<(String, Option)> = raw + .get("os") + .and_then(Value::as_object) + .map(|profiles| { + profiles + .iter() + .map(|(os, profile)| (os.clone(), first_path_template(profile))) + .collect() + }) + .unwrap_or_default(); + os.sort_by_key(|(name, _)| os_rank(name)); + Some(Self { id, name, os }) + } + + /// From the engine's own type. + pub fn from_core( + descriptor: &accshift_core::platforms::descriptor::Descriptor, + ) -> Option { + Self::from_value(&serde_json::to_value(descriptor).ok()?) + } + + /// The descriptors compiled into the engine (`SHIPPED_DESCRIPTORS`). + pub fn shipped() -> Vec { + let (loaded, _errors) = accshift_core::platforms::descriptor::load_embedded(); + loaded.iter().filter_map(Self::from_core).collect() + } + + /// The systems, as `supportedOs`. + pub fn supported_os(&self) -> Vec { + self.os + .iter() + .map(|(os, _)| super::to_runtime_os(Some(os))) + .collect() + } + + /// `placeholderFrom`: where the launcher usually lives, per system, from + /// the first place the descriptor says to look. + pub fn path_placeholder(&self) -> Option { + let entries: Vec<(RuntimeOs, String)> = self + .os + .iter() + .filter_map(|(os, template)| { + let os = super::to_runtime_os(Some(os)); + template + .as_deref() + .map(|template| (os, render_template(template, os))) + }) + .collect(); + (!entries.is_empty()).then_some(PathPlaceholder::PerOs(entries)) + } +} + +/// `profile.executable?.candidates?.find(kind === "path" && template)`. +fn first_path_template(profile: &Value) -> Option { + profile + .get("executable")? + .get("candidates")? + .as_array()? + .iter() + .find_map(|candidate| { + if candidate.get("kind").and_then(Value::as_str) != Some("path") { + return None; + } + candidate + .get("template") + .and_then(Value::as_str) + .filter(|template| !template.is_empty()) + .map(str::to_string) + }) +} + +/// One `${NAME}` or `${NAME(x)}` at the start of `rest`: its inner text and +/// byte length. +fn variable_at(rest: &str) -> Option<(&str, usize)> { + let inner = rest.strip_prefix("${")?; + let word = inner + .char_indices() + .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_')) + .map_or(inner.len(), |(index, _)| index); + if word == 0 { + return None; + } + let mut end = word; + let after = &inner[word..]; + if let Some(args) = after.strip_prefix('(') { + let close = args.find(')')?; + end = word + 1 + close + 1; + } + inner[end..] + .starts_with('}') + .then(|| (&inner[..end], 2 + end + 1)) +} + +/// `renderTemplate`: `${LOCALAPPDATA}` is `%LOCALAPPDATA%` on Windows and +/// `$LOCALAPPDATA` elsewhere, with the system's own separator. +pub fn render_template(template: &str, os: RuntimeOs) -> String { + let mut expanded = String::with_capacity(template.len()); + let mut index = 0; + while index < template.len() { + let rest = &template[index..]; + if let Some((name, length)) = variable_at(rest) { + if os == RuntimeOs::Windows { + expanded.push('%'); + expanded.push_str(name); + expanded.push('%'); + } else { + expanded.push('$'); + expanded.push_str(name); + } + index += length; + continue; + } + let c = rest.chars().next().unwrap_or_default(); + expanded.push(c); + index += c.len_utf8(); + } + if os == RuntimeOs::Windows { + expanded.replace('/', "\\") + } else { + expanded.replace('\\', "/") + } +} + +/// `adopt`: the report's descriptors become the user platforms. +fn adopt(registry: &mut PlatformRegistry, report: UserPlatformReport) -> UserPlatformReport { + let descriptors: Vec = report + .loaded + .iter() + .filter_map(PlatformDescriptor::from_core) + .collect(); + registry.register_user_platforms(&descriptors); + report +} + +/// `reloadUserPlatforms`. +pub fn reload_user_platforms( + registry: &mut PlatformRegistry, + backend: &dyn Backend, +) -> BackendResult { + Ok(adopt(registry, backend.reload_user_platforms()?)) +} + +/// `selectDescriptorFile`: `None` when the user cancelled or the dialog +/// failed, both of which mean "leave everything alone". +pub fn select_descriptor_file(backend: &dyn Backend) -> Option { + backend.descriptor_select_file().ok() +} + +/// `previewDescriptorFile`. +pub fn preview_descriptor_file( + backend: &dyn Backend, + path: &str, +) -> BackendResult { + backend.descriptor_preview_file(path) +} + +/// `installDescriptorFile`. +pub fn install_descriptor_file( + registry: &mut PlatformRegistry, + backend: &dyn Backend, + path: &str, +) -> BackendResult { + Ok(adopt(registry, backend.descriptor_install_file(path)?)) +} + +/// `removeDescriptor`. +pub fn remove_descriptor( + registry: &mut PlatformRegistry, + backend: &dyn Backend, + platform_id: &str, +) -> BackendResult { + Ok(adopt(registry, backend.descriptor_remove(platform_id)?)) +} + +/// `openDescriptorsFolder`. +pub fn open_descriptors_folder(backend: &dyn Backend) -> BackendResult<()> { + backend.open_descriptors_folder() +} + +/// `dryRunSwitch`. +pub fn dry_run_switch( + backend: &dyn Backend, + platform_id: &str, + account_id: &str, +) -> BackendResult { + backend.platform_dry_run(platform_id, account_id) +} diff --git a/crates/accshift-app/src/platforms/generic.rs b/crates/accshift-app/src/platforms/generic.rs new file mode 100644 index 00000000..76608a40 --- /dev/null +++ b/crates/accshift-app/src/platforms/generic.rs @@ -0,0 +1,255 @@ +//! Platforms driven entirely by the shared `platform_*` commands, ported from +//! `src/lib/platforms/genericAdapter.ts` and the adapters built on it: +//! `battle-net`, `ubisoft`, `epic`, `gog`, `jagex`, `discord` (`*/adapter.ts`) +//! and a platform from a user descriptor (`userAdapterLoader` in +//! `registry.ts`). +//! +//! The TS factory takes closures (`toAccount`, `copyItems`, `maskSwitchLogId`, +//! `forgetToastParams`); the closed set of them becomes [`GenericFlavor`]. + +use super::PlatformAccount; +use crate::i18n::TranslationValue; +use crate::menu::platform::CopyItemConfig; +use crate::time::unix_ms_to_seconds; +use serde_json::Value; + +/// The per-platform behaviour the TS closures carried. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GenericFlavor { + /// `GenericRawAccount` (`accountId`, `label`, `lastUsedAt`), one + /// account-id copy entry. + Default, + /// `BattleNetRawAccount`: email id, battle tag label, masked switch log, + /// username and email copy entries, `{ email }` forget toast, no labels. + BattleNet, + /// `UbisoftRawAccount`: uuid id, shortened uuid label, uuid copy entry. + Ubisoft, +} + +/// `GenericAdapterConfig`. +#[derive(Debug, Clone, PartialEq)] +pub struct GenericConfig { + /// i18n key prefix (Battle.net keys use `battlenet`). + pub i18n_prefix: String, + pub reload_after_add: bool, + pub no_accounts_toast_key: String, + pub no_accounts_hint_key: Option, + /// Params for the no-accounts toast and hint (a user platform's name). + pub message_params: Option>, + pub flavor: GenericFlavor, +} + +impl GenericConfig { + fn simple(id: &str, toast: &str, hint: Option<&str>, reload_after_add: bool) -> Self { + Self { + i18n_prefix: id.to_string(), + reload_after_add, + no_accounts_toast_key: toast.to_string(), + no_accounts_hint_key: hint.map(str::to_string), + message_params: None, + flavor: GenericFlavor::Default, + } + } + + /// The config of a shipped generic platform, `None` for any other id. + pub fn shipped(id: &str) -> Option { + Some(match id { + "battle-net" => Self { + i18n_prefix: "battlenet".to_string(), + flavor: GenericFlavor::BattleNet, + ..Self::simple(id, "toast.noBattleNetAccountsFound", None, false) + }, + "ubisoft" => Self { + flavor: GenericFlavor::Ubisoft, + ..Self::simple(id, "toast.noUbisoftAccountsFound", None, false) + }, + "epic" => Self::simple( + id, + "toast.noEpicAccountsFound", + Some("epic.noAccountsHint"), + true, + ), + "gog" => Self::simple( + id, + "toast.noGogAccountsFound", + Some("gog.noAccountsHint"), + true, + ), + "jagex" => Self::simple( + id, + "toast.noJagexAccountsFound", + Some("jagex.noAccountsHint"), + true, + ), + "discord" => Self::simple( + id, + "toast.noDiscordAccountsFound", + Some("discord.noAccountsHint"), + true, + ), + _ => return None, + }) + } + + /// `userAdapterLoader`: the shared `platform.*` wording with the + /// descriptor's name filled in. + pub fn user(name: &str) -> Self { + Self { + i18n_prefix: "platform".to_string(), + reload_after_add: true, + no_accounts_toast_key: "app.noAccountsFound".to_string(), + no_accounts_hint_key: Some("app.noAccountsHint".to_string()), + message_params: Some(vec![("platform".to_string(), name.into())]), + flavor: GenericFlavor::Default, + } + } + + /// `` `${prefix}.${suffix}` ``. + pub fn key(&self, suffix: &str) -> String { + format!("{}.{suffix}", self.i18n_prefix) + } + + /// `supportsAccountLabels !== false`. + pub fn supports_account_labels(&self) -> bool { + self.flavor != GenericFlavor::BattleNet + } + + /// The account mapping of the flavor. + pub fn to_account(&self, raw: &Value) -> PlatformAccount { + match self.flavor { + GenericFlavor::Default => default_to_account(raw), + GenericFlavor::BattleNet => to_battle_net_account(raw), + GenericFlavor::Ubisoft => to_ubisoft_account(raw), + } + } + + /// `copyItems(account)`, or the default account-id entry. + pub fn copy_items(&self, account: &PlatformAccount) -> Vec { + match self.flavor { + GenericFlavor::Default => vec![CopyItemConfig::new( + "accountId", + account.id.clone(), + &self.key("copyLabelAccountId"), + )], + GenericFlavor::BattleNet => { + let username = + first_non_empty(&[&account.display_name, &account.username, &account.id]); + let trimmed = crate::js::trim(username); + let username = if trimmed.is_empty() { + account.id.as_str() + } else { + trimmed + }; + vec![ + CopyItemConfig::new("username", username, "battlenet.copyLabelUsername"), + CopyItemConfig::new("email", account.id.clone(), "battlenet.copyLabelEmail"), + ] + } + GenericFlavor::Ubisoft => vec![CopyItemConfig::new( + "uuid", + account.id.clone(), + "ubisoft.copyLabelUuid", + )], + } + } + + /// `forgetToastParams(display)`. + pub fn forget_toast_params(&self, display: &str) -> Vec<(String, TranslationValue)> { + match self.flavor { + GenericFlavor::BattleNet => vec![("email".to_string(), display.into())], + _ => Vec::new(), + } + } + + /// `maskSwitchLogId`, `None` when the flavor logs the id as is. + pub fn mask_switch_log_id(&self, account_id: &str) -> Option { + (self.flavor == GenericFlavor::BattleNet).then(|| mask_email(account_id)) + } +} + +fn first_non_empty<'a>(values: &[&'a String]) -> &'a str { + values + .iter() + .find(|value| !value.is_empty()) + .map_or("", |value| value.as_str()) +} + +fn str_field<'a>(raw: &'a Value, name: &str) -> &'a str { + raw.get(name).and_then(Value::as_str).unwrap_or_default() +} + +fn number_field(raw: &Value, name: &str) -> Option { + raw.get(name).and_then(Value::as_f64) +} + +/// `defaultToAccount` on a `GenericRawAccount`. +pub fn default_to_account(raw: &Value) -> PlatformAccount { + let id = str_field(raw, "accountId"); + let label = str_field(raw, "label"); + PlatformAccount { + id: id.to_string(), + display_name: if label.is_empty() { id } else { label }.to_string(), + username: id.to_string(), + last_login_at_sec: unix_ms_to_seconds(number_field(raw, "lastUsedAt")), + } +} + +/// `getBattleNetDisplayName`: the email's local part. +fn battle_net_display_name(email: &str) -> String { + let trimmed = crate::js::trim(email); + let candidate = crate::js::trim(trimmed.split('@').next().unwrap_or_default()); + if candidate.is_empty() { + trimmed + } else { + candidate + } + .to_string() +} + +/// `toBattleNetAccount`: the battle tag name, else the email's local part. +pub fn to_battle_net_account(raw: &Value) -> PlatformAccount { + let email = str_field(raw, "email"); + let battle_tag = crate::js::trim(str_field(raw, "battleTag")); + let display_name = if battle_tag.is_empty() { + battle_net_display_name(email) + } else { + let name = crate::js::trim(battle_tag.split('#').next().unwrap_or_default()); + if name.is_empty() { battle_tag } else { name }.to_string() + }; + PlatformAccount { + id: email.to_string(), + display_name, + username: String::new(), + last_login_at_sec: unix_ms_to_seconds(number_field(raw, "lastLoginAt")), + } +} + +/// `maskEmail`: the first three UTF-16 units of the local part, then an +/// ellipsis, so raw emails stay out of log files. +pub fn mask_email(email: &str) -> String { + let local: Vec = email + .split('@') + .next() + .unwrap_or_default() + .encode_utf16() + .collect(); + let kept = String::from_utf16_lossy(&local[..local.len().min(3)]); + format!("{kept}\u{2026}") +} + +/// `toUbisoftAccount`: the label, else the uuid's first group. +pub fn to_ubisoft_account(raw: &Value) -> PlatformAccount { + let uuid = str_field(raw, "uuid"); + let label = crate::js::trim(str_field(raw, "label")); + let display_name = if label.is_empty() { + uuid.split('-').next().unwrap_or(uuid).to_string() + } else { + label.to_string() + }; + PlatformAccount { + id: uuid.to_string(), + display_name, + username: String::new(), + last_login_at_sec: unix_ms_to_seconds(number_field(raw, "lastUsedAt")), + } +} diff --git a/crates/accshift-app/src/platforms/icons.rs b/crates/accshift-app/src/platforms/icons.rs new file mode 100644 index 00000000..e87b42f5 --- /dev/null +++ b/crates/accshift-app/src/platforms/icons.rs @@ -0,0 +1,56 @@ +//! Fill-style 24x24 SVG paths for platform logos, ported from +//! `src/lib/shared/platformIcons.ts` (generated from it, do not edit by hand). +//! The titlebar tabs and the personas editor share these; inline copies drift. + +/// `PLATFORM_ICON_PATHS`, as `(platform id, path data)`. +pub const PLATFORM_ICON_PATHS: [(&str, &str); 9] = [ + ( + "steam", + "M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z", + ), + ( + "riot", + "M13.458.86 0 7.093l3.353 12.761 2.552-.313-.701-8.024.838-.373 1.447 8.202 4.361-.535-.775-8.857.83-.37 1.591 9.025 4.412-.542-.849-9.708.84-.374 1.74 9.87L24 17.318V3.5Zm.316 19.356.222 1.256L24 23.14v-4.18l-10.22 1.256Z", + ), + ( + "battle-net", + "M18.94 8.296C15.9 6.892 11.534 6 7.426 6.332c.206-1.36.714-2.308 1.548-2.508 1.148-.275 2.4.48 3.594 1.854.782.102 1.71.28 2.355.429C12.747 2.013 9.828-.282 7.607.565c-1.688.644-2.553 2.97-2.448 6.094-2.2.468-3.915 1.3-5.013 2.495-.056.065-.181.227-.137.305.034.058.146-.008.194-.04 1.274-.89 2.904-1.373 5.027-1.676.303 3.333 1.713 7.56 4.055 10.952-1.28.502-2.356.536-2.946-.087-.812-.856-.784-2.318-.19-4.04a26.764 26.764 0 0 1-.807-2.254c-2.459 3.934-2.986 7.61-1.143 9.11 1.402 1.14 3.847.725 6.502-.926 1.505 1.672 3.083 2.74 4.667 3.094.084.015.287.043.332-.034.034-.06-.08-.124-.131-.149-1.408-.657-2.64-1.828-3.964-3.515 2.735-1.929 5.691-5.263 7.457-8.988 1.076.86 1.64 1.773 1.398 2.595-.336 1.131-1.615 1.84-3.403 2.185a27.697 27.697 0 0 1-1.548 1.826c4.634.16 8.08-1.22 8.458-3.565.286-1.786-1.295-3.696-4.053-5.17.696-2.139.832-4.04.346-5.588-.029-.08-.106-.27-.196-.27-.068 0-.067.13-.063.187.135 1.547-.263 3.2-1.062 5.19zm-8.533 9.869c-1.96-3.145-3.09-6.849-3.082-10.594 3.702-.124 7.474.748 10.714 2.627-1.743 3.269-4.385 6.1-7.633 7.966h.001z", + ), + ( + "ubisoft", + "M23.561 11.988C23.301-.304 6.954-4.89.656 6.634c.282.206.661.477.943.672a11.747 11.747 0 00-.976 3.067 11.885 11.885 0 00-.184 2.071C.439 18.818 5.621 24 12.005 24c6.385 0 11.556-5.17 11.556-11.556v-.455zm-20.27 2.06c-.152 1.246-.054 1.636-.054 1.788l-.282.098c-.108-.206-.37-.932-.488-1.908C2.163 10.308 4.7 6.96 8.57 6.33c3.544-.52 6.937 1.68 7.728 4.758l-.282.098c-.087-.087-.228-.336-.77-.878-4.281-4.281-11.002-2.32-11.956 3.74zm11.002 2.081a3.145 3.145 0 01-2.59 1.355 3.15 3.15 0 01-3.155-3.155 3.159 3.159 0 012.927-3.144c1.018-.043 1.972.51 2.416 1.398a2.58 2.58 0 01-.455 2.95c.293.205.575.4.856.595zm6.58.12c-1.669 3.782-5.106 5.766-8.77 5.712-7.034-.347-9.083-8.466-4.38-11.393l.207.206c-.076.108-.358.325-.791 1.182-.51 1.041-.672 2.081-.607 2.732.369 5.67 8.314 6.83 11.045 1.214C21.057 8.217 11.822.401 3.626 6.374l-.184-.184C5.599 2.808 9.816 1.3 13.837 2.309c6.147 1.55 9.453 7.956 7.035 13.94z", + ), + ( + "roblox", + "M18.926 23.998 0 18.892 5.075.002 24 5.108ZM15.348 10.09l-5.282-1.453-1.414 5.273 5.282 1.453z", + ), + ( + "gog", + "M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z", + ), + ( + "jagex", + "M7.8477 22.727 17.5163 3.4675h2.9257L16.1593 11.994l4.2771 8.5263H15.0566l1.0957 2.195h7.8477L18.6179 12.0001 24 1.285 16.1523 1.273l-.7672 1.5371-8.9025 17.7225h-2.9251l4.2819-8.5268L3.5639 3.4808h5.3789L7.8477 1.285H0l5.3819 10.7152L0 22.7153Z", + ), + ( + "discord", + "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z", + ), + ( + "epic", + "M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z", + ), +]; + +/// The path data of a platform logo, `None` for a platform without one. +pub fn platform_icon_path(platform_id: &str) -> Option<&'static str> { + PLATFORM_ICON_PATHS + .iter() + .find(|(id, _)| *id == platform_id) + .map(|(_, path)| *path) +} + +/// Two-silhouette "identities" glyph for the personas tab (Material "group"). +pub const PERSONAS_ICON_PATH: &str = "M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"; + +pub const PERSONAS_ACCENT: &str = "#a855f7"; diff --git a/crates/accshift-app/src/platforms/mod.rs b/crates/accshift-app/src/platforms/mod.rs new file mode 100644 index 00000000..ad094ed5 --- /dev/null +++ b/crates/accshift-app/src/platforms/mod.rs @@ -0,0 +1,297 @@ +//! The platform model: what the Svelte front-end computes in TypeScript +//! before anything reaches a component, so the egui UI only draws. +//! +//! Ported from `src/lib/shared/platform.ts` (the types below), the registry +//! and adapters under `src/lib/platforms/`, and the shared helpers they use. +//! +//! - [`registry::PlatformRegistry`] holds the platform list (shipped plus the +//! user's descriptors) and one [`adapter::PlatformAdapter`] per platform. +//! - [`adapter::PlatformAdapter`] holds account shapes, switch, add flow, labels, +//! context menu entries, warnings and toast wording for one platform. +//! - [`steam`], [`riot`], [`roblox`] and [`generic`] hold the per-platform pieces. +//! - [`warnings`], [`card`], [`path_error`] and [`icons`] hold shared models. +//! +//! Every user-facing string is a [`crate::text::Text`] (an i18n key plus +//! parameters) resolved at draw time. Every backend call goes through +//! [`crate::Backend`] and blocks, so the shell runs them on the job pool. + +pub mod adapter; +pub mod add_flow; +pub mod api; +pub mod card; +pub mod descriptors; +pub mod generic; +pub mod icons; +pub mod path_error; +pub mod registry; +pub mod riot; +pub mod roblox; +pub mod steam; +pub mod warnings; + +use crate::text::Text; +use serde::{Deserialize, Serialize}; + +pub use crate::folders::FolderSection as DisplaySection; +pub use adapter::PlatformAdapter; +pub use registry::{PlatformDef, PlatformRegistry}; + +/// `PlatformAccount`: one account as every platform hands it to the UI. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlatformAccount { + pub id: String, + pub display_name: String, + pub username: String, + /// Unix SECONDS. Steam reads seconds out of `loginusers.vdf`, every other + /// platform stamps milliseconds, so each adapter converts once on its way + /// here and the unit is carried by the name. + pub last_login_at_sec: Option, +} + +/// `getStartupSnapshot()`: accounts and the current one in one call. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct StartupSnapshot { + pub accounts: Vec, + pub current_account: String, +} + +/// `RuntimeOs`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RuntimeOs { + Windows, + Linux, + Macos, + Unknown, +} + +impl RuntimeOs { + pub fn as_str(self) -> &'static str { + match self { + RuntimeOs::Windows => "windows", + RuntimeOs::Linux => "linux", + RuntimeOs::Macos => "macos", + RuntimeOs::Unknown => "unknown", + } + } +} + +/// `toRuntimeOs`: the backend's OS name, `Unknown` for any other. +pub fn to_runtime_os(value: Option<&str>) -> RuntimeOs { + match value { + Some("windows") => RuntimeOs::Windows, + Some("linux") => RuntimeOs::Linux, + Some("macos") => RuntimeOs::Macos, + _ => RuntimeOs::Unknown, + } +} + +/// `PathPlaceholder`: one hint, or one per system. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathPlaceholder { + Text(String), + /// In the order the entries were declared. + PerOs(Vec<(RuntimeOs, String)>), +} + +impl PathPlaceholder { + fn for_os(&self, os: RuntimeOs) -> Option<&str> { + match self { + PathPlaceholder::Text(text) => Some(text), + PathPlaceholder::PerOs(entries) => entries + .iter() + .find(|(entry_os, _)| *entry_os == os) + .map(|(_, text)| text.as_str()), + } + } +} + +/// `resolvePathPlaceholder`: the system's own hint, else the Windows, Linux +/// then macOS one, else empty. +pub fn resolve_path_placeholder(placeholder: Option<&PathPlaceholder>, os: RuntimeOs) -> String { + let Some(placeholder) = placeholder else { + return String::new(); + }; + [os, RuntimeOs::Windows, RuntimeOs::Linux, RuntimeOs::Macos] + .into_iter() + .find_map(|candidate| placeholder.for_os(candidate)) + .unwrap_or_default() + .to_string() +} + +/// `profileRefresh` capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ProfileRefresh { + pub avatars: bool, + pub bans: bool, +} + +/// `PlatformSettingsSchema`: defaults and the sanitizer of the platform's +/// `platformSettings[id]` entry. Only Steam has one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlatformSettingsSchema { + Steam, +} + +impl PlatformSettingsSchema { + /// `defaults()`, as the JSON the store holds. + pub fn defaults(self) -> serde_json::Value { + match self { + PlatformSettingsSchema::Steam => steam::settings_schema::defaults_value(), + } + } + + /// `sanitize(raw, legacyRoot)`, as the JSON the store holds. + pub fn sanitize( + self, + raw: Option<&serde_json::Value>, + legacy_root: Option<&serde_json::Map>, + ) -> serde_json::Value { + match self { + PlatformSettingsSchema::Steam => { + steam::settings_schema::sanitize_value(raw, legacy_root) + } + } + } +} + +/// `PlatformCapabilities`: what the app layer may offer for a platform, +/// without loading its adapter. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PlatformCapabilities { + /// A multi-select bulk edit bar (titlebar button plus bar). The bar + /// itself is a UI component. + pub bulk_edit: bool, + /// Takes part in the settings "refresh now" actions. + pub profile_refresh: Option, + /// Account cards can show a username line under the display name. + pub account_usernames: bool, + /// What an account with no known last login reads as. + pub last_login_unknown_key: Option<&'static str>, + /// Prefetch the profile right after a completed add flow. + pub prime_profile_after_add: bool, + /// Has account warning states worth rechecking on reloads. + pub account_warnings: bool, + /// Client-storage targets holding this platform's account data; an + /// external change to one triggers an account reload on this tab. + pub external_data_stores: Vec<&'static str>, + /// The `platformSettings[id]` schema. + pub settings: Option, +} + +/// `PlatformProfileInfo`. `None` from a fetch means "no data, keep the avatar +/// on screen"; `avatar_url: None` means the profile has no avatar. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PlatformProfileInfo { + pub avatar_url: Option, + pub display_name: Option, + pub avatar_loading: bool, +} + +/// `CachedPlatformProfile`. `url: None` is a cached "no avatar" result that +/// still suppresses refetches. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CachedPlatformProfile { + pub url: Option, + pub display_name: Option, + pub expired: bool, +} + +/// `PlatformAddFlowState`, plus whatever string a newer backend sends. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AddFlowState { + WaitingForClient, + WaitingForLogin, + /// Another accshift operation holds the cross-process lock. Non-terminal. + Busy, + Capturing, + Ready, + Failed, + Other(String), +} + +impl AddFlowState { + pub fn parse(state: &str) -> Self { + match state { + "waiting_for_client" => AddFlowState::WaitingForClient, + "waiting_for_login" => AddFlowState::WaitingForLogin, + "busy" => AddFlowState::Busy, + "capturing" => AddFlowState::Capturing, + "ready" => AddFlowState::Ready, + "failed" => AddFlowState::Failed, + other => AddFlowState::Other(other.to_string()), + } + } +} + +/// `PlatformAddFlowStatus`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddFlowStatus { + pub setup_id: String, + /// The raw state string, as TS keeps it. + pub state: String, + pub account_id: Option, + pub account_display_name: Option, + pub error_message: Option, +} + +impl AddFlowStatus { + pub fn state(&self) -> AddFlowState { + AddFlowState::parse(&self.state) + } +} + +/// `PlatformAddAccountResult`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AddAccountResult { + pub setup_status: Option, +} + +/// `WarningRefreshOutcome`: how a forced warning refresh ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WarningRefreshOutcome { + /// Every requested account came back. + Complete { + checked: usize, + }, + /// The fetch ran but some accounts are missing from the answer. + Partial { + checked: usize, + requested: usize, + }, + Failed, + /// Nothing was fetched because the platform lacks its API key. + NoApiKey, +} + +/// `PlatformWarningLoadOptions` minus the translator (texts are keys). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WarningLoadOptions { + pub force_refresh: bool, + pub silent: bool, +} + +impl Default for WarningLoadOptions { + /// TS defaults: not forced, silent. + fn default() -> Self { + Self { + force_refresh: false, + silent: true, + } + } +} + +/// `PlatformBulkEditResult`: the counts the app layer reads. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PlatformBulkEditResult { + pub succeeded: usize, + pub failed: Vec, +} + +/// A toast an adapter asks for, with its kind. +#[derive(Debug, Clone, PartialEq)] +pub struct ToastRequest { + pub text: Text, + pub error: bool, +} diff --git a/crates/accshift-app/src/platforms/path_error.rs b/crates/accshift-app/src/platforms/path_error.rs new file mode 100644 index 00000000..cffc8ce3 --- /dev/null +++ b/crates/accshift-app/src/platforms/path_error.rs @@ -0,0 +1,71 @@ +//! A rejected launcher path, ported from `src/lib/shared/platformPathError.ts`. +//! +//! `platform_set_path` rejects a folder with a machine-readable code, a `|`, +//! then an English fallback (`coded_path_error` in +//! `crates/accshift-core/src/platforms/steam/mod.rs`). Matching on the code +//! keeps the mapping stable when the English wording changes. + +use crate::text::Text; + +/// `PATH_ERROR_MESSAGE_KEYS`. +pub const PATH_ERROR_MESSAGE_KEYS: [(&str, &str); 3] = [ + ( + "steam_path_never_signed_in", + "settings.pathSteamNeverSignedIn", + ), + ("steam_path_not_steam", "settings.pathNotSteamFolder"), + ("steam_path_not_a_directory", "settings.pathNotADirectory"), +]; + +/// `PlatformPathError`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformPathError { + /// The backend code, `None` when the rejection carried none. + pub code: Option, + /// English text after the code, or the whole message without one. + pub fallback: String, +} + +/// `CODE_RE`: `^[a-z][a-z0-9_]*$`. Codes are lowercase snake_case, so an +/// English message never looks like one. +fn is_code(value: &str) -> bool { + let mut bytes = value.bytes(); + bytes.next().is_some_and(|first| first.is_ascii_lowercase()) + && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +/// `parsePlatformPathError` on the rejection's message. +pub fn parse_platform_path_error(raw: &str) -> PlatformPathError { + let Some(separator) = raw.find('|') else { + return PlatformPathError { + code: None, + fallback: raw.to_string(), + }; + }; + let code = &raw[..separator]; + if !is_code(code) { + return PlatformPathError { + code: None, + fallback: raw.to_string(), + }; + } + PlatformPathError { + code: Some(code.to_string()), + fallback: raw[separator + 1..].to_string(), + } +} + +/// `platformPathErrorMessage`: a known code becomes its translated message, +/// anything else keeps the caller's generic wording. +pub fn platform_path_error_message(raw: &str, generic_message: Text) -> Text { + let parsed = parse_platform_path_error(raw); + parsed + .code + .as_deref() + .and_then(|code| { + PATH_ERROR_MESSAGE_KEYS + .iter() + .find(|(known, _)| *known == code) + }) + .map_or(generic_message, |(_, key)| Text::key(*key)) +} diff --git a/crates/accshift-app/src/platforms/registry.rs b/crates/accshift-app/src/platforms/registry.rs new file mode 100644 index 00000000..2b0814dc --- /dev/null +++ b/crates/accshift-app/src/platforms/registry.rs @@ -0,0 +1,317 @@ +//! The platform list, ported from `src/lib/platforms/registry.ts`. +//! +//! A platform's look (accent, settings tab, capabilities) comes from +//! [`chrome`]; its name, systems and launcher path come from its descriptor +//! when it has one. Declaration order is display order. User descriptors +//! append generic platforms that disappear with their file. + +use super::adapter::PlatformAdapter; +use super::descriptors::PlatformDescriptor; +use super::{ + PathPlaceholder, PlatformCapabilities, PlatformSettingsSchema, ProfileRefresh, RuntimeOs, +}; +use crate::storage::{ + CLIENT_STORE_ROBLOX_PROFILE_CACHE, CLIENT_STORE_STEAM_BAN_CHECK_STATE, + CLIENT_STORE_STEAM_BAN_INFO_CACHE, CLIENT_STORE_STEAM_PROFILE_CACHE, + STORAGE_TARGET_EPIC_SNAPSHOTS, STORAGE_TARGET_RIOT_SNAPSHOTS, STORAGE_TARGET_UBISOFT_SNAPSHOTS, +}; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// `PlatformDef`. The TS `settingsComponent` and the bulk edit bar loader are +/// UI and stay with the egui crate; `settings_tab_key` names the tab. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformDef { + pub id: String, + pub name: String, + /// `#rrggbb`, or `hsl(H 62% 52%)` for a platform nobody picked one for. + pub accent: String, + pub implemented: bool, + pub supported_os: Vec, + pub settings_tab_key: Option<&'static str>, + pub path_label_key: Option<&'static str>, + pub path_placeholder: Option, + pub capabilities: PlatformCapabilities, + /// Built from the user's descriptor folder rather than shipped. + pub user_provided: bool, +} + +impl PlatformDef { + pub fn supports(&self, os: RuntimeOs) -> bool { + self.supported_os.contains(&os) + } +} + +/// `PlatformChrome`: what a platform looks like, as opposed to what it does. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PlatformChrome { + pub name: Option<&'static str>, + pub accent: &'static str, + pub supported_os: Option>, + pub settings_tab_key: Option<&'static str>, + pub path_label_key: Option<&'static str>, + pub path_placeholder: Option, + pub capabilities: PlatformCapabilities, +} + +/// The shipped platform ids in display order (`PLATFORM_CHROME` keys). +pub const CHROME_ORDER: [&str; 9] = [ + "steam", + "riot", + "battle-net", + "ubisoft", + "roblox", + "epic", + "gog", + "jagex", + "discord", +]; + +fn per_os(entries: &[(RuntimeOs, &str)]) -> Option { + Some(PathPlaceholder::PerOs( + entries + .iter() + .map(|(os, text)| (*os, (*text).to_string())) + .collect(), + )) +} + +/// `PLATFORM_CHROME[id]`. +pub fn chrome(id: &str) -> Option { + use RuntimeOs::{Linux, Macos, Windows}; + let simple = + |accent: &'static str, tab: &'static str, path: Option<&'static str>| PlatformChrome { + accent, + settings_tab_key: Some(tab), + path_label_key: path, + ..PlatformChrome::default() + }; + let stores = |ids: &[&'static str]| PlatformCapabilities { + external_data_stores: ids.to_vec(), + ..PlatformCapabilities::default() + }; + Some(match id { + "steam" => PlatformChrome { + name: Some("Steam"), + accent: "#2563eb", + supported_os: Some(vec![Windows, Linux, Macos]), + settings_tab_key: Some("settings.steam"), + path_label_key: Some("settings.steamFolder"), + path_placeholder: per_os(&[ + (Windows, "C:\\Program Files (x86)\\Steam"), + (Linux, "~/.local/share/Steam"), + (Macos, "~/Library/Application Support/Steam"), + ]), + capabilities: PlatformCapabilities { + bulk_edit: true, + profile_refresh: Some(ProfileRefresh { + avatars: true, + bans: true, + }), + account_usernames: true, + prime_profile_after_add: true, + account_warnings: true, + external_data_stores: vec![ + CLIENT_STORE_STEAM_PROFILE_CACHE, + CLIENT_STORE_STEAM_BAN_CHECK_STATE, + CLIENT_STORE_STEAM_BAN_INFO_CACHE, + ], + settings: Some(PlatformSettingsSchema::Steam), + ..PlatformCapabilities::default() + }, + }, + "riot" => PlatformChrome { + name: Some("Riot Games"), + accent: "#ef4444", + supported_os: Some(vec![Windows]), + settings_tab_key: Some("settings.riot"), + path_label_key: Some("settings.riotClientPath"), + path_placeholder: Some(PathPlaceholder::Text( + "C:\\Riot Games\\Riot Client\\RiotClientServices.exe".to_string(), + )), + capabilities: PlatformCapabilities { + last_login_unknown_key: Some("time.neverConnected"), + external_data_stores: vec![STORAGE_TARGET_RIOT_SNAPSHOTS], + ..PlatformCapabilities::default() + }, + }, + "battle-net" => PlatformChrome { + name: Some("Battle.net"), + accent: "#38bdf8", + supported_os: Some(vec![Windows, Macos]), + settings_tab_key: Some("settings.battleNet"), + path_label_key: Some("settings.battleNetPath"), + path_placeholder: per_os(&[ + ( + Windows, + "C:\\Program Files (x86)\\Battle.net\\Battle.net Launcher.exe", + ), + (Macos, "/Applications/Battle.net.app"), + ]), + ..PlatformChrome::default() + }, + "ubisoft" => PlatformChrome { + capabilities: stores(&[STORAGE_TARGET_UBISOFT_SNAPSHOTS]), + ..simple("#0070ff", "settings.ubisoft", Some("settings.ubisoftPath")) + }, + "roblox" => PlatformChrome { + name: Some("Roblox"), + supported_os: Some(vec![Windows]), + capabilities: PlatformCapabilities { + account_warnings: true, + external_data_stores: vec![CLIENT_STORE_ROBLOX_PROFILE_CACHE], + ..PlatformCapabilities::default() + }, + ..simple("#e1242a", "settings.roblox", None) + }, + "epic" => PlatformChrome { + capabilities: stores(&[STORAGE_TARGET_EPIC_SNAPSHOTS]), + ..simple("#0078f2", "settings.epic", Some("settings.epicPath")) + }, + "gog" => simple("#a02de3", "settings.gog", Some("settings.gogPath")), + "jagex" => simple("#eab308", "settings.jagex", Some("settings.jagexPath")), + "discord" => simple("#5865f2", "settings.discord", Some("settings.discordPath")), + _ => return None, + }) +} + +/// `accentFor`: a stable hue from the id, so a user platform keeps its colour. +pub fn accent_for(id: &str) -> String { + let mut hue: u32 = 0; + for c in id.chars() { + let mut units = [0u16; 2]; + let first = c.encode_utf16(&mut units)[0]; + hue = (hue * 31 + u32::from(first)) % 360; + } + format!("hsl({hue} 62% 52%)") +} + +/// `buildDef`. +pub fn build_def( + id: &str, + chrome: Option<&PlatformChrome>, + descriptor: Option<&PlatformDescriptor>, + user_provided: bool, +) -> PlatformDef { + let accent = chrome.map_or_else(|| accent_for(id), |chrome| chrome.accent.to_string()); + PlatformDef { + id: id.to_string(), + name: descriptor + .map(|descriptor| descriptor.name.clone()) + .or_else(|| chrome.and_then(|chrome| chrome.name).map(str::to_string)) + .unwrap_or_else(|| id.to_string()), + accent, + implemented: true, + supported_os: match descriptor { + Some(descriptor) => descriptor.supported_os(), + None => chrome + .and_then(|chrome| chrome.supported_os.clone()) + .unwrap_or_default(), + }, + settings_tab_key: chrome.and_then(|chrome| chrome.settings_tab_key), + path_label_key: chrome.and_then(|chrome| chrome.path_label_key), + path_placeholder: match descriptor { + Some(descriptor) => descriptor.path_placeholder(), + None => chrome.and_then(|chrome| chrome.path_placeholder.clone()), + }, + capabilities: chrome + .map(|chrome| chrome.capabilities.clone()) + .unwrap_or_default(), + user_provided, + } +} + +/// `buildShippedDefs`: the chrome in order, each with its descriptor, then +/// any shipped descriptor the chrome does not know. +pub fn build_shipped_defs(descriptors: &[PlatformDescriptor]) -> Vec { + let by_id: BTreeMap<&str, &PlatformDescriptor> = descriptors + .iter() + .map(|descriptor| (descriptor.id.as_str(), descriptor)) + .collect(); + let mut defs: Vec = CHROME_ORDER + .iter() + .map(|id| build_def(id, chrome(id).as_ref(), by_id.get(id).copied(), false)) + .collect(); + for descriptor in descriptors { + if chrome(&descriptor.id).is_none() { + defs.push(build_def(&descriptor.id, None, Some(descriptor), false)); + } + } + defs +} + +/// `PLATFORM_DEFS` and the adapters (`registerPlatform`, +/// `ensurePlatformLoaded`). Adapters are cheap and built eagerly; they are +/// shared so the job pool can hold one while the UI keeps the registry. +#[derive(Debug, Clone)] +pub struct PlatformRegistry { + defs: Vec, + adapters: BTreeMap>, +} + +impl PlatformRegistry { + /// The shipped platforms, descriptors read from the engine. + pub fn shipped() -> Self { + Self::with_descriptors(&PlatformDescriptor::shipped()) + } + + /// The shipped chrome over the given shipped descriptors. + pub fn with_descriptors(descriptors: &[PlatformDescriptor]) -> Self { + let defs = build_shipped_defs(descriptors); + let adapters = defs + .iter() + .map(|def| { + let adapter = PlatformAdapter::shipped(&def.id) + .unwrap_or_else(|| PlatformAdapter::user(&def.id, &def.name)); + (def.id.clone(), Arc::new(adapter)) + }) + .collect(); + Self { defs, adapters } + } + + /// `PLATFORM_DEFS`, in display order. + pub fn defs(&self) -> &[PlatformDef] { + &self.defs + } + + /// `getPlatformDefinition`. + pub fn definition(&self, platform_id: &str) -> Option<&PlatformDef> { + self.defs.iter().find(|def| def.id == platform_id) + } + + /// `ensurePlatformLoaded`. + pub fn adapter(&self, platform_id: &str) -> Option> { + self.adapters.get(platform_id).cloned() + } + + /// `registerUserPlatforms`: the folder is the truth. A shipped id wins; + /// a user platform still listed keeps its adapter (and session state). + pub fn register_user_platforms(&mut self, descriptors: &[PlatformDescriptor]) { + let previous: Vec = self + .defs + .iter() + .filter(|def| def.user_provided) + .map(|def| def.id.clone()) + .collect(); + self.defs.retain(|def| !def.user_provided); + for descriptor in descriptors { + if self.defs.iter().any(|def| def.id == descriptor.id) { + continue; + } + let def = build_def(&descriptor.id, None, Some(descriptor), true); + let keep = previous.contains(&def.id) + && self.adapters.get(&def.id).is_some_and(|adapter| { + adapter.kind() == PlatformAdapter::user(&def.id, &def.name).kind() + }); + if !keep { + self.adapters.insert( + def.id.clone(), + Arc::new(PlatformAdapter::user(&def.id, &def.name)), + ); + } + self.defs.push(def); + } + let live: Vec = self.defs.iter().map(|def| def.id.clone()).collect(); + self.adapters.retain(|id, _| live.contains(id)); + } +} diff --git a/crates/accshift-app/src/platforms/riot.rs b/crates/accshift-app/src/platforms/riot.rs new file mode 100644 index 00000000..8ebfdafe --- /dev/null +++ b/crates/accshift-app/src/platforms/riot.rs @@ -0,0 +1,214 @@ +//! Riot, ported from `src/lib/platforms/riot/{adapter,accountCache,profile, +//! contextMenu,riotApi,types}.ts`. + +use super::{PlatformAccount, PlatformProfileInfo, StartupSnapshot}; +use crate::menu::platform::{build_platform_context_menu, ForgetConfig}; +use crate::menu::{MenuAction, MenuCommand, PlatformTask}; +use crate::text::Text; +use crate::time::unix_ms_to_seconds; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +/// `RiotProfile`. `snapshot_state` is `setup_pending`, `awaiting_capture`, +/// `capturing`, `ready` or whatever a newer backend sends. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct RiotProfile { + #[serde(default)] + pub id: String, + #[serde(default)] + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_tag_line: Option, + #[serde(default)] + pub snapshot_state: String, + /// Unix MILLISECONDS. + #[serde(default)] + pub last_captured_at: Option, + /// Unix MILLISECONDS. + #[serde(default)] + pub last_used_at: Option, +} + +impl RiotProfile { + /// Lenient parse: a row that does not fit is skipped by the callers. + pub fn from_value(raw: &Value) -> Option { + serde_json::from_value(raw.clone()).ok() + } + + pub fn list(raw: &Value) -> Vec { + raw.as_array() + .map(|rows| rows.iter().filter_map(Self::from_value).collect()) + .unwrap_or_default() + } +} + +/// `getRiotAlias`: `name#tag`, `name`, or empty. +fn riot_alias(profile: &RiotProfile) -> String { + let name = crate::js::trim(profile.account_name.as_deref().unwrap_or_default()); + let tag_line = crate::js::trim(profile.account_tag_line.as_deref().unwrap_or_default()); + if name.is_empty() { + return String::new(); + } + if tag_line.is_empty() { + name.to_string() + } else { + format!("{name}#{tag_line}") + } +} + +/// `profileStatusLabel`. English, as TS has it. +fn status_label(profile: &RiotProfile) -> &'static str { + match profile.snapshot_state.as_str() { + "setup_pending" => "Waiting for connection", + "capturing" => "Saving session", + "ready" => "", + _ => "Capture required", + } +} + +/// `profileSecondaryLabel`. +fn secondary_label(profile: &RiotProfile) -> String { + if profile.snapshot_state == "ready" { + return String::new(); + } + let status = status_label(profile); + let label = crate::js::trim(&profile.label); + let alias = riot_alias(profile); + if label.is_empty() || alias.is_empty() || label == alias { + return status.to_string(); + } + format!("{label} \u{b7} {status}") +} + +/// `toRiotAccount`. Riot stamps milliseconds; the last use wins over the +/// last capture. +pub fn to_riot_account(profile: &RiotProfile) -> PlatformAccount { + let alias = riot_alias(profile); + PlatformAccount { + id: profile.id.clone(), + display_name: if alias.is_empty() { + profile.label.clone() + } else { + alias + }, + username: secondary_label(profile), + last_login_at_sec: unix_ms_to_seconds(profile.last_used_at.or(profile.last_captured_at)), + } +} + +/// The Riot startup snapshot (`{ profiles, currentProfile }`), with the +/// profiles kept for the cache. +pub fn to_riot_snapshot(raw: &Value) -> (StartupSnapshot, Vec) { + let profiles = raw + .get("profiles") + .map(RiotProfile::list) + .unwrap_or_default(); + let snapshot = StartupSnapshot { + accounts: profiles.iter().map(to_riot_account).collect(), + current_account: raw + .get("currentProfile") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }; + (snapshot, profiles) +} + +/// `accountCache.ts`: the profiles of the last load, by id. +#[derive(Debug, Clone, Default)] +pub struct RiotProfileCache { + profiles: BTreeMap, +} + +impl RiotProfileCache { + /// `rememberRiotProfiles`: replaces the whole cache. + pub fn remember(&mut self, profiles: &[RiotProfile]) { + self.profiles = profiles + .iter() + .map(|profile| (profile.id.clone(), profile.clone())) + .collect(); + } + + /// `getCachedRiotProfileMeta`. + pub fn get(&self, profile_id: &str) -> Option<&RiotProfile> { + self.profiles.get(profile_id) + } + + /// `forgetCachedRiotProfile`. + pub fn forget(&mut self, profile_id: &str) { + self.profiles.remove(profile_id); + } +} + +/// `getRiotProfile`: no avatar (shared fallback gradients), the alias or the +/// label, and a spinner while the session is being set up or saved. +pub fn riot_profile_info(profile: Option<&RiotProfile>) -> Option { + let profile = profile?; + let alias = riot_alias(profile); + Some(PlatformProfileInfo { + avatar_url: None, + display_name: Some(if alias.is_empty() { + profile.label.clone() + } else { + alias + }), + avatar_loading: matches!( + profile.snapshot_state.as_str(), + "setup_pending" | "capturing" + ), + }) +} + +/// `getNoAccountsToastMessage`. +pub const NO_ACCOUNTS_TOAST_KEY: &str = "toast.noRiotProfilesFound"; + +/// `getRiotContextMenuItems`: capture (or recapture), then forget. No copy +/// entries. +pub fn riot_context_menu_actions( + account: &PlatformAccount, + profile: Option<&RiotProfile>, +) -> Vec { + let capture_label = if profile.is_some_and(|profile| profile.snapshot_state == "ready") { + "riot.recaptureSession" + } else { + "riot.captureSession" + }; + let mut actions = vec![MenuAction::item( + format!("riot.capture.{}", account.id), + Some("platform.primary"), + Text::key(capture_label), + MenuCommand::Task(PlatformTask::RiotCapture { + profile_id: account.id.clone(), + display_name: account.display_name.clone(), + }), + )]; + let mut forget = ForgetConfig::keys( + "riot.forgetConfirmTitle", + "riot.forgetConfirmMessage", + "riot.forget", + "riot.forgotProfile", + ); + forget.toast_params = vec![("profile".to_string(), account.display_name.as_str().into())]; + let base = if account.display_name.is_empty() { + &account.username + } else { + &account.display_name + }; + let trimmed = crate::js::trim(base); + let display = if trimmed.is_empty() { + account.username.clone() + } else { + trimmed.to_string() + }; + actions.extend(build_platform_context_menu( + "riot", + account, + &[], + &forget, + Some(display), + )); + actions +} diff --git a/crates/accshift-app/src/platforms/roblox.rs b/crates/accshift-app/src/platforms/roblox.rs new file mode 100644 index 00000000..770934fd --- /dev/null +++ b/crates/accshift-app/src/platforms/roblox.rs @@ -0,0 +1,218 @@ +//! Roblox, ported from `src/lib/platforms/roblox/{adapter,contextMenu, +//! robloxApi,types,warnings}.ts`. The profile cache is M2 work. + +use super::warnings::{WarningChip, WarningLoad, WarningPresentation, WarningTone}; +use super::{PlatformAccount, PlatformProfileInfo, StartupSnapshot}; +use crate::backend::Backend; +use crate::error::BackendError; +use crate::menu::platform::{build_platform_context_menu, CopyItemConfig, ForgetConfig}; +use crate::menu::MenuAction; +use crate::settings::AppSettings; +use crate::text::Text; +use crate::time::unix_ms_to_seconds; +use crate::types::RobloxProfileInfo; +use serde_json::Value; +use std::collections::BTreeSet; + +/// `SESSION_EXPIRED_PATTERN`: the backend's wording when the stored +/// `.ROBLOSECURITY` cookie is refused, matched case-insensitively. +pub const SESSION_EXPIRED_PATTERN: &str = "auth ticket request failed (http 401"; + +/// `SESSION_EXPIRED_PATTERN.test(message)`. +pub fn is_session_expired_error(message: &str) -> bool { + message.to_lowercase().contains(SESSION_EXPIRED_PATTERN) +} + +/// `toRobloxAccount` on the camelCase `RobloxAccount` payload. +/// `lastLoginAt` is milliseconds despite its name. +pub fn to_roblox_account(raw: &Value) -> PlatformAccount { + let text = |name: &str| raw.get(name).and_then(Value::as_str).unwrap_or_default(); + let username = text("username"); + let display_name = text("displayName"); + PlatformAccount { + id: text("userId").to_string(), + display_name: if display_name.is_empty() { + username + } else { + display_name + } + .to_string(), + username: username.to_string(), + last_login_at_sec: unix_ms_to_seconds(raw.get("lastLoginAt").and_then(Value::as_f64)), + } +} + +pub fn to_roblox_accounts(raw: &Value) -> Vec { + raw.as_array() + .map(|rows| rows.iter().map(to_roblox_account).collect()) + .unwrap_or_default() +} + +/// The Roblox startup snapshot (`{ accounts, currentAccount }`). +pub fn to_roblox_snapshot(raw: &Value) -> StartupSnapshot { + StartupSnapshot { + accounts: raw + .get("accounts") + .map(to_roblox_accounts) + .unwrap_or_default(), + current_account: raw + .get("currentAccount") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + } +} + +/// `getProfileInfo` after the fetch: `None` is a failed fetch; a profile with +/// no picture or an unsafe URL still resolves, avatar-less. +pub fn to_roblox_profile_info(info: Option<&RobloxProfileInfo>) -> Option { + let info = info?; + let avatar = info + .avatar_url + .as_deref() + .filter(|url| !url.is_empty() && super::steam::is_safe_http_url(url)); + Some(PlatformProfileInfo { + avatar_url: avatar.map(str::to_string), + display_name: None, + avatar_loading: false, + }) +} + +/// `getNoAccountsToastMessage`. +pub const NO_ACCOUNTS_TOAST_KEY: &str = "toast.noRobloxAccountsFound"; +/// `getNoAccountsHintMessage`. +pub const NO_ACCOUNTS_HINT_KEY: &str = "roblox.noAccountsHint"; + +/// `getSwitchErrorToastMessage`. +pub fn switch_error_toast(message: &str) -> Option { + is_session_expired_error(message).then(|| Text::key("roblox.sessionExpiredSwitchError")) +} + +/// `getRobloxContextMenuItems`: username and user id copies, then forget. +pub fn roblox_context_menu_actions(account: &PlatformAccount) -> Vec { + let username = if account.username.is_empty() { + account.id.clone() + } else { + account.username.clone() + }; + let copy_items = [ + CopyItemConfig::new("username", username, "roblox.copyLabelUsername"), + CopyItemConfig::new("userId", account.id.clone(), "roblox.copyLabelUserId"), + ]; + let forget = ForgetConfig::keys( + "roblox.forgetConfirmTitle", + "roblox.forgetConfirmMessage", + "roblox.forget", + "roblox.forgotAccount", + ); + let base = if account.display_name.is_empty() { + &account.id + } else { + &account.display_name + }; + let trimmed = crate::js::trim(base); + let display = if trimmed.is_empty() { + account.id.clone() + } else { + trimmed.to_string() + }; + build_platform_context_menu("roblox", account, ©_items, &forget, Some(display)) +} + +/// The module state of `warnings.ts`: dead sessions this session knows of. +/// Roblox cookies carry no readable expiry, so the backend probes them over +/// the network, once per session unless forced. +#[derive(Debug, Clone, Default)] +pub struct RobloxSessions { + dead: BTreeSet, + checked_this_session: bool, +} + +/// `expiredPresentation`. +pub fn expired_presentation() -> WarningPresentation { + WarningPresentation { + tooltip: Text::key("roblox.sessionExpiredTooltip"), + card_outline_tone: Some(WarningTone::Orange), + list_has_red: false, + list_has_orange: true, + chips: vec![WarningChip { + tone: WarningTone::Orange, + text: Text::key("roblox.sessionExpired"), + }], + } +} + +impl RobloxSessions { + /// `markRobloxSessionExpired`: a switch refused with HTTP 401 proves the + /// cookie dead, whatever the health-check setting. + pub fn mark_expired(&mut self, user_id: &str) { + self.dead.insert(user_id.to_string()); + } + + /// `clearRobloxSessionExpired`: a fresh cookie or a successful switch. + pub fn clear_expired(&mut self, user_id: &str) { + self.dead.remove(user_id); + } + + pub fn is_expired(&self, user_id: &str) -> bool { + self.dead.contains(user_id) + } + + /// `getCachedRobloxWarningStates` (`toWarningMap`). + pub fn warning_map(&self) -> WarningLoad { + WarningLoad { + warnings: self + .dead + .iter() + .map(|id| (id.clone(), expired_presentation())) + .collect(), + outcome: None, + toasts: Vec::new(), + } + } + + /// `loadRobloxWarningStates` up to the probe: whether to run it. The + /// setting gates the probe only; marks from failed switches still show. + pub fn needs_probe( + &self, + settings: &AppSettings, + accounts: &[PlatformAccount], + force_refresh: bool, + ) -> bool { + let enabled = settings + .health_check_per_platform + .get("roblox") + .copied() + .unwrap_or(true); + enabled && !accounts.is_empty() && (force_refresh || !self.checked_this_session) + } + + /// The probe, on the job pool. + pub fn probe(backend: &dyn Backend) -> Result, BackendError> { + backend.roblox_check_sessions() + } + + /// Stores the probe's answer. A failure keeps what was known. + pub fn finish_probe(&mut self, result: Result, BackendError>) -> WarningLoad { + if let Ok(dead) = result { + self.dead = dead.into_iter().collect(); + self.checked_this_session = true; + } + self.warning_map() + } + + /// The whole load, for callers that may block. + pub fn load_blocking( + &mut self, + backend: &dyn Backend, + settings: &AppSettings, + accounts: &[PlatformAccount], + force_refresh: bool, + ) -> WarningLoad { + if !self.needs_probe(settings, accounts, force_refresh) { + return self.warning_map(); + } + let result = Self::probe(backend); + self.finish_probe(result) + } +} diff --git a/crates/accshift-app/src/platforms/steam/api.rs b/crates/accshift-app/src/platforms/steam/api.rs new file mode 100644 index 00000000..63750d7c --- /dev/null +++ b/crates/accshift-app/src/platforms/steam/api.rs @@ -0,0 +1,230 @@ +//! The Steam calls with logic of their own, ported from +//! `src/lib/platforms/steam/steamApi.ts`: the launch config read from the +//! settings, the switch variants and their logs, bulk edit logging and the +//! `hasApiKey` memo. Plain pass-through calls go straight to +//! [`crate::Backend`]. + +use super::super::api::{log_app_event, PlatformApi}; +use super::super::AddFlowStatus; +use crate::backend::Backend; +use crate::error::BackendResult; +use crate::settings::{SteamPlatformSettings, SteamShutdownMode}; +use crate::types::{BulkEditRequest, BulkEditResult}; +use serde_json::{json, Value}; + +/// `getSteamLaunchConfig`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SteamLaunchConfig { + pub run_as_admin: bool, + /// Trimmed. + pub launch_options: String, + /// `force` or `graceful`. + pub shutdown_mode: &'static str, +} + +impl SteamLaunchConfig { + pub fn from_settings(settings: &SteamPlatformSettings) -> Self { + Self { + run_as_admin: settings.run_as_admin, + launch_options: crate::js::trim(&settings.launch_options).to_string(), + shutdown_mode: match settings.shutdown_mode { + SteamShutdownMode::Force => "force", + SteamShutdownMode::Graceful => "graceful", + }, + } + } + + /// The `params` object the switch and setup commands take. + pub fn params(&self) -> Value { + json!({ + "runAsAdmin": self.run_as_admin, + "launchOptions": self.launch_options, + "shutdownMode": self.shutdown_mode, + }) + } + + fn configured(&self) -> bool { + !self.launch_options.is_empty() + } +} + +/// `switchAccount(username)`. +pub fn switch_account( + backend: &dyn Backend, + username: &str, + cfg: &SteamLaunchConfig, +) -> BackendResult<()> { + PlatformApi::new(backend, "steam").switch_account( + username, + &cfg.params(), + Some(json!({ + "username": username, + "runAsAdmin": cfg.run_as_admin, + "launchOptionsConfigured": cfg.configured(), + })), + ) +} + +/// `switchAccountMode`: the generic switch with `mode` and `steamId` in the +/// params, so the backend applies the persona mode. +pub fn switch_account_mode( + backend: &dyn Backend, + username: &str, + steam_id: &str, + mode: &str, + cfg: &SteamLaunchConfig, +) -> BackendResult<()> { + let mut params = cfg.params(); + if let Some(object) = params.as_object_mut() { + object.insert("mode".to_string(), Value::from(mode)); + object.insert("steamId".to_string(), Value::from(steam_id)); + } + PlatformApi::new(backend, "steam").switch_account( + username, + ¶ms, + Some(json!({ + "username": username, + "steamId": steam_id, + "mode": mode, + "runAsAdmin": cfg.run_as_admin, + "launchOptionsConfigured": cfg.configured(), + })), + ) +} + +/// `switchAccountAndLaunchGame`, logged before and after. +pub fn switch_account_and_launch_game( + backend: &dyn Backend, + username: &str, + steam_id: &str, + app_id: &str, + cfg: &SteamLaunchConfig, +) -> BackendResult<()> { + const SOURCE: &str = "frontend.steam.switch_and_launch_game"; + let details = json!({ + "username": username, + "steamId": steam_id, + "appId": app_id, + "runAsAdmin": cfg.run_as_admin, + "launchOptionsConfigured": cfg.configured(), + }); + log_app_event( + backend, + "info", + SOURCE, + "Switch+launch request started", + Some(&details), + ); + match backend.steam_switch_account_and_launch_game( + username, + app_id, + cfg.run_as_admin, + &cfg.launch_options, + cfg.shutdown_mode, + ) { + Ok(()) => { + log_app_event( + backend, + "info", + SOURCE, + "Switch+launch request completed", + Some(&details), + ); + Ok(()) + } + Err(error) => { + let mut failed = details.as_object().cloned().unwrap_or_default(); + failed.insert("error".to_string(), Value::String(error.message.clone())); + log_app_event( + backend, + "error", + SOURCE, + "Switch+launch failed", + Some(&Value::Object(failed)), + ); + Err(error) + } + } +} + +/// `beginAccountSetup`: the add flow takes the launch config too. +pub fn begin_account_setup( + backend: &dyn Backend, + cfg: &SteamLaunchConfig, +) -> BackendResult { + PlatformApi::new(backend, "steam").begin_setup(&cfg.params()) +} + +/// `bulkEdit`, logged before and after. +pub fn bulk_edit(backend: &dyn Backend, request: BulkEditRequest) -> BackendResult { + const SOURCE: &str = "frontend.steam.bulk_edit"; + let details = json!({ + "accountCount": request.steam_ids.len(), + "newsPopup": request.news_popup, + "doNotDisturb": request.do_not_disturb, + "launchOptionCount": request.launch_options.len(), + }); + log_app_event(backend, "info", SOURCE, "Bulk edit started", Some(&details)); + match backend.steam_bulk_edit(request) { + Ok(result) => { + let mut done = details.as_object().cloned().unwrap_or_default(); + done.insert("succeeded".to_string(), Value::from(result.succeeded)); + done.insert("failed".to_string(), Value::from(result.failed.len())); + log_app_event( + backend, + "info", + SOURCE, + "Bulk edit completed", + Some(&Value::Object(done)), + ); + Ok(result) + } + Err(error) => { + let mut failed = details.as_object().cloned().unwrap_or_default(); + failed.insert("error".to_string(), Value::String(error.message.clone())); + log_app_event( + backend, + "error", + SOURCE, + "Bulk edit failed", + Some(&Value::Object(failed)), + ); + Err(error) + } + } +} + +/// The `hasApiKey` memo. `steam_has_api_key` decrypts on every call, and the +/// key only changes through `setApiKey`, so the answer is kept for the +/// session. A read that started before a write must not store its answer +/// after it: the generation tells. +#[derive(Debug, Clone, Default)] +pub struct ApiKeyMemo { + cached: Option, + generation: u64, +} + +impl ApiKeyMemo { + /// The memoized answer, if any. + pub fn cached(&self) -> Option { + self.cached + } + + /// The generation a read starts at. + pub fn generation(&self) -> u64 { + self.generation + } + + /// Stores a read's answer unless a write happened since it started. + pub fn finish_read(&mut self, generation: u64, value: bool) { + if generation == self.generation { + self.cached = Some(value); + } + } + + /// After a successful `steam_set_api_key`: drop the answer. + pub fn invalidate(&mut self) { + self.cached = None; + self.generation += 1; + } +} diff --git a/crates/accshift-app/src/platforms/steam/context_menu.rs b/crates/accshift-app/src/platforms/steam/context_menu.rs new file mode 100644 index 00000000..83ee5bb7 --- /dev/null +++ b/crates/accshift-app/src/platforms/steam/context_menu.rs @@ -0,0 +1,265 @@ +//! Steam account menu entries, ported from +//! `src/lib/platforms/steam/contextMenu.ts`. + +use super::friend_code::encode_friend_code; +use super::to_profile_url; +use crate::menu::platform::{build_platform_context_menu, CopyItemConfig, ForgetConfig}; +use crate::menu::{ + ConfirmRequest, MenuAction, MenuActionBody, MenuCommand, PlatformTask, SubmenuLoader, +}; +use crate::platforms::PlatformAccount; +use crate::stores::DefaultGame; +use crate::text::Text; +use crate::types::CopyableGame; + +fn loader_item( + id: String, + group: &str, + label: Text, + command: Option, + loader: SubmenuLoader, +) -> MenuAction { + MenuAction { + id, + group: Some(group.to_string()), + label, + body: MenuActionBody::Item { + command, + submenu: None, + loader: Some(loader), + }, + } +} + +/// `(account.displayName || account.username).trim() || account.username`. +fn display_value(account: &PlatformAccount) -> String { + let base = if account.display_name.is_empty() { + &account.username + } else { + &account.display_name + }; + let trimmed = crate::js::trim(base); + if trimmed.is_empty() { + account.username.clone() + } else { + trimmed.to_string() + } +} + +/// `getSteamContextMenuItems`: launch entries, copies, data entries, forget. +/// `default_game` is `getDefaultGame(account.id)`. +pub fn steam_context_menu_actions( + account: &PlatformAccount, + current_account_id: Option<&str>, + default_game: Option<&DefaultGame>, +) -> Vec { + let id = &account.id; + let is_current = current_account_id == Some(id.as_str()); + let mode = |mode: &str| { + MenuCommand::Task(PlatformTask::SteamSwitchMode { + username: account.username.clone(), + steam_id: id.clone(), + mode: mode.to_string(), + }) + }; + let run_label = match default_game { + Some(game) => Text::with( + if is_current { + "steam.runGame" + } else { + "steam.switchAndRunGame" + }, + [("game", game.name.as_str())], + ), + None => Text::key(if is_current { + "steam.runPick" + } else { + "steam.switchAndRunPick" + }), + }; + // Always the combined backend command: it checks the live Steam state and + // skips the restart when the account is already logged in. + let run_command = default_game.map(|game| { + MenuCommand::Task(PlatformTask::SteamRunGame { + username: account.username.clone(), + steam_id: id.clone(), + app_id: game.app_id.clone(), + remember: None, + }) + }); + let mut actions = vec![ + MenuAction::item( + format!("steam.launch.online.{id}"), + Some("platform.primary"), + Text::key("steam.launchOnline"), + mode("online"), + ), + MenuAction::item( + format!("steam.launch.invisible.{id}"), + Some("platform.primary"), + Text::key("steam.launchInvisible"), + mode("invisible"), + ), + loader_item( + format!("steam.switch.run.{id}"), + "platform.primary", + run_label, + run_command, + SubmenuLoader::SteamAccountGames { + username: account.username.clone(), + steam_id: id.clone(), + }, + ), + ]; + if default_game.is_some() { + actions.push(MenuAction::item( + format!("steam.switch.run.clear.{id}"), + Some("platform.data"), + Text::key("steam.clearDefaultGame"), + MenuCommand::Task(PlatformTask::SteamClearDefaultGame { + steam_id: id.clone(), + }), + )); + } + + let copy_items = [ + CopyItemConfig::new( + "username", + account.username.clone(), + "steam.copyLabelUsername", + ), + CopyItemConfig::new("id64", id.clone(), "steam.copyLabelSteamId64"), + CopyItemConfig::new( + "friendCode", + encode_friend_code(id), + "steam.copyLabelFriendCode", + ), + CopyItemConfig::new( + "profileUrl", + to_profile_url(id), + "steam.copyLabelProfileUrl", + ), + ]; + let mut forget = ForgetConfig::keys( + "steam.forgetConfirmTitle", + "steam.forgetConfirmMessage", + "steam.forget", + "steam.forgotAccount", + ); + forget.toast_params = vec![("username".to_string(), account.username.as_str().into())]; + let copy_and_forget = build_platform_context_menu( + "steam", + account, + ©_items, + &forget, + Some(display_value(account)), + ); + let (copies, forget_items): (Vec, Vec) = copy_and_forget + .into_iter() + .filter(|action| { + matches!( + action.group.as_deref(), + Some("platform.copy" | "platform.danger") + ) + }) + .partition(|action| action.group.as_deref() == Some("platform.copy")); + + let mut data = vec![MenuAction::item( + format!("steam.open.userdata.{id}"), + Some("platform.data"), + Text::key("steam.openUserdataFolder"), + MenuCommand::Task(PlatformTask::SteamOpenUserdata { + steam_id: id.clone(), + }), + )]; + if is_current { + data.push(MenuAction::item( + format!("steam.clear.browserCache.{id}"), + Some("platform.data"), + Text::key("steam.clearIntegratedBrowserCache"), + MenuCommand::Confirm(Box::new(ConfirmRequest { + title: Text::key("steam.clearIntegratedBrowserCacheConfirmTitle"), + message: Text::key("steam.clearIntegratedBrowserCacheConfirmMessage"), + confirm_label: Some(Text::key("steam.clearIntegratedBrowserCache")), + on_confirm: PlatformTask::SteamClearBrowserCache, + })), + )); + } + if let Some(target) = current_account_id.filter(|target| !target.is_empty() && *target != id) { + data.push(loader_item( + format!("steam.copy.settings.{id}"), + "platform.data", + Text::key("steam.copySettingsFrom"), + None, + SubmenuLoader::SteamCopyableGames { + from_steam_id: id.clone(), + to_steam_id: target.to_string(), + }, + )); + } + + actions.extend(copies); + actions.extend(data); + actions.extend(forget_items); + actions +} + +/// The `submenuLoader` of the run entry, once the account's games are in. +/// `remember` sets the picked game as the account's default before running. +pub fn account_games_submenu( + username: &str, + steam_id: &str, + games: &[CopyableGame], +) -> Vec { + if games.is_empty() { + return vec![MenuAction::item( + format!("steam.switch.run.{steam_id}.empty"), + None, + Text::key("steam.switchAndRunNoGames"), + MenuCommand::Close, + )]; + } + games + .iter() + .map(|game| { + MenuAction::item( + format!("steam.switch.run.{steam_id}.{}", game.app_id), + None, + Text::raw(game.name.clone()), + MenuCommand::Task(PlatformTask::SteamRunGame { + username: username.to_string(), + steam_id: steam_id.to_string(), + app_id: game.app_id.clone(), + remember: Some(DefaultGame { + app_id: game.app_id.clone(), + name: game.name.clone(), + }), + }), + ) + }) + .collect() +} + +/// The `submenuLoader` of "copy settings from", once the games are in. +pub fn copyable_games_submenu( + from_steam_id: &str, + to_steam_id: &str, + games: &[CopyableGame], +) -> Vec { + games + .iter() + .map(|game| { + MenuAction::item( + format!("steam.copy.settings.{from_steam_id}.{}", game.app_id), + None, + Text::raw(game.name.clone()), + MenuCommand::Task(PlatformTask::SteamCopyGameSettings { + from_steam_id: from_steam_id.to_string(), + to_steam_id: to_steam_id.to_string(), + app_id: game.app_id.clone(), + game_name: game.name.clone(), + }), + ) + }) + .collect() +} diff --git a/crates/accshift-app/src/platforms/steam/cs2.rs b/crates/accshift-app/src/platforms/steam/cs2.rs new file mode 100644 index 00000000..4dc0564e --- /dev/null +++ b/crates/accshift-app/src/platforms/steam/cs2.rs @@ -0,0 +1,198 @@ +//! CS2 card content, ported from `src/lib/platforms/steam/cs2CardContent.ts`, +//! and the data half of `cs2Bridge.svelte.ts` (the cached rows, the TTL and +//! the enabled flag; the settings form is UI work). + +use crate::backend::Backend; +use crate::platforms::api::log_app_event; +use crate::platforms::card::{CardChip, CardProgress, CardSection, ChipTone, UsernameBadge}; +use crate::text::Text; +use crate::types::Cs2BridgeAccount; +use serde_json::json; +use std::collections::BTreeMap; + +/// The source refreshes GC stats about every 3 h; 5 min keeps hover data +/// fresh without hammering the bridge on every tab switch. +pub const CS2_BRIDGE_CACHE_TTL_MS: f64 = 5.0 * 60.0 * 1000.0; + +fn case_chip(case_earned: bool) -> (ChipTone, Text) { + if case_earned { + (ChipTone::Green, Text::key("card.cs2CaseEarned")) + } else { + (ChipTone::Slate, Text::key("card.cs2CaseNotEarned")) + } +} + +/// `cs2ExtensionSections`: the level, XP bar and weekly-case chip. Empty until +/// the bridge has both a level and an XP reading. +pub fn cs2_extension_sections(data: Option<&Cs2BridgeAccount>) -> Vec { + let Some(data) = data else { + return Vec::new(); + }; + let (Some(level), Some(xp)) = (data.level, data.xp) else { + return Vec::new(); + }; + let (tone, text) = case_chip(data.case_earned); + vec![CardSection { + title: Some(Text::key("card.cs2Section")), + text: Some(Text::with("card.cs2Level", [("level", level)])), + progress: Some(CardProgress { + value: f64::from(xp), + max: f64::from(data.xp_max), + label: Some(format!("{xp}/{}", data.xp_max)), + }), + chips: vec![CardChip { text, tone }], + ..CardSection::default() + }] +} + +/// `cs2UsernameBadge`: the weekly-case state next to the username. +pub fn cs2_username_badge(data: Option<&Cs2BridgeAccount>) -> Option { + let (tone, label) = case_chip(data?.case_earned); + Some(UsernameBadge { tone, label }) +} + +/// The bridge cache of `cs2Bridge.svelte.ts`. Loads are silent: on failure +/// the card shows nothing. +#[derive(Debug, Clone, Default)] +pub struct Cs2Bridge { + data: BTreeMap, + version: u64, + /// `None` until the settings were read. + enabled: Option, + last_fetch_at: f64, +} + +/// What a load needs from the backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cs2LoadPlan { + /// The enabled flag is unknown: read the settings first. + pub read_settings: bool, + pub enabled: bool, +} + +/// What the backend answered. +#[derive(Debug, Clone)] +pub struct Cs2LoadResult { + pub enabled: Option, + /// `Some` when the bridge was asked and answered. + pub accounts: Option>, +} + +impl Cs2Bridge { + /// `getCs2BridgeData`. + pub fn data(&self, steam_id: &str) -> Option<&Cs2BridgeAccount> { + self.data.get(steam_id) + } + + /// `getCs2BridgeVersion`: bumps whenever the data changes. + pub fn version(&self) -> u64 { + self.version + } + + /// `invalidateCs2Bridge`: after a settings change, refetch next time. + pub fn invalidate(&mut self) { + self.enabled = None; + self.last_fetch_at = 0.0; + self.data.clear(); + self.version += 1; + } + + /// `loadCs2BridgeData` up to the backend: `None` while the cache is + /// fresh. + pub fn begin_load(&self, force: bool, now_ms: f64) -> Option { + if !force + && self.last_fetch_at > 0.0 + && now_ms - self.last_fetch_at < CS2_BRIDGE_CACHE_TTL_MS + { + return None; + } + Some(Cs2LoadPlan { + read_settings: self.enabled.is_none(), + enabled: self.enabled.unwrap_or(false), + }) + } + + /// The backend half, on the job pool. + pub fn fetch(plan: Cs2LoadPlan, backend: &dyn Backend) -> Cs2LoadResult { + let mut result = Cs2LoadResult { + enabled: None, + accounts: None, + }; + let enabled = if plan.read_settings { + match backend.cs2_bridge_get_settings() { + Ok(settings) => { + result.enabled = Some(settings.enabled); + settings.enabled + } + Err(error) => { + warn(backend, "Bridge fetch failed", &error.message); + return result; + } + } + } else { + plan.enabled + }; + if !enabled { + return result; + } + match backend.cs2_bridge_fetch() { + Ok(accounts) => { + log_app_event( + backend, + "info", + "frontend.cs2_bridge", + "Bridge data refreshed", + Some(&json!({ "accountCount": accounts.len() })), + ); + result.accounts = Some(accounts); + } + Err(error) => warn(backend, "Bridge fetch failed", &error.message), + } + result + } + + /// Stores what [`Self::fetch`] got. + pub fn finish_load(&mut self, result: Cs2LoadResult, now_ms: f64) { + if let Some(enabled) = result.enabled { + self.enabled = Some(enabled); + } + if let Some(accounts) = result.accounts { + self.data = accounts + .into_iter() + .map(|account| (account.steam_id.clone(), account)) + .collect(); + self.version += 1; + } + self.last_fetch_at = now_ms; + } + + /// The three steps in one go. + pub fn load_blocking(&mut self, backend: &dyn Backend, force: bool, now_ms: f64) { + if let Some(plan) = self.begin_load(force, now_ms) { + let result = Self::fetch(plan, backend); + self.finish_load(result, now_ms); + } + } + + /// Records the enabled flag a bridge check read from the settings + /// (`enabledKnown = true` in `triggerCs2BridgeCheck`). + pub fn note_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } + + /// `triggerCs2BridgeCheck` result: merges a re-checked row. + pub fn apply_check(&mut self, account: Cs2BridgeAccount) { + self.data.insert(account.steam_id.clone(), account); + self.version += 1; + } +} + +fn warn(backend: &dyn Backend, message: &str, error: &str) { + log_app_event( + backend, + "warn", + "frontend.cs2_bridge", + message, + Some(&json!({ "error": error })), + ); +} diff --git a/crates/accshift-app/src/platforms/steam/friend_code.rs b/crates/accshift-app/src/platforms/steam/friend_code.rs new file mode 100644 index 00000000..e8fac892 --- /dev/null +++ b/crates/accshift-app/src/platforms/steam/friend_code.rs @@ -0,0 +1,145 @@ +//! CS2 friend codes, ported from `src/lib/platforms/steam/friendCode.ts` +//! (itself from github.com/emily33901/js-csfriendcode). + +const ALPHABET: &[u8; 32] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, + 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, + 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, +]; + +/// Minimal MD5 (RFC 1321), as the TS file carries its own. +pub fn md5(input: &[u8]) -> [u8; 16] { + let k: Vec = (0..64) + .map(|i| ((f64::from(i + 1)).sin().abs() * 4_294_967_296.0).floor() as u32) + .collect(); + let bit_len = (input.len() as u64).wrapping_mul(8); + let mut padded = input.to_vec(); + padded.push(0x80); + while padded.len() % 64 != 56 { + padded.push(0); + } + padded.extend_from_slice(&bit_len.to_le_bytes()); + + let (mut a0, mut b0, mut c0, mut d0) = ( + 0x6745_2301_u32, + 0xefcd_ab89_u32, + 0x98ba_dcfe_u32, + 0x1032_5476_u32, + ); + for chunk in padded.chunks(64) { + let m: Vec = chunk + .chunks(4) + .map(|word| u32::from_le_bytes([word[0], word[1], word[2], word[3]])) + .collect(); + let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0); + for i in 0..64 { + let (f, g) = match i { + 0..=15 => ((b & c) | (!b & d), i), + 16..=31 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 32..=47 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let f = f.wrapping_add(a).wrapping_add(k[i]).wrapping_add(m[g]); + a = d; + d = c; + c = b; + b = b.wrapping_add(f.rotate_left(S[i])); + } + a0 = a0.wrapping_add(a); + b0 = b0.wrapping_add(b); + c0 = c0.wrapping_add(c); + d0 = d0.wrapping_add(d); + } + let mut out = [0u8; 16]; + out[..4].copy_from_slice(&a0.to_le_bytes()); + out[4..8].copy_from_slice(&b0.to_le_bytes()); + out[8..12].copy_from_slice(&c0.to_le_bytes()); + out[12..].copy_from_slice(&d0.to_le_bytes()); + out +} + +/// `BigInt(text)` for the integers a Steam id can be: decimal, `0x`, `0o`, +/// `0b`, surrounding whitespace, the empty string as 0. `None` where BigInt +/// throws, and past i128 (BigInt has no ceiling; no Steam id comes close). +fn parse_big_int(text: &str) -> Option { + let trimmed = crate::js::trim(text); + if trimmed.is_empty() { + return Some(0); + } + let lower = trimmed.to_ascii_lowercase(); + for (prefix, radix) in [("0x", 16), ("0o", 8), ("0b", 2)] { + if let Some(digits) = lower.strip_prefix(prefix) { + if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) { + return None; + } + return i128::from_str_radix(digits, radix).ok(); + } + } + let (negative, digits) = match trimmed.as_bytes()[0] { + b'-' => (true, &trimmed[1..]), + b'+' => (false, &trimmed[1..]), + _ => (false, trimmed), + }; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let value: i128 = digits.parse().ok()?; + Some(if negative { -value } else { value }) +} + +fn hash_steam_id(steam_id: i128) -> u128 { + let account_id = (steam_id & 0xffff_ffff) as u64; + let strange = account_id | 0x4353_474f_0000_0000; + let hash = md5(&strange.to_le_bytes()); + u128::from(u32::from_le_bytes([hash[0], hash[1], hash[2], hash[3]])) +} + +fn make_u64(hi: u128, lo: u128) -> u128 { + (hi << 32) | lo +} + +/// `encodeFriendCode`: the CS2 friend code of a SteamID64, empty for an id +/// that is not an integer. +pub fn encode_friend_code(steam_id64: &str) -> String { + let Some(mut steam_id) = parse_big_int(steam_id64) else { + return String::new(); + }; + let hash = hash_steam_id(steam_id); + let mut r: u128 = 0; + for i in 0..8 { + let id_nibble = (steam_id & 0xf) as u128; + steam_id >>= 4; + let hash_nibble = (hash >> i) & 1; + let a = (r << 4) | id_nibble; + r = make_u64(r >> 28, a); + r = make_u64(r >> 31, (a << 1) | hash_nibble); + } + // Byte-swap the low 64 bits: to LE bytes, reverse, read as LE. + let mut value = (r as u64).swap_bytes(); + let mut code = String::with_capacity(15); + for i in 0..13 { + if i == 4 || i == 9 { + code.push('-'); + } + code.push(char::from(ALPHABET[(value & 0x1f) as usize])); + value >>= 5; + } + match code.strip_prefix("AAAA-") { + Some(rest) => rest.to_string(), + None => code, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn md5_matches_the_rfc_vectors() { + let hex = |bytes: [u8; 16]| bytes.iter().map(|b| format!("{b:02x}")).collect::(); + assert_eq!(hex(md5(b"")), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(hex(md5(b"abc")), "900150983cd24fb0d6963f7d28e17f72"); + } +} diff --git a/crates/accshift-app/src/platforms/steam/mod.rs b/crates/accshift-app/src/platforms/steam/mod.rs new file mode 100644 index 00000000..45f8ce60 --- /dev/null +++ b/crates/accshift-app/src/platforms/steam/mod.rs @@ -0,0 +1,118 @@ +//! Steam, ported from `src/lib/platforms/steam/*.ts` (the `.svelte` files +//! and the profile cache are UI and M2 work). +//! +//! - [`api`] holds `steamApi.ts`, the calls with logic of their own. +//! - [`warnings`] holds `warnings.ts`, ban warnings. +//! - [`context_menu`] holds `contextMenu.ts`. +//! - [`cs2`] holds `cs2CardContent.ts` and the data half of `cs2Bridge.svelte.ts`. +//! - [`friend_code`] and [`settings_schema`] hold their TS namesakes. +//! - here: `adapter.ts` mappings, `steamIdUtils.ts`, the toast wording. + +pub mod api; +pub mod context_menu; +pub mod cs2; +pub mod friend_code; +pub mod settings_schema; +pub mod warnings; + +use super::{PlatformAccount, PlatformProfileInfo, StartupSnapshot}; +use crate::text::Text; +use crate::types::ProfileInfo; +use serde_json::Value; + +/// `toProfileUrl`. +pub fn to_profile_url(steam_id64: &str) -> String { + format!("https://steamcommunity.com/profiles/{steam_id64}") +} + +/// `toSteamAccount`. Steam is the one platform whose stamp is already Unix +/// SECONDS (read out of `loginusers.vdf`), so it passes through. +pub fn to_steam_account(raw: &Value) -> PlatformAccount { + let text = |name: &str| { + raw.get(name) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + PlatformAccount { + id: text("steam_id"), + display_name: text("persona_name"), + username: text("account_name"), + last_login_at_sec: raw.get("last_login_at").and_then(Value::as_f64), + } +} + +/// `getAccounts().map(toSteamAccount)`. +pub fn to_steam_accounts(raw: &Value) -> Vec { + raw.as_array() + .map(|list| list.iter().map(to_steam_account).collect()) + .unwrap_or_default() +} + +/// The Steam startup snapshot (`{ accounts, currentAccount }`). +pub fn to_steam_snapshot(raw: &Value) -> StartupSnapshot { + StartupSnapshot { + accounts: raw + .get("accounts") + .map(to_steam_accounts) + .unwrap_or_default(), + current_account: raw + .get("currentAccount") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + } +} + +/// `isSafeHttpUrl`: an absolute `http:` or `https:` URL with a host. `new +/// URL()` is approximated: surrounding C0 controls and spaces are ignored, +/// the scheme is case-insensitive and the host must be non-empty. +pub fn is_safe_http_url(value: &str) -> bool { + let trimmed = value.trim_matches(|c: char| c <= ' '); + let Some((scheme, rest)) = trimmed.split_once(':') else { + return false; + }; + let scheme = scheme.to_ascii_lowercase(); + if scheme != "http" && scheme != "https" { + return false; + } + let after_slashes = rest.trim_start_matches(['/', '\\']); + let authority = after_slashes + .split(['/', '\\', '?', '#']) + .next() + .unwrap_or_default(); + let host = authority.rsplit('@').next().unwrap_or_default(); + let host = host.split(':').next().unwrap_or_default(); + !host.is_empty() +} + +/// `toPlatformProfileInfo`: `None` (no data, keep what is on screen) stays +/// `None`; an unsafe or blank avatar URL becomes "no avatar". +pub fn to_platform_profile_info(profile: Option<&ProfileInfo>) -> Option { + let profile = profile?; + let avatar = crate::js::trim(profile.avatar_url.as_deref().unwrap_or_default()); + Some(PlatformProfileInfo { + avatar_url: (!avatar.is_empty() && is_safe_http_url(avatar)).then(|| avatar.to_string()), + display_name: profile.display_name.clone(), + avatar_loading: false, + }) +} + +/// `getNoAccountsToastMessage`. +pub const NO_ACCOUNTS_TOAST_KEY: &str = "toast.noSteamAccountsFound"; + +/// `getSwitchErrorToastMessage`. +pub fn switch_error_toast(message: &str) -> Option { + message + .to_lowercase() + .contains("steam is running as administrator") + .then(|| Text::key("toast.steamElevated")) +} + +/// `getLoadErrorToastMessage`. +pub fn load_error_toast(message: &str) -> Option { + let normalized = crate::js::trim(message).to_lowercase(); + (normalized.contains("could not locate steam installation") + || normalized.contains("could not read steam configuration")) + .then(|| Text::key("toast.steamFolderNotFound")) +} diff --git a/crates/accshift-app/src/platforms/steam/settings_schema.rs b/crates/accshift-app/src/platforms/steam/settings_schema.rs new file mode 100644 index 00000000..8f220a02 --- /dev/null +++ b/crates/accshift-app/src/platforms/steam/settings_schema.rs @@ -0,0 +1,28 @@ +//! The `steam` entry of `platformSettings`, ported from +//! `src/lib/platforms/steam/settingsSchema.ts`. The sanitizer itself lives in +//! [`crate::settings::sanitize_steam_settings`], where the settings store +//! runs it; this module is the schema the registry hands out. + +use crate::settings::{sanitize_steam_settings, SteamPlatformSettings}; +use serde_json::{Map, Value}; + +/// `defaults()`: not elevated, no launch options, graceful shutdown. +pub fn defaults() -> SteamPlatformSettings { + SteamPlatformSettings::default() +} + +pub fn defaults_value() -> Value { + serde_json::to_value(defaults()).unwrap_or(Value::Null) +} + +/// `sanitize(raw, legacyRoot)`. +pub fn sanitize( + raw: Option<&Value>, + legacy_root: Option<&Map>, +) -> SteamPlatformSettings { + sanitize_steam_settings(raw, legacy_root) +} + +pub fn sanitize_value(raw: Option<&Value>, legacy_root: Option<&Map>) -> Value { + serde_json::to_value(sanitize(raw, legacy_root)).unwrap_or(Value::Null) +} diff --git a/crates/accshift-app/src/platforms/steam/warnings.rs b/crates/accshift-app/src/platforms/steam/warnings.rs new file mode 100644 index 00000000..a9df6faa --- /dev/null +++ b/crates/accshift-app/src/platforms/steam/warnings.rs @@ -0,0 +1,523 @@ +//! Steam ban warnings, ported from `src/lib/platforms/steam/warnings.ts`. +//! +//! The TS loader reads the client stores, awaits the backend, then writes the +//! stores back. Stores live on the UI thread and backend calls on the job +//! pool, so the load is split in three: [`plan`] (UI thread, reads stores and +//! the session state), [`fetch`] (job thread, the only backend calls) and +//! [`finish`] (UI thread, writes stores and the session state). +//! [`load_blocking`] chains them for callers that may block. + +use super::api::ApiKeyMemo; +use crate::backend::Backend; +use crate::error::BackendError; +use crate::platforms::warnings::{WarningChip, WarningLoad, WarningPresentation, WarningTone}; +use crate::platforms::{PlatformAccount, ToastRequest, WarningLoadOptions, WarningRefreshOutcome}; +use crate::settings::AppSettings; +use crate::storage::{CLIENT_STORE_STEAM_BAN_CHECK_STATE, CLIENT_STORE_STEAM_BAN_INFO_CACHE}; +use crate::stores::ClientStores; +use crate::text::Text; +use crate::types::BanInfo; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +pub const BAN_ERROR_TOAST_COOLDOWN_MS: f64 = 30_000.0; +/// A Steam id left out of this many consecutive answers stops being asked +/// for this session, until a forced refresh. +pub const BAN_OMISSIONS_BEFORE_SKIP: u32 = 2; + +/// The module state of `warnings.ts`: what this session already checked. +#[derive(Debug, Clone, Default)] +pub struct BanSession { + pub checked: BTreeSet, + pub omissions: BTreeMap, + pub last_error_toast_at: f64, +} + +/// `BanCheckState`, the `cache.steam.ban-check-state` store. +#[derive(Debug, Clone, PartialEq)] +pub struct BanCheckState { + pub last_success_at: f64, + pub checked_steam_ids: Vec, +} + +fn unique(ids: impl IntoIterator) -> Vec { + let mut seen = BTreeSet::new(); + ids.into_iter() + .filter(|id| seen.insert(id.clone())) + .collect() +} + +/// `readBanCheckState`. +pub fn read_ban_check_state(stores: &ClientStores) -> Option { + let raw = stores.storage().get(CLIENT_STORE_STEAM_BAN_CHECK_STATE)?; + if !raw.is_object() && !raw.is_array() { + return None; + } + let last_success_at = crate::js::to_number(raw.get("lastSuccessAt")); + if !last_success_at.is_finite() || last_success_at < 0.0 { + return None; + } + let checked = raw + .get("checkedSteamIds") + .and_then(Value::as_array) + .map(|ids| { + ids.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + Some(BanCheckState { + last_success_at, + checked_steam_ids: unique(checked), + }) +} + +/// `isBanInfo`, then the parse. +fn ban_info(value: &Value) -> Option { + let object = value.as_object()?; + let is_bool = |name: &str| object.get(name).is_some_and(Value::is_boolean); + let is_number = |name: &str| object.get(name).is_some_and(Value::is_number); + let is_string = |name: &str| object.get(name).is_some_and(Value::is_string); + let valid = is_string("steam_id") + && is_bool("community_banned") + && is_bool("vac_banned") + && is_number("number_of_vac_bans") + && is_number("days_since_last_ban") + && is_number("number_of_game_bans") + && is_string("economy_ban"); + if !valid { + return None; + } + serde_json::from_value(value.clone()).ok() +} + +/// `readBanInfoCache`: the valid entries of `cache.steam.ban-info-cache`. +pub fn read_ban_info_cache(stores: &ClientStores) -> BTreeMap { + let Some(record) = stores + .storage() + .get(CLIENT_STORE_STEAM_BAN_INFO_CACHE) + .and_then(Value::as_object) + else { + return BTreeMap::new(); + }; + record + .iter() + .filter_map(|(id, value)| ban_info(value).map(|info| (id.clone(), info))) + .collect() +} + +fn write_ban_info_cache(stores: &mut ClientStores, bans: &BTreeMap) { + let value = serde_json::to_value(bans).unwrap_or(Value::Null); + stores + .storage_mut() + .set(CLIENT_STORE_STEAM_BAN_INFO_CACHE, value); +} + +/// `formatBanTooltip`: English lines, as TS has them. +pub fn format_ban_tooltip(info: &BanInfo) -> String { + let mut lines = Vec::new(); + if info.community_banned { + lines.push("Community ban".to_string()); + } + if info.vac_banned { + let count = info.number_of_vac_bans.max(1); + lines.push(format!( + "{count} VAC ban{}", + if count > 1 { "s" } else { "" } + )); + } + if info.number_of_game_bans > 0 { + let count = info.number_of_game_bans; + lines.push(format!( + "{count} game ban{}", + if count > 1 { "s" } else { "" } + )); + } + lines.join("\n") +} + +/// `toSteamAccountWarningPresentation`. Every cached row gets one, chips or +/// not, as in TS. +pub fn ban_presentation(info: &BanInfo) -> WarningPresentation { + let mut chips = Vec::new(); + if info.community_banned { + chips.push(WarningChip { + tone: WarningTone::Orange, + text: Text::key("ban.community"), + }); + } + if info.vac_banned { + let count = info.number_of_vac_bans.max(1); + let key = if count > 1 { + "ban.vac.multiple" + } else { + "ban.vac.single" + }; + chips.push(WarningChip { + tone: WarningTone::Red, + text: Text::with(key, [("count", count)]), + }); + } + if info.number_of_game_bans > 0 { + let count = info.number_of_game_bans; + let key = if count > 1 { + "ban.game.multiple" + } else { + "ban.game.single" + }; + chips.push(WarningChip { + tone: WarningTone::Red, + text: Text::with(key, [("count", count)]), + }); + } + let red = info.vac_banned || info.number_of_game_bans > 0; + let orange = + info.community_banned || (!info.economy_ban.is_empty() && info.economy_ban != "none"); + WarningPresentation { + tooltip: Text::raw(format_ban_tooltip(info)), + card_outline_tone: if red { + Some(WarningTone::Red) + } else if orange { + Some(WarningTone::Orange) + } else { + None + }, + list_has_red: red, + list_has_orange: info.community_banned, + chips, + } +} + +/// `toWarningMap`. +pub fn to_warning_map(bans: &BTreeMap) -> BTreeMap { + bans.iter() + .map(|(id, info)| (id.clone(), ban_presentation(info))) + .collect() +} + +/// `getCachedSteamWarningStates`. +pub fn cached_warning_states(stores: &ClientStores) -> BTreeMap { + to_warning_map(&read_ban_info_cache(stores)) +} + +/// The set `alreadyCheckedBanIds` answers with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AlreadyChecked { + /// Nothing counts as checked: check everything. + Nothing, + /// What this session already fetched. + Session, + /// The persisted set of the delay window. + Cached, +} + +/// `alreadyCheckedBanIds`: with no delay configured, whatever this session +/// fetched; inside a delay window, the persisted set; a forced refresh or a +/// lapsed window checks everything again. +pub fn already_checked_ban_ids( + force_refresh: bool, + delay_days: i64, + within_delay_window: bool, +) -> AlreadyChecked { + if force_refresh { + return AlreadyChecked::Nothing; + } + if delay_days == 0 { + return AlreadyChecked::Session; + } + if within_delay_window { + AlreadyChecked::Cached + } else { + AlreadyChecked::Nothing + } +} + +/// What [`plan`] decided: the ids to ask for and what [`finish`] needs. +#[derive(Debug, Clone, PartialEq)] +pub struct BanPlan { + pub ids_to_fetch: Vec, + /// `Some(generation)` when the API key memo is unknown and [`fetch`] asks + /// the backend first. + pub key_lookup: Option, + pub delay_days: i64, + pub now_ms: f64, + pub within_delay_window: bool, + pub previously_checked: Vec, + pub force_refresh: bool, + pub silent: bool, + /// Show the sticky `toast.banChecking` until [`finish`] returns. + pub checking_toast: bool, +} + +/// The first step of a load. +#[derive(Debug, Clone, PartialEq)] +pub enum BanStep { + /// Nothing to fetch: the answer is already known. + Done(WarningLoad), + Fetch(BanPlan), +} + +/// The backend half of a load. +#[derive(Debug, Clone)] +pub enum BanFetch { + /// `hasApiKey` failed. + KeyLookupFailed(BackendError), + /// Steam has no API key. + NoApiKey { key_generation: Option }, + /// The key is there; `bans` is `None` when there was nothing to ask. + Fetched { + key_generation: Option, + bans: Option, BackendError>>, + }, +} + +/// `loadSteamWarningStates` up to the backend call. +pub fn plan( + session: &BanSession, + memo: &ApiKeyMemo, + stores: &ClientStores, + settings: &AppSettings, + accounts: &[PlatformAccount], + options: WarningLoadOptions, + now_ms: f64, +) -> BanStep { + let cached = || WarningLoad { + warnings: cached_warning_states(stores), + ..WarningLoad::default() + }; + if accounts.is_empty() { + return BanStep::Done(cached()); + } + if memo.cached() == Some(false) { + return BanStep::Done(WarningLoad { + outcome: Some(WarningRefreshOutcome::NoApiKey), + ..cached() + }); + } + let steam_ids = unique(accounts.iter().map(|account| account.id.clone())); + let delay_days = settings.data_refresh.ban_check_days; + let cached_state = read_ban_check_state(stores); + let delay_ms = delay_days as f64 * 24.0 * 60.0 * 60.0 * 1000.0; + let within_delay_window = delay_days > 0 + && cached_state + .as_ref() + .is_some_and(|state| now_ms - state.last_success_at < delay_ms); + let previously_checked = cached_state + .map(|state| state.checked_steam_ids) + .unwrap_or_default(); + let cached_ids: BTreeSet<&String> = previously_checked.iter().collect(); + let already = already_checked_ban_ids(options.force_refresh, delay_days, within_delay_window); + let is_checked = |id: &String| match already { + AlreadyChecked::Nothing => false, + AlreadyChecked::Session => session.checked.contains(id), + AlreadyChecked::Cached => cached_ids.contains(id), + }; + let ids_to_fetch: Vec = steam_ids + .into_iter() + .filter(|id| { + !is_checked(id) + && (options.force_refresh + || session.omissions.get(id).copied().unwrap_or(0) < BAN_OMISSIONS_BEFORE_SKIP) + }) + .collect(); + let key_lookup = memo.cached().is_none().then(|| memo.generation()); + if ids_to_fetch.is_empty() && key_lookup.is_none() { + return BanStep::Done(WarningLoad { + outcome: Some(WarningRefreshOutcome::Complete { checked: 0 }), + ..cached() + }); + } + BanStep::Fetch(BanPlan { + checking_toast: !options.silent && !ids_to_fetch.is_empty(), + ids_to_fetch, + key_lookup, + delay_days, + now_ms, + within_delay_window, + previously_checked, + force_refresh: options.force_refresh, + silent: options.silent, + }) +} + +/// The backend calls of a load: the key lookup when the memo is unknown, then +/// the bans. Runs on the job pool. +pub fn fetch(plan: &BanPlan, backend: &dyn Backend) -> BanFetch { + if plan.key_lookup.is_some() { + match backend.steam_has_api_key() { + Err(error) => return BanFetch::KeyLookupFailed(error), + Ok(false) => { + return BanFetch::NoApiKey { + key_generation: plan.key_lookup, + } + } + Ok(true) => {} + } + } + let bans = + (!plan.ids_to_fetch.is_empty()).then(|| backend.steam_get_player_bans(&plan.ids_to_fetch)); + BanFetch::Fetched { + key_generation: plan.key_lookup, + bans, + } +} + +/// `showBanErrorToast`: at most once per cooldown, never on a silent load. +fn error_toast(session: &mut BanSession, plan: &BanPlan, toasts: &mut Vec) { + if plan.silent || plan.now_ms - session.last_error_toast_at < BAN_ERROR_TOAST_COOLDOWN_MS { + return; + } + toasts.push(ToastRequest { + text: Text::key("toast.banCheckFailedGeneric"), + error: true, + }); + session.last_error_toast_at = plan.now_ms; +} + +/// `loadSteamWarningStates` after the backend call: session, memo and stores +/// updated, the map and outcome returned. +pub fn finish( + session: &mut BanSession, + memo: &mut ApiKeyMemo, + plan: &BanPlan, + fetched: BanFetch, + stores: &mut ClientStores, +) -> WarningLoad { + let mut cached_bans = read_ban_info_cache(stores); + let mut toasts = Vec::new(); + let outcome = match fetched { + BanFetch::KeyLookupFailed(_) => { + // Unknown is not absent: no "add a key" hint here. + error_toast(session, plan, &mut toasts); + WarningRefreshOutcome::Failed + } + BanFetch::NoApiKey { key_generation } => { + if let Some(generation) = key_generation { + memo.finish_read(generation, false); + } + WarningRefreshOutcome::NoApiKey + } + BanFetch::Fetched { + key_generation, + bans, + } => { + if let Some(generation) = key_generation { + memo.finish_read(generation, true); + } + match bans { + None => WarningRefreshOutcome::Complete { checked: 0 }, + Some(Err(_)) => { + error_toast(session, plan, &mut toasts); + WarningRefreshOutcome::Failed + } + Some(Ok(bans)) => { + apply_bans(session, plan, &bans, &mut cached_bans, stores, &mut toasts) + } + } + } + }; + WarningLoad { + warnings: to_warning_map(&cached_bans), + outcome: Some(outcome), + toasts, + } +} + +fn apply_bans( + session: &mut BanSession, + plan: &BanPlan, + bans: &[BanInfo], + cached_bans: &mut BTreeMap, + stores: &mut ClientStores, + toasts: &mut Vec, +) -> WarningRefreshOutcome { + // mergeBanRows: rows without a Steam id are unusable. + let mut banned_count = 0usize; + let mut returned = BTreeSet::new(); + for ban in bans { + if ban.steam_id.is_empty() { + continue; + } + cached_bans.insert(ban.steam_id.clone(), ban.clone()); + returned.insert(ban.steam_id.clone()); + if ban.vac_banned || ban.community_banned || ban.number_of_game_bans > 0 { + banned_count += 1; + } + } + // An omitted id is not a checked id: marking it would skip the retry. + let confirmed: Vec = plan + .ids_to_fetch + .iter() + .filter(|id| returned.contains(*id)) + .cloned() + .collect(); + for id in &plan.ids_to_fetch { + if returned.contains(id) { + session.checked.insert(id.clone()); + session.omissions.remove(id); + } else { + *session.omissions.entry(id.clone()).or_insert(0) += 1; + } + } + // recordBanCheckState. + if plan.delay_days == 0 { + stores + .storage_mut() + .set_immediate(CLIENT_STORE_STEAM_BAN_CHECK_STATE, Value::Null); + } else { + let covers_every_account = plan.force_refresh || !plan.within_delay_window; + let checked = if covers_every_account { + confirmed.clone() + } else { + unique(plan.previously_checked.iter().chain(&confirmed).cloned()) + }; + stores.storage_mut().set( + CLIENT_STORE_STEAM_BAN_CHECK_STATE, + json!({ "lastSuccessAt": plan.now_ms, "checkedSteamIds": checked }), + ); + } + write_ban_info_cache(stores, cached_bans); + if !plan.silent && banned_count > 0 { + let key = if banned_count > 1 { + "toast.banCheckSummary.multiple" + } else { + "toast.banCheckSummary.single" + }; + toasts.push(ToastRequest { + text: Text::with(key, [("count", banned_count)]), + error: false, + }); + } + if confirmed.len() == plan.ids_to_fetch.len() { + WarningRefreshOutcome::Complete { + checked: confirmed.len(), + } + } else { + WarningRefreshOutcome::Partial { + checked: confirmed.len(), + requested: plan.ids_to_fetch.len(), + } + } +} + +/// [`plan`], [`fetch`] and [`finish`] in one go, for callers that may block. +#[allow(clippy::too_many_arguments)] +pub fn load_blocking( + session: &mut BanSession, + memo: &mut ApiKeyMemo, + backend: &dyn Backend, + stores: &mut ClientStores, + settings: &AppSettings, + accounts: &[PlatformAccount], + options: WarningLoadOptions, + now_ms: f64, +) -> WarningLoad { + match plan(session, memo, stores, settings, accounts, options, now_ms) { + BanStep::Done(load) => load, + BanStep::Fetch(plan) => { + let fetched = fetch(&plan, backend); + finish(session, memo, &plan, fetched, stores) + } + } +} diff --git a/crates/accshift-app/src/platforms/warnings.rs b/crates/accshift-app/src/platforms/warnings.rs new file mode 100644 index 00000000..7e70a258 --- /dev/null +++ b/crates/accshift-app/src/platforms/warnings.rs @@ -0,0 +1,37 @@ +//! Account warning presentation, ported from `src/lib/shared/accountWarnings.ts`. + +use crate::text::Text; + +/// `AccountWarningTone`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WarningTone { + Red, + Orange, +} + +/// `AccountWarningChip`. +#[derive(Debug, Clone, PartialEq)] +pub struct WarningChip { + pub tone: WarningTone, + pub text: Text, +} + +/// `AccountWarningPresentation`: what a card, a list row and a tooltip show +/// for an account with a warning. +#[derive(Debug, Clone, PartialEq)] +pub struct WarningPresentation { + pub tooltip: Text, + pub card_outline_tone: Option, + pub list_has_red: bool, + pub list_has_orange: bool, + pub chips: Vec, +} + +/// What a warning load hands back: the map to show, how the load ended (the +/// TS `onSettled`, `None` when TS does not call it) and the toasts to show. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct WarningLoad { + pub warnings: std::collections::BTreeMap, + pub outcome: Option, + pub toasts: Vec, +} diff --git a/crates/accshift-app/src/real.rs b/crates/accshift-app/src/real.rs new file mode 100644 index 00000000..0d11f3ed --- /dev/null +++ b/crates/accshift-app/src/real.rs @@ -0,0 +1,561 @@ +//! The real backend: every command runs on `accshift-core` the way +//! `src-tauri/src/commands` does, on the same app directories. +//! +//! Part 1 of the port filled boot, storage, themes, PIN, descriptors and +//! utilities; part 2 filled platforms, Steam, the CS2 bridge, Riot, Roblox +//! and telemetry. Every command of [`crate::COMMANDS`] is in [`PORTED`]. +//! The state the Tauri app keeps with `.manage(..)` (`BootState`, +//! `PinSession`, the HTTP client, `TelemetryState`) lives in +//! [`RealBackend`]. Window work (show, backdrop subclass, geometry, the boot +//! failsafe) is the shell's. +//! +//! Every method blocks the calling thread, as the Tauri command body blocks +//! its blocking-pool thread: call them from `crate::jobs`, never the UI +//! thread. Switch, forget, setup, Steam tools, Riot capture and the Roblox +//! cookie add take the same cross-process lock file as the Tauri app and the +//! CLI (`accshift_core::lock`), so two apps can never switch together. + +mod app; +mod boot; +mod context; +mod descriptors; +mod pin; +mod platform; +mod riot; +mod roblox; +mod runtime; +mod steam; +mod telemetry; +mod utility; +mod wallpaper; + +pub use app::{UI_DETAILS_MAX_BYTES, UI_LOG_CAP}; +pub use boot::BootState; +pub use context::{EguiAppContext, BUNDLE_IDENTIFIER}; +pub use descriptors::{SELECT_FILTERS, SELECT_TITLE}; +pub use platform::busy_status; +pub use runtime::CANCEL_SETUP_LOCK_TIMEOUT; +pub use telemetry::{ + app_version, complete_mode_b_forget, process_pending_forgets, stage_mode_b_opt_out, + telemetry_install_ids, NO_ENDPOINT, SHUTDOWN_FLUSH_DEADLINE, SURFACE, +}; + +use crate::backend::Backend; +use crate::error::{BackendError, BackendResult}; +use crate::types::*; +use accshift_core::pin::PinSession; +use accshift_core::telemetry::Event; +use serde_json::Value; +use std::collections::BTreeMap; +use std::sync::atomic::AtomicU32; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +/// Cross-process lock budget of every mutating command (`LOCK_TIMEOUT` in +/// `src-tauri/src/commands/mod.rs`). +pub const LOCK_TIMEOUT: Duration = Duration::from_secs(2); + +/// The commands [`RealBackend`] runs for real: all of [`crate::COMMANDS`] +/// since part 2. +pub const PORTED: &[&str] = &[ + "get_boot_payload", + "get_runtime_os", + "detect_streaming_software", + "migrate_legacy_config", + "load_client_storage_snapshot", + "save_client_storage_store", + "get_storage_manifest", + "log_app_event", + "finish_boot", + "pin_unlock", + "pin_lock", + "reload_user_platforms", + "descriptor_select_file", + "descriptor_preview_file", + "descriptor_install_file", + "descriptor_remove", + "open_descriptors_folder", + "open_url", + "open_logs_folder", + "get_desktop_wallpaper", + "list_custom_themes", + "save_custom_theme", + "delete_custom_theme", + "platform_get_accounts", + "platform_get_startup_snapshot", + "platform_get_current_account", + "platform_switch_account", + "platform_forget_account", + "platform_begin_setup", + "platform_get_setup_status", + "platform_cancel_setup", + "platform_get_path", + "platform_set_path", + "platform_select_path", + "platform_detect_installed", + "platform_set_account_label", + "platform_dry_run", + "steam_set_api_key", + "steam_has_api_key", + "steam_open_api_key_page", + "steam_switch_account_and_launch_game", + "steam_get_profile_info", + "steam_get_profile_infos", + "steam_get_player_bans", + "steam_copy_game_settings", + "steam_get_copyable_games", + "steam_open_userdata", + "steam_clear_browser_cache", + "steam_bulk_edit", + "steam_get_account_games", + "cs2_bridge_get_settings", + "cs2_bridge_set_settings", + "cs2_bridge_fetch", + "cs2_bridge_check", + "cs2_bridge_test", + "riot_capture_profile", + "roblox_add_account_by_cookie", + "roblox_get_profile_info", + "roblox_check_sessions", + "telemetry_get_state", + "telemetry_set_mode_a", + "telemetry_set_mode_b", + "telemetry_retry_forget", + "telemetry_complete_onboarding", + "telemetry_track_persona_switch", + "telemetry_track_account_added", + "telemetry_track_account_add_started", + "telemetry_track_account_add_cancelled", + "telemetry_track_operation_failed", + "telemetry_track_update", + "telemetry_track_settings_snapshot", + "telemetry_track_streamer_mode", + "telemetry_export", +]; + +/// See the module documentation. One per process: the PIN session, the boot +/// state and the UI log cap are per instance. +pub struct RealBackend { + ctx: accshift_core::AppCtx, + boot: BootState, + pin: PinSession, + http: OnceLock, + ui_logs_written: AtomicU32, + telemetry: telemetry::Telemetry, +} + +impl RealBackend { + /// On the user's real app directories, with the HTTP client built up + /// front like the Tauri app does before its first plugin, and telemetry + /// pointed at the build-time endpoint + /// (`accshift_core::telemetry::TELEMETRY_URL`). The queue itself starts + /// with [`Self::start_telemetry`]. + pub fn new() -> Result { + let backend = Self::build( + Arc::new(EguiAppContext::new()?), + Some(accshift_core::telemetry::TELEMETRY_URL.to_string()), + ); + backend.http_client()?; + Ok(backend) + } + + /// On any directory layout (tests, a parity scratch folder). The HTTP + /// client is built on first use. No telemetry endpoint: nothing is ever + /// sent, and `/forget` or `/export` answer [`NO_ENDPOINT`]. + pub fn with_context(ctx: accshift_core::AppCtx) -> Self { + Self::build(ctx, None) + } + + fn build(ctx: accshift_core::AppCtx, telemetry_endpoint: Option) -> Self { + Self { + ctx, + boot: BootState::default(), + pin: PinSession::new(), + http: OnceLock::new(), + ui_logs_written: AtomicU32::new(0), + telemetry: telemetry::Telemetry::new(telemetry_endpoint), + } + } + + pub fn context(&self) -> &accshift_core::AppCtx { + &self.ctx + } + + /// The boot state `finish_boot` marks; the shell reads it for its + /// failsafe. + pub fn boot_state(&self) -> &BootState { + &self.boot + } + + /// The PIN session every switch command checks + /// (`PinSession::ensure_unlocked`). + pub fn pin_session(&self) -> &PinSession { + &self.pin + } + + /// The shared HTTP client (`boot::build_http_client`). + pub fn http_client(&self) -> Result<&reqwest::Client, String> { + if let Some(client) = self.http.get() { + return Ok(client); + } + let client = boot::build_http_client()?; + Ok(self.http.get_or_init(|| client)) + } +} + +impl Backend for RealBackend { + fn get_boot_payload(&self) -> BackendResult { + app::boot_payload(&self.ctx) + } + fn get_runtime_os(&self) -> BackendResult { + Ok(app::runtime_os()) + } + fn detect_streaming_software(&self) -> BackendResult { + Ok(utility::streaming_software_running()) + } + fn migrate_legacy_config(&self) -> BackendResult { + Ok(app::migrate_legacy_config(&self.ctx)) + } + fn load_client_storage_snapshot(&self) -> BackendResult { + app::load_snapshot(&self.ctx) + } + fn save_client_storage_store( + &self, + store_id: &str, + value: &Value, + ) -> BackendResult> { + app::save_store(self, store_id, value) + } + fn get_storage_manifest(&self) -> BackendResult { + app::manifest(&self.ctx) + } + fn log_app_event( + &self, + level: &str, + source: &str, + message: &str, + details: Option<&str>, + ) -> BackendResult<()> { + app::log_app_event(self, level, source, message, details) + } + fn finish_boot(&self, source: &str, marks: Option<&Value>) -> BackendResult<()> { + app::finish_boot(self, source, marks) + } + fn avatar_cache_dir(&self) -> Option { + self.ctx.app_cache_dir().ok().map(|dir| dir.join("avatars")) + } + /// Only the shipped `accshift-gui` release exe writes the Run value, the + /// same guard the scheme registration uses: a debug or renamed build + /// would register a path the installed app never launches. + #[cfg(windows)] + fn set_autostart(&self, enabled: bool) -> BackendResult<()> { + use crate::lifecycle::autostart; + if !autostart::manages_current_exe() { + return Ok(()); + } + autostart::apply(enabled).map_err(BackendError::new) + } + fn pin_unlock(&self, code: &str) -> BackendResult { + pin::unlock(self, code) + } + fn pin_lock(&self) -> BackendResult<()> { + pin::lock(self); + Ok(()) + } + fn platform_get_accounts(&self, platform_id: &str) -> BackendResult { + platform::get_accounts(self, platform_id) + } + fn platform_get_startup_snapshot(&self, platform_id: &str) -> BackendResult { + platform::get_startup_snapshot(self, platform_id) + } + fn platform_get_current_account(&self, platform_id: &str) -> BackendResult { + platform::get_current_account(self, platform_id) + } + fn platform_switch_account( + &self, + platform_id: &str, + account_id: &str, + params: &Value, + ) -> BackendResult<()> { + platform::switch_account(self, platform_id, account_id, params) + } + fn platform_forget_account(&self, platform_id: &str, account_id: &str) -> BackendResult<()> { + platform::forget_account(self, platform_id, account_id) + } + /// Blocks while the platform's launcher is stopped and its session + /// cleared; the egui wizard then polls `platform_get_setup_status`. + fn platform_begin_setup( + &self, + platform_id: &str, + params: &Value, + ) -> BackendResult { + platform::begin_setup(self, platform_id, params) + } + fn platform_get_setup_status( + &self, + platform_id: &str, + setup_id: &str, + ) -> BackendResult { + platform::get_setup_status(self, platform_id, setup_id) + } + fn platform_cancel_setup(&self, platform_id: &str, setup_id: &str) -> BackendResult<()> { + platform::cancel_setup(self, platform_id, setup_id) + } + fn platform_get_path(&self, platform_id: &str) -> BackendResult { + platform::get_path(self, platform_id) + } + fn platform_set_path(&self, platform_id: &str, path: &str) -> BackendResult<()> { + platform::set_path(self, platform_id, path) + } + /// The platform's folder dialog: same thread rule as + /// [`Self::descriptor_select_file`]. + fn platform_select_path(&self, platform_id: &str) -> BackendResult { + platform::select_path(platform_id) + } + fn platform_detect_installed(&self) -> BackendResult> { + Ok(platform::detect_installed(self)) + } + fn platform_set_account_label( + &self, + platform_id: &str, + account_id: &str, + label: &str, + ) -> BackendResult<()> { + platform::set_account_label(self, platform_id, account_id, label) + } + fn platform_dry_run(&self, platform_id: &str, account_id: &str) -> BackendResult { + platform::dry_run(self, platform_id, account_id) + } + fn reload_user_platforms(&self) -> BackendResult { + Ok(descriptors::reload(&self.ctx)) + } + /// Thread constraint: the `rfd` dialog runs on the thread that calls this + /// and blocks it until the user picks or cancels, so the egui shell + /// chooses that thread. On Windows and Linux (xdg portal) any thread + /// works: give it a job thread, never the UI thread, or the window + /// freezes behind the dialog. On macOS `rfd` hops to the main thread + /// itself, so the UI thread must keep running its event loop while the + /// job waits. The dialog has no parent window: it is not modal to ours. + fn descriptor_select_file(&self) -> BackendResult { + descriptors::select_file() + } + fn descriptor_preview_file(&self, path: &str) -> BackendResult { + descriptors::preview(&self.ctx, path) + } + fn descriptor_install_file(&self, path: &str) -> BackendResult { + descriptors::install(&self.ctx, path) + } + fn descriptor_remove(&self, platform_id: &str) -> BackendResult { + descriptors::remove(&self.ctx, platform_id) + } + fn open_descriptors_folder(&self) -> BackendResult<()> { + descriptors::open_folder(&self.ctx) + } + fn open_url(&self, url: &str) -> BackendResult<()> { + utility::open_url(url) + } + fn open_logs_folder(&self) -> BackendResult<()> { + utility::open_logs_folder(&self.ctx) + } + /// Captures on the calling thread (a job thread): see + /// `real/wallpaper.rs` for how that differs from the Tauri command. + fn get_desktop_wallpaper(&self) -> BackendResult> { + Ok(wallpaper::desktop_wallpaper()) + } + fn steam_set_api_key(&self, key: &str) -> BackendResult<()> { + steam::set_api_key(self, key) + } + fn steam_has_api_key(&self) -> BackendResult { + Ok(steam::has_api_key(self)) + } + fn steam_open_api_key_page(&self) -> BackendResult<()> { + steam::open_api_key_page() + } + fn steam_switch_account_and_launch_game( + &self, + username: &str, + app_id: &str, + run_as_admin: bool, + launch_options: &str, + shutdown_mode: &str, + ) -> BackendResult<()> { + steam::switch_and_launch_game( + self, + username, + app_id, + run_as_admin, + launch_options, + shutdown_mode, + ) + } + fn steam_get_profile_info(&self, steam_id: &str) -> BackendResult> { + steam::profile_info(self, steam_id) + } + fn steam_get_profile_infos( + &self, + steam_ids: &[String], + ) -> BackendResult> { + steam::profile_infos(self, steam_ids) + } + fn steam_get_player_bans(&self, steam_ids: &[String]) -> BackendResult> { + steam::player_bans(self, steam_ids) + } + fn steam_copy_game_settings( + &self, + from_steam_id: &str, + to_steam_id: &str, + app_id: &str, + ) -> BackendResult<()> { + steam::copy_game_settings(self, from_steam_id, to_steam_id, app_id) + } + fn steam_get_copyable_games( + &self, + from_steam_id: &str, + to_steam_id: &str, + ) -> BackendResult> { + steam::copyable_games(self, from_steam_id, to_steam_id) + } + fn steam_open_userdata(&self, steam_id: &str) -> BackendResult<()> { + steam::open_userdata(self, steam_id) + } + fn steam_clear_browser_cache(&self) -> BackendResult<()> { + steam::clear_browser_cache(self) + } + fn steam_bulk_edit(&self, request: BulkEditRequest) -> BackendResult { + steam::bulk_edit(self, request) + } + fn steam_get_account_games(&self, steam_id: &str) -> BackendResult> { + steam::account_games(self, steam_id) + } + fn cs2_bridge_get_settings(&self) -> BackendResult { + Ok(steam::cs2_get_settings(self)) + } + fn cs2_bridge_set_settings( + &self, + enabled: bool, + url: &str, + token: Option<&str>, + ) -> BackendResult<()> { + steam::cs2_set_settings(self, enabled, url, token) + } + fn cs2_bridge_fetch(&self) -> BackendResult> { + steam::cs2_fetch(self) + } + fn cs2_bridge_check(&self, steam_id: &str) -> BackendResult> { + steam::cs2_check(self, steam_id) + } + fn cs2_bridge_test(&self) -> BackendResult { + steam::cs2_test(self) + } + fn riot_capture_profile(&self, profile_id: &str) -> BackendResult<()> { + riot::capture_profile(self, profile_id) + } + fn roblox_add_account_by_cookie(&self, cookie: &str) -> BackendResult { + roblox::add_account_by_cookie(self, cookie) + } + fn roblox_get_profile_info(&self, user_id: &str) -> BackendResult { + roblox::profile_info(self, user_id) + } + fn roblox_check_sessions(&self) -> BackendResult> { + roblox::check_sessions(self) + } + fn list_custom_themes(&self) -> BackendResult> { + utility::list_themes(&self.ctx) + } + fn save_custom_theme(&self, theme: &CustomTheme) -> BackendResult<()> { + utility::save_theme(&self.ctx, theme) + } + fn delete_custom_theme(&self, theme_id: &str) -> BackendResult<()> { + utility::delete_theme(&self.ctx, theme_id) + } + fn telemetry_get_state(&self) -> BackendResult { + Ok(telemetry::get_state(&*self.ctx)) + } + fn telemetry_set_mode_a(&self, enabled: bool) -> BackendResult<()> { + telemetry::set_mode_a(self, enabled) + } + fn telemetry_set_mode_b(&self, enabled: bool) -> BackendResult<()> { + telemetry::set_mode_b(self, enabled) + } + fn telemetry_retry_forget(&self) -> BackendResult<()> { + telemetry::retry_forget(self) + } + fn telemetry_complete_onboarding( + &self, + mode_a_enabled: bool, + mode_b_enabled: bool, + ) -> BackendResult<()> { + telemetry::complete_onboarding(self, mode_a_enabled, mode_b_enabled) + } + /// The `telemetry_track_*` commands (commands/telemetry.rs lines + /// 192-318) only queue; the queue drops whatever the consent refuses. + fn telemetry_track_persona_switch(&self, platforms: u64, succeeded: u64) -> BackendResult<()> { + self.telemetry.track(Event::PersonaSwitch { + platforms, + succeeded, + }); + Ok(()) + } + fn telemetry_track_account_added(&self, platform_id: &str) -> BackendResult<()> { + self.telemetry.track(Event::AccountAdded { + platform: platform_id.to_string(), + }); + Ok(()) + } + fn telemetry_track_account_add_started(&self, platform_id: &str) -> BackendResult<()> { + self.telemetry.track(Event::AccountAddStarted { + platform: platform_id.to_string(), + }); + Ok(()) + } + fn telemetry_track_account_add_cancelled(&self, platform_id: &str) -> BackendResult<()> { + self.telemetry.track(Event::AccountAddCancelled { + platform: platform_id.to_string(), + }); + Ok(()) + } + fn telemetry_track_operation_failed( + &self, + operation: &str, + error_code: &str, + platform_id: Option<&str>, + ) -> BackendResult<()> { + self.telemetry.track(Event::OperationFailed { + operation: operation.to_string(), + platform: platform_id.map(str::to_string), + error_code: error_code.to_string(), + }); + Ok(()) + } + fn telemetry_track_update( + &self, + stage: &str, + target_version: Option<&str>, + error_code: Option<&str>, + ) -> BackendResult<()> { + if let Some(event) = telemetry::update_event(stage, target_version, error_code) { + self.telemetry.track(event); + } + Ok(()) + } + fn telemetry_track_settings_snapshot(&self, snapshot: &SettingsSnapshot) -> BackendResult<()> { + self.telemetry + .track(telemetry::settings_snapshot_event(snapshot)); + Ok(()) + } + fn telemetry_track_streamer_mode(&self) -> BackendResult<()> { + self.telemetry.track(Event::StreamerModeActivated); + Ok(()) + } + fn telemetry_export(&self) -> BackendResult { + telemetry::export(self) + } +} + +/// What the Tauri app answers for a command it does not register on this OS +/// (`Command not found`, tauri `webview/mod.rs`): the Riot and Roblox +/// commands off Windows. +#[cfg_attr(windows, allow(dead_code))] +pub(crate) fn not_registered(command: &str) -> BackendError { + BackendError::new(format!("Command {command} not found")) +} diff --git a/crates/accshift-app/src/real/app.rs b/crates/accshift-app/src/real/app.rs new file mode 100644 index 00000000..484bbf4c --- /dev/null +++ b/crates/accshift-app/src/real/app.rs @@ -0,0 +1,177 @@ +//! App lifecycle commands: boot payload, logs, `finish_boot` and client +//! storage. Port of `src-tauri/src/commands/app.rs`. + +use super::{RealBackend, LOCK_TIMEOUT}; +use crate::error::BackendResult; +use crate::types::{BootPayload, ClientStorageSnapshot, StorageManifest}; +use accshift_core::error::PlatformError; +use accshift_core::{config, logging, platforms, storage, themes, AppContext}; +use serde_json::Value; +use std::sync::atomic::Ordering; + +/// Per-session ceiling on UI-originated log records (`WEBVIEW_LOG_CAP`). +pub const UI_LOG_CAP: u32 = 2_000; + +/// UI log details are cut to this many bytes before redaction runs +/// (`WEBVIEW_DETAILS_MAX_BYTES`). +pub const UI_DETAILS_MAX_BYTES: usize = 4_096; + +/// `get_runtime_os`, app.rs lines 6-9. +pub(super) fn runtime_os() -> String { + std::env::consts::OS.to_string() +} + +/// `migrate_legacy_config_inner`, app.rs lines 26-32. +pub(super) fn migrate_legacy_config(c: &dyn AppContext) -> String { + match config::migrate_legacy_config(c) { + None => "none".to_string(), + Some(Ok(())) => "migrated".to_string(), + Some(Err(e)) => format!("error:{e}"), + } +} + +/// `get_boot_payload`, app.rs lines 51-74. +pub(super) fn boot_payload(c: &dyn AppContext) -> BackendResult { + // Migration must land before anything reads config or stores. + let migration = migrate_legacy_config(c); + let storage_snapshot = storage::load_client_storage_snapshot(c).map_err(PlatformError::from)?; + // A missing themes dir is normal on a first run. + let custom_themes = themes::list_custom_themes(c).unwrap_or_default(); + // A rejected descriptor is reported, never fatal. + let user_platforms = platforms::reload_user_platforms(c); + Ok(BootPayload { + migration, + runtime_os: runtime_os(), + storage_snapshot, + custom_themes, + user_platforms, + }) +} + +/// `log_app_event`, app.rs lines 84-117. The cap counts per backend +/// instance, which is one per process like the Tauri command's static. +pub(super) fn log_app_event( + backend: &RealBackend, + level: &str, + source: &str, + message: &str, + details: Option<&str>, +) -> BackendResult<()> { + let written = backend.ui_logs_written.fetch_add(1, Ordering::Relaxed); + if written >= UI_LOG_CAP { + if written == UI_LOG_CAP { + let _ = logging::append_app_log( + &backend.ctx, + "warn", + "logging", + "Webview log cap reached; dropping further webview records this session", + None, + ); + } + return Ok(()); + } + let details = details + .map(|text| accshift_core::diagnostics::redact::trim_text(text, UI_DETAILS_MAX_BYTES)); + logging::append_app_log(&backend.ctx, level, source, message, details.as_deref())?; + Ok(()) +} + +/// `finish_boot`, app.rs lines 119-170, minus `show_main_window` (the egui +/// shell shows its own window). +pub(super) fn finish_boot( + backend: &RealBackend, + source: &str, + marks: Option<&Value>, +) -> BackendResult<()> { + let was_first_completion = backend.boot.mark_completed(); + if was_first_completion { + if let Some(marks) = marks { + accshift_core::diagnostics::event( + &accshift_core::diagnostics::catalog::STARTUP_FRONTEND, + ) + .source("frontend.boot") + .msg("Frontend startup profile") + .field("marks", marks.clone()) + .field("trigger", source.to_string()) + .emit(&backend.ctx); + } + } + let message = if was_first_completion { + "Boot completed" + } else { + "Boot completion requested again" + }; + let _ = logging::append_app_log(&backend.ctx, "info", source, message, None); + if was_first_completion { + backend.after_first_boot(); + } + Ok(()) +} + +/// `load_client_storage_snapshot`, app.rs lines 243-263. +pub(super) fn load_snapshot(c: &dyn AppContext) -> BackendResult { + let snapshot = storage::load_client_storage_snapshot(c)?; + let details = serde_json::json!({ + "storeCount": snapshot.stores.len(), + "manifestCount": snapshot.manifest.stores.len(), + "schemaVersion": snapshot.manifest.schema_version, + }) + .to_string(); + let _ = logging::append_app_log( + c, + "info", + "storage.load_snapshot", + "Loaded client storage snapshot", + Some(&details), + ); + Ok(snapshot) +} + +/// `save_client_storage_store`, app.rs lines 265-299. +pub(super) fn save_store( + backend: &RealBackend, + store_id: &str, + value: &Value, +) -> BackendResult> { + let c = &backend.ctx; + // The cross-process lock config writes take, held across the write. + let _write_lock = + accshift_core::lock::acquire_for_write(c, LOCK_TIMEOUT).map_err(|e| e.to_string())?; + // The PIN lives in this store: while the session is locked, a write may + // not turn it off or replace it. + if store_id == storage::STORE_SETTINGS { + backend.pin.check_settings_write(c, value)?; + } + let fingerprint = storage::save_client_store(c, store_id, value)?; + let details = serde_json::json!({ + "storeId": store_id, + "isNull": value.is_null(), + }) + .to_string(); + let _ = logging::append_app_log( + c, + "info", + "storage.save_store", + "Saved client storage store", + Some(&details), + ); + Ok(Some(fingerprint)) +} + +/// `get_storage_manifest`, app.rs lines 301-320. +pub(super) fn manifest(c: &dyn AppContext) -> BackendResult { + let manifest = storage::build_storage_manifest(c)?; + let details = serde_json::json!({ + "storeCount": manifest.stores.len(), + "schemaVersion": manifest.schema_version, + }) + .to_string(); + let _ = logging::append_app_log( + c, + "info", + "storage.get_manifest", + "Built storage manifest", + Some(&details), + ); + Ok(manifest) +} diff --git a/crates/accshift-app/src/real/boot.rs b/crates/accshift-app/src/real/boot.rs new file mode 100644 index 00000000..d73f3ba3 --- /dev/null +++ b/crates/accshift-app/src/real/boot.rs @@ -0,0 +1,237 @@ +//! What `src-tauri/src/lib.rs` (`run`, the `setup` closure) and +//! `src-tauri/src/boot.rs` do for the backend, without the window: the boot +//! state, the log session and panic hook, the startup maintenance thread and +//! the shared HTTP client. The window steps (`build_main_window`, the close +//! handler, the geometry saver, the boot failsafe that shows the window) and +//! the deep links belong to the egui shell. + +use super::RealBackend; +use accshift_core::{logging, AppCtx}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +/// `app_runtime::BootState`: whether the UI reported its first frame done. +#[derive(Debug, Default)] +pub struct BootState { + completed: AtomicBool, +} + +impl BootState { + /// True the first time only. + pub fn mark_completed(&self) -> bool { + !self.completed.swap(true, Ordering::SeqCst) + } + + pub fn is_completed(&self) -> bool { + self.completed.load(Ordering::SeqCst) + } +} + +/// `boot::build_http_client`, boot.rs lines 117-133: 10 s per request, 5 s to +/// connect. The Tauri app exits when it cannot build one; here the caller +/// gets the error. +pub(super) fn build_http_client() -> Result { + accshift_core::http::client_builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .map_err(|e| format!("Fatal: failed to create HTTP client: {e}")) +} + +impl RealBackend { + /// The steps of the Tauri `setup` closure that only need the app + /// directories (lib.rs lines 121-153): rotate the log chain, write + /// "App setup started", install the panic hook. Call once per process, + /// before the first frame; tests never call it (it replaces the + /// process-wide panic hook). + pub fn begin_session(&self) { + let _ = logging::begin_log_session(&self.ctx); + let _ = logging::append_app_log( + &self.ctx, + "info", + "backend.startup", + "App setup started", + None, + ); + logging::install_panic_hook(self.ctx.clone()); + } + + /// `boot::spawn_snapshot_upgrade` (boot.rs lines 747-858) and the Riot + /// rollback sweep (lines 860-885): arms the keyring index, then on a + /// background thread re-encrypts legacy plaintext snapshots, purges the + /// plaintext backups, sweeps Riot rollback copies, deletes the Tauri + /// app's WebView2 caches and frees orphaned keyring entries, each locked + /// step under the cross-process write lock. Call + /// once after [`Self::begin_session`]. Touches the real keyring, so + /// tests never call it. + pub fn spawn_maintenance(&self) -> std::thread::JoinHandle<()> { + spawn_snapshot_upgrade(self.ctx.clone()) + } + + /// The telemetry half of `finish_boot` (app.rs lines 153-167: + /// `emit_first_run_once`, `AppLaunched`, `emit_accounts_snapshots`), in + /// `real/telemetry.rs`. + pub(super) fn after_first_boot(&self) { + self.record_first_boot(); + } +} + +fn spawn_snapshot_upgrade(upgrade_ctx: AppCtx) -> std::thread::JoinHandle<()> { + accshift_core::secrets::init(&upgrade_ctx); + std::thread::spawn(move || { + let locked = |step: &str| match accshift_core::lock::acquire_for_write( + &upgrade_ctx, + Duration::from_secs(2), + ) { + Ok(guard) => Some(guard), + Err(error) => { + let _ = logging::append_app_log( + &upgrade_ctx, + "warn", + "backend.snapshot-upgrade", + &format!("Skipped {step} because the store could not be locked"), + Some(&error.to_string()), + ); + None + } + }; + + let mut failures: Vec = Vec::new(); + let mut stats = accshift_core::snapshot_crypto::LegacyUpgradeStats::default(); + for platform_id in accshift_core::snapshot_crypto::SNAPSHOT_PLATFORM_IDS { + let Some(_operation) = locked("snapshot upgrade") else { + return; + }; + stats.merge( + accshift_core::snapshot_crypto::upgrade_legacy_plaintext_platform( + &upgrade_ctx, + platform_id, + &mut |message, detail| failures.push(format!("{message} ({detail})")), + ), + ); + } + if stats.touched_anything() { + let level = if stats.failed > 0 { "warn" } else { "info" }; + let _ = logging::append_app_log( + &upgrade_ctx, + level, + "backend.snapshot-upgrade", + &format!( + "Re-encrypted {} legacy plaintext snapshot file(s), {} failed", + stats.upgraded, stats.failed + ), + (!failures.is_empty()) + .then(|| failures.join("; ")) + .as_deref(), + ); + } + + if let Some(_operation) = locked("snapshot backup purge") { + let mut purge_failures: Vec = Vec::new(); + let purged = accshift_core::storage::purge_migrated_snapshot_backups( + &upgrade_ctx, + &mut |message, detail| purge_failures.push(format!("{message} ({detail})")), + ); + if purged > 0 || !purge_failures.is_empty() { + let _ = logging::append_app_log( + &upgrade_ctx, + if purge_failures.is_empty() { + "info" + } else { + "warn" + }, + "backend.snapshot-upgrade", + &format!("Deleted {purged} plaintext pre-migration snapshot backup(s)"), + (!purge_failures.is_empty()) + .then(|| purge_failures.join("; ")) + .as_deref(), + ); + } + } + + if let Some(_operation) = locked("rollback sweep") { + sweep_riot_rollback_copies(&upgrade_ctx); + } + + prune_webview_caches(&upgrade_ctx); + + let Some(_operation) = locked("keyring sweep") else { + return; + }; + let mut sweep_failures: Vec = Vec::new(); + let swept = accshift_core::secrets::gc(&upgrade_ctx, &mut |message, detail| { + sweep_failures.push(format!("{message} ({detail})")) + }); + if swept.touched_anything() { + let level = if swept.failed > 0 { "warn" } else { "info" }; + let _ = logging::append_app_log( + &upgrade_ctx, + level, + "backend.secrets-gc", + &format!( + "Freed {} orphaned keyring entry(ies), {} failed", + swept.freed, swept.failed + ), + (!sweep_failures.is_empty()) + .then(|| sweep_failures.join("; ")) + .as_deref(), + ); + } + }) +} + +#[cfg(windows)] +fn sweep_riot_rollback_copies(upgrade_ctx: &AppCtx) { + let mut failures: Vec = Vec::new(); + let swept = accshift_core::platforms::riot::sweep_rollback_dirs(upgrade_ctx, &mut |m, d| { + failures.push(format!("{m} ({d})")) + }); + if !swept.touched_anything() { + return; + } + let level = if swept.failed > 0 { "warn" } else { "info" }; + let _ = logging::append_app_log( + upgrade_ctx, + level, + "backend.riot-rollback-sweep", + &format!( + "Removed {} leftover Riot rollback copy(ies), {} failed", + swept.removed, swept.failed + ), + (!failures.is_empty()) + .then(|| failures.join("; ")) + .as_deref(), + ); +} + +#[cfg(not(windows))] +fn sweep_riot_rollback_copies(_upgrade_ctx: &AppCtx) {} + +/// The Tauri app's WebView2 caches, dead weight since the egui app +/// (`legacy_web_storage::prune_webview2_caches`). +#[cfg(windows)] +fn prune_webview_caches(upgrade_ctx: &AppCtx) { + use accshift_core::AppContext; + let Ok(local) = upgrade_ctx.app_local_data_dir() else { + return; + }; + let (freed, failures) = crate::legacy_web_storage::prune_webview2_caches(&local); + if freed == 0 && failures.is_empty() { + return; + } + let _ = logging::append_app_log( + upgrade_ctx, + if failures.is_empty() { "info" } else { "warn" }, + "backend.webview-cache-prune", + &format!( + "Deleted {:.1} MB of WebView2 cache left by the Tauri app", + freed as f64 / 1e6 + ), + (!failures.is_empty()) + .then(|| failures.join("; ")) + .as_deref(), + ); +} + +#[cfg(not(windows))] +fn prune_webview_caches(_upgrade_ctx: &AppCtx) {} diff --git a/crates/accshift-app/src/real/context.rs b/crates/accshift-app/src/real/context.rs new file mode 100644 index 00000000..1b12f3c9 --- /dev/null +++ b/crates/accshift-app/src/real/context.rs @@ -0,0 +1,64 @@ +//! Where the real backend reads and writes: the Tauri app's own folders. + +use accshift_core::AppContext; +use std::path::PathBuf; + +/// Must match `identifier` in `crates/accshift-egui/tauri.conf.json`, like the CLI's. +pub const BUNDLE_IDENTIFIER: &str = "com.accshift.desktop"; + +/// The app directories exactly as Tauri's path resolver computes them +/// (`tauri-2.11/src/path/desktop.rs`): `dirs::config_dir()`, +/// `dirs::data_dir()`, `dirs::data_local_dir()` and `dirs::cache_dir()`, each +/// joined with the bundle identifier. Same `dirs` major as Tauri, so config, +/// state, stores, themes, descriptors and logs stay shared with the Tauri app +/// and the CLI. +/// +/// | | Windows | Linux | macOS | +/// |---|---|---|---| +/// | config | `%APPDATA%\` | `$XDG_CONFIG_HOME/` or `~/.config/` | `~/Library/Application Support/` | +/// | data | `%APPDATA%\` | `$XDG_DATA_HOME/` or `~/.local/share/` | `~/Library/Application Support/` | +/// | local data | `%LOCALAPPDATA%\` | same as data | `~/Library/Application Support/` | +/// | cache | `%LOCALAPPDATA%\` | `$XDG_CACHE_HOME/` or `~/.cache/` | `~/Library/Caches/` | +/// +/// Logs are not Tauri's `app_log_dir`: the app writes them under +/// `accshift_core::storage::app_log_root`, `/logs` (`/logs/dev` +/// in a debug build). +#[derive(Debug, Clone)] +pub struct EguiAppContext { + identifier: String, +} + +impl EguiAppContext { + /// Never fails today: as with Tauri's resolver, a missing home folder + /// surfaces when a directory is asked for. `Result` keeps the skeleton's + /// signature. + pub fn new() -> Result { + Ok(Self { + identifier: BUNDLE_IDENTIFIER.to_string(), + }) + } + + fn resolve(&self, what: &str, base: Option) -> Result { + // Same message shape as `TauriAppContext::resolve`. + base.map(|dir| dir.join(&self.identifier)) + .ok_or_else(|| format!("Could not resolve app {what} dir: unknown path")) + } +} + +impl AppContext for EguiAppContext { + fn app_config_dir(&self) -> Result { + self.resolve("config", dirs::config_dir()) + } + + fn app_data_dir(&self) -> Result { + self.resolve("data", dirs::data_dir()) + } + + fn app_local_data_dir(&self) -> Result { + self.resolve("local data", dirs::data_local_dir()) + } + + fn app_cache_dir(&self) -> Result { + self.resolve("cache", dirs::cache_dir()) + } +} diff --git a/crates/accshift-app/src/real/descriptors.rs b/crates/accshift-app/src/real/descriptors.rs new file mode 100644 index 00000000..ed3f24d2 --- /dev/null +++ b/crates/accshift-app/src/real/descriptors.rs @@ -0,0 +1,60 @@ +//! User descriptors: the folder is the truth, these read it, preview a file, +//! install or remove one. Port of `src-tauri/src/commands/descriptors.rs`. + +use crate::error::BackendResult; +use crate::types::{DescriptorPreview, UserPlatformReport}; +use accshift_core::error::{AppError, PlatformError}; +use accshift_core::platforms::descriptor::library; +use accshift_core::{os, platforms, AppContext}; +use std::path::Path; + +/// Title and filters of the Tauri picker (descriptors.rs lines 25-34, the +/// `accshift_core::os::select_file` arguments +/// `"Platform descriptor (*.json)|*.json|All files (*.*)|*.*"`). +pub const SELECT_TITLE: &str = "Select a platform descriptor"; +pub const SELECT_FILTERS: &[(&str, &[&str])] = + &[("Platform descriptor", &["json"]), ("All files", &["*"])]; + +/// `reload_user_platforms`, descriptors.rs lines 6-19. +pub(super) fn reload(c: &dyn AppContext) -> UserPlatformReport { + platforms::reload_user_platforms(c) +} + +/// `descriptor_select_file`, descriptors.rs lines 21-34, on `rfd` instead of +/// the PowerShell `OpenFileDialog`. Cancelling answers `Dialog cancelled` +/// (`AppError::Cancelled`), as the Tauri command does. +pub(super) fn select_file() -> BackendResult { + let mut dialog = rfd::FileDialog::new().set_title(SELECT_TITLE); + for (name, extensions) in SELECT_FILTERS { + dialog = dialog.add_filter(*name, extensions); + } + match dialog.pick_file() { + Some(path) => Ok(path.display().to_string()), + None => Err(PlatformError::from(AppError::Cancelled).into()), + } +} + +/// `descriptor_preview_file`, descriptors.rs lines 36-49. +pub(super) fn preview(c: &dyn AppContext, path: &str) -> BackendResult { + library::preview_file(c, Path::new(path)) + .map_err(|e| PlatformError::other(e.to_string()).into()) +} + +/// `descriptor_install_file`, descriptors.rs lines 51-68. +pub(super) fn install(c: &dyn AppContext, path: &str) -> BackendResult { + library::install_file(c, Path::new(path)).map_err(PlatformError::other)?; + Ok(reload(c)) +} + +/// `descriptor_remove`, descriptors.rs lines 70-83. +pub(super) fn remove(c: &dyn AppContext, platform_id: &str) -> BackendResult { + library::remove(c, platform_id).map_err(PlatformError::other)?; + Ok(reload(c)) +} + +/// `open_descriptors_folder`, descriptors.rs lines 85-92: creates the folder +/// first, then reveals it. +pub(super) fn open_folder(c: &dyn AppContext) -> BackendResult<()> { + let dir = library::ensure_user_dir(c).map_err(PlatformError::other)?; + os::open_folder(&dir).map_err(|e| PlatformError::from(e).into()) +} diff --git a/crates/accshift-app/src/real/pin.rs b/crates/accshift-app/src/real/pin.rs new file mode 100644 index 00000000..82adac98 --- /dev/null +++ b/crates/accshift-app/src/real/pin.rs @@ -0,0 +1,33 @@ +//! The PIN lock. Port of `src-tauri/src/commands/pin.rs`. + +use super::RealBackend; +use crate::error::BackendResult; +use crate::types::PinUnlockResult; +use accshift_core::pin::UnlockOutcome; + +/// `impl From for PinUnlockResult`, pin.rs lines 25-51. +pub(super) fn unlock_result(outcome: UnlockOutcome) -> PinUnlockResult { + let ms = |d: std::time::Duration| d.as_millis().min(u128::from(u64::MAX)) as u64; + let (status, legacy, retry_after_ms) = match outcome { + UnlockOutcome::Unlocked { legacy } => ("unlocked", legacy, 0), + UnlockOutcome::Invalid { retry_after } => ("invalid", false, ms(retry_after)), + UnlockOutcome::RetryLater { retry_after } => ("retry_later", false, ms(retry_after)), + UnlockOutcome::NotConfigured => ("not_configured", false, 0), + }; + PinUnlockResult { + status: status.to_string(), + legacy, + retry_after_ms, + } +} + +/// `pin_unlock`, pin.rs lines 53-68. PBKDF2 at 100k rounds: run it on the +/// job pool, never on the UI thread. +pub(super) fn unlock(backend: &RealBackend, code: &str) -> BackendResult { + Ok(unlock_result(backend.pin.unlock(&backend.ctx, code)?)) +} + +/// `pin_lock`, pin.rs lines 70-75. +pub(super) fn lock(backend: &RealBackend) { + backend.pin.lock(); +} diff --git a/crates/accshift-app/src/real/platform.rs b/crates/accshift-app/src/real/platform.rs new file mode 100644 index 00000000..7ef19b70 --- /dev/null +++ b/crates/accshift-app/src/real/platform.rs @@ -0,0 +1,179 @@ +//! Generic platform commands: every platform's accounts, switch, setup, +//! path and label. Port of `src-tauri/src/commands/platform.rs`. + +use super::runtime::{locked, locked_within, CANCEL_SETUP_LOCK_TIMEOUT}; +use super::{RealBackend, LOCK_TIMEOUT}; +use crate::error::{BackendError, BackendResult}; +use crate::types::{DryRunPlan, SetupStatus}; +use accshift_core::error::PlatformError; +use accshift_core::platforms::{self, require_service}; +use accshift_core::telemetry::{self, Event}; +use serde_json::Value; + +/// `platform_get_accounts`, platform.rs lines 6-16. +pub(super) fn get_accounts(backend: &RealBackend, platform_id: &str) -> BackendResult { + Ok(require_service(platform_id)?.get_accounts(backend.ctx.clone())?) +} + +/// `platform_get_startup_snapshot`, platform.rs lines 18-30. +pub(super) fn get_startup_snapshot( + backend: &RealBackend, + platform_id: &str, +) -> BackendResult { + Ok(require_service(platform_id)?.get_startup_snapshot(backend.ctx.clone())?) +} + +/// `platform_get_current_account`, platform.rs lines 32-38. +pub(super) fn get_current_account( + backend: &RealBackend, + platform_id: &str, +) -> BackendResult { + Ok(require_service(platform_id)?.get_current_account(backend.ctx.clone())?) +} + +/// `platform_switch_account`, platform.rs lines 40-78: the PIN check and the +/// switch under the operation lock, on this thread, then `platform_switch` +/// with the duration and the typed error family. An unknown platform fails +/// before anything is recorded. +pub(super) fn switch_account( + backend: &RealBackend, + platform_id: &str, + account_id: &str, + params: &Value, +) -> BackendResult<()> { + let service = require_service(platform_id)?; + let started = std::time::Instant::now(); + let result = locked(&backend.ctx, |c| { + backend.pin.ensure_unlocked(&*c)?; + service.switch_account(c, account_id, params.clone()) + }); + let duration_ms = started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; + let error_code = result + .as_ref() + .err() + .map(|e| telemetry::error_code_for_kind(e.kind).to_string()); + backend.telemetry.track(Event::PlatformSwitch { + platform: platform_id.to_string(), + duration_ms, + success: result.is_ok(), + error_code, + }); + result.map_err(BackendError::from) +} + +/// `platform_forget_account`, platform.rs lines 80-93. +pub(super) fn forget_account( + backend: &RealBackend, + platform_id: &str, + account_id: &str, +) -> BackendResult<()> { + let service = require_service(platform_id)?; + let result = locked(&backend.ctx, |c| service.forget_account(c, account_id)); + backend.track_operation("account_forget", Some(platform_id), result) +} + +/// `platform_begin_setup`, platform.rs lines 95-115: under the operation +/// lock, its failures recorded as `account_add`. +pub(super) fn begin_setup( + backend: &RealBackend, + platform_id: &str, + params: &Value, +) -> BackendResult { + let service = require_service(platform_id)?; + let result = locked(&backend.ctx, |c| service.begin_setup(c, params.clone())); + backend.track_operation("account_add", Some(platform_id), result) +} + +/// The holding state a status poll answers while someone else holds the +/// lock (platform.rs lines 125-153): `busy`, never an error, so the wizard +/// keeps its spinner and the next poll gets the real state. +pub fn busy_status(setup_id: &str) -> SetupStatus { + SetupStatus { + setup_id: setup_id.to_string(), + state: "busy".to_string(), + account_id: String::new(), + account_display_name: String::new(), + error_message: String::new(), + } +} + +/// `platform_get_setup_status`, platform.rs lines 117-155. +pub(super) fn get_setup_status( + backend: &RealBackend, + platform_id: &str, + setup_id: &str, +) -> BackendResult { + let service = require_service(platform_id)?; + let c = &backend.ctx; + match accshift_core::lock::acquire_exclusive(&**c, LOCK_TIMEOUT) { + Ok(_lock) => Ok(service.get_setup_status(c.clone(), setup_id)?), + Err(accshift_core::lock::LockError::Contended) => Ok(busy_status(setup_id)), + Err(e) => Err(PlatformError::from(e).into()), + } +} + +/// `platform_cancel_setup`, platform.rs lines 157-172: the 30 s budget. +pub(super) fn cancel_setup( + backend: &RealBackend, + platform_id: &str, + setup_id: &str, +) -> BackendResult<()> { + let service = require_service(platform_id)?; + Ok(locked_within( + &backend.ctx, + CANCEL_SETUP_LOCK_TIMEOUT, + |c| service.cancel_setup(c, setup_id), + )?) +} + +/// `platform_get_path`, platform.rs lines 174-180. +pub(super) fn get_path(backend: &RealBackend, platform_id: &str) -> BackendResult { + Ok(require_service(platform_id)?.get_path(backend.ctx.clone())?) +} + +/// `platform_set_path`, platform.rs lines 182-193: no operation lock, the +/// config write locks itself. +pub(super) fn set_path(backend: &RealBackend, platform_id: &str, path: &str) -> BackendResult<()> { + Ok(require_service(platform_id)?.set_path(backend.ctx.clone(), path)?) +} + +/// `platform_detect_installed`, platform.rs lines 195-209: a probe that +/// panics answers an empty list, like the Tauri command's +/// `unwrap_or_default` on the join error. +pub(super) fn detect_installed(backend: &RealBackend) -> Vec { + let c = backend.ctx.clone(); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + platforms::detect_installed(c) + })) + .unwrap_or_default() +} + +/// `platform_select_path`, platform.rs lines 211-219: the platform's own +/// folder dialog (a `powershell.exe` child on Windows), blocking the calling +/// job thread until the user answers. +pub(super) fn select_path(platform_id: &str) -> BackendResult { + Ok(require_service(platform_id)?.select_path()?) +} + +/// `platform_set_account_label`, platform.rs lines 221-234. +pub(super) fn set_account_label( + backend: &RealBackend, + platform_id: &str, + account_id: &str, + label: &str, +) -> BackendResult<()> { + Ok(require_service(platform_id)?.set_account_label(backend.ctx.clone(), account_id, label)?) +} + +/// `platform_dry_run`, platform.rs lines 236-251. The Tauri command answers +/// the plan as JSON; the trait types it as [`DryRunPlan`], which is what +/// every implementation serializes. +pub(super) fn dry_run( + backend: &RealBackend, + platform_id: &str, + account_id: &str, +) -> BackendResult { + let plan = require_service(platform_id)?.dry_run(backend.ctx.clone(), account_id)?; + serde_json::from_value(plan) + .map_err(|e| BackendError::new(format!("Unreadable dry run plan: {e}"))) +} diff --git a/crates/accshift-app/src/real/riot.rs b/crates/accshift-app/src/real/riot.rs new file mode 100644 index 00000000..2faea43c --- /dev/null +++ b/crates/accshift-app/src/real/riot.rs @@ -0,0 +1,24 @@ +//! Riot commands. Port of `src-tauri/src/commands/riot.rs`: Windows only, +//! there is no Linux or macOS Riot client. + +use super::RealBackend; +use crate::error::BackendResult; + +/// `riot_capture_profile`, riot.rs lines 6-18: saves the signed-in Riot +/// session as this profile's snapshot, under the operation lock, failures +/// recorded as `profile_capture`. +#[cfg(windows)] +pub(super) fn capture_profile(backend: &RealBackend, profile_id: &str) -> BackendResult<()> { + use accshift_core::error::PlatformError; + use accshift_core::platforms::{ids, riot}; + let result = super::runtime::locked(&backend.ctx, |c| { + riot::capture_profile(c, profile_id.to_string()).map_err(PlatformError::from) + }); + backend.track_operation("profile_capture", Some(ids::RIOT), result) +} + +/// Off Windows the Tauri app does not register the command at all. +#[cfg(not(windows))] +pub(super) fn capture_profile(_backend: &RealBackend, _profile_id: &str) -> BackendResult<()> { + Err(super::not_registered("riot_capture_profile")) +} diff --git a/crates/accshift-app/src/real/roblox.rs b/crates/accshift-app/src/real/roblox.rs new file mode 100644 index 00000000..0c399c35 --- /dev/null +++ b/crates/accshift-app/src/real/roblox.rs @@ -0,0 +1,85 @@ +//! Roblox commands. Port of `src-tauri/src/commands/roblox.rs`: Windows +//! only, the cookie write goes through the registry. + +use super::RealBackend; +use crate::error::BackendResult; +use crate::types::{RobloxAccount, RobloxProfileInfo}; + +/// `roblox_add_account_by_cookie`, roblox.rs lines 6-34: the cookie is +/// checked against Roblox unlocked, only the store write takes the +/// operation lock; failures recorded as `account_add` on Roblox. +#[cfg(windows)] +pub(super) fn add_account_by_cookie( + backend: &RealBackend, + cookie: &str, +) -> BackendResult { + use accshift_core::error::PlatformError; + use accshift_core::platforms::{ids, roblox}; + let client = backend.http_client()?.clone(); + let result = match super::runtime::block_on(roblox::validate_pasted_cookie( + cookie.to_string(), + client, + )) { + Ok(pasted) => super::runtime::locked(&backend.ctx, |c| { + roblox::store_pasted_account(&*c, pasted).map_err(PlatformError::from) + }), + Err(e) => Err(PlatformError::from(e)), + }; + backend + .track_operation("account_add", Some(ids::ROBLOX), result) + .map(account) +} + +#[cfg(windows)] +fn account(account: accshift_core::platforms::roblox::RobloxAccount) -> RobloxAccount { + RobloxAccount { + user_id: account.user_id, + username: account.username, + display_name: account.display_name, + last_login_at: account.last_login_at, + } +} + +/// `roblox_get_profile_info`, roblox.rs lines 36-45. +#[cfg(windows)] +pub(super) fn profile_info( + backend: &RealBackend, + user_id: &str, +) -> BackendResult { + use accshift_core::platforms::roblox; + let client = backend.http_client()?.clone(); + let info = super::runtime::block_on(roblox::get_profile_info(user_id.to_string(), client))?; + Ok(RobloxProfileInfo { + avatar_url: info.avatar_url, + }) +} + +/// `roblox_check_sessions`, roblox.rs lines 47-59: the user ids whose stored +/// session is dead, one blocking probe per account on this thread. +#[cfg(windows)] +pub(super) fn check_sessions(backend: &RealBackend) -> BackendResult> { + Ok(accshift_core::platforms::roblox::dead_session_user_ids( + &*backend.ctx, + )) +} + +#[cfg(not(windows))] +pub(super) fn add_account_by_cookie( + _backend: &RealBackend, + _cookie: &str, +) -> BackendResult { + Err(super::not_registered("roblox_add_account_by_cookie")) +} + +#[cfg(not(windows))] +pub(super) fn profile_info( + _backend: &RealBackend, + _user_id: &str, +) -> BackendResult { + Err(super::not_registered("roblox_get_profile_info")) +} + +#[cfg(not(windows))] +pub(super) fn check_sessions(_backend: &RealBackend) -> BackendResult> { + Err(super::not_registered("roblox_check_sessions")) +} diff --git a/crates/accshift-app/src/real/runtime.rs b/crates/accshift-app/src/real/runtime.rs new file mode 100644 index 00000000..7076aa38 --- /dev/null +++ b/crates/accshift-app/src/real/runtime.rs @@ -0,0 +1,67 @@ +//! What `src-tauri/src/commands/mod.rs` gives every command: the async +//! runtime, the blocking pool and the cross-process operation lock. +//! +//! The egui shell calls each backend method on a job thread (`crate::jobs`), +//! so "on the blocking pool" is simply "on the calling thread" here. The +//! commands that `.await` an `accshift-core` future (profiles, bans, the CS2 +//! bridge, Roblox) block that job thread on one process-wide tokio runtime, +//! the role Tauri's async runtime plays. + +use accshift_core::error::PlatformError; +use accshift_core::AppCtx; +use std::future::Future; +use std::sync::OnceLock; +use std::time::Duration; + +/// Budget for cancelling a setup (`CANCEL_SETUP_LOCK_TIMEOUT`, +/// commands/mod.rs lines 44-49): a detached Riot or Steam setup launch holds +/// the lock for up to about 20 s, and a cancel waits it out. +pub const CANCEL_SETUP_LOCK_TIMEOUT: Duration = Duration::from_secs(30); + +/// Runs `future` to completion on the shared runtime and blocks the calling +/// thread until then. Call it from a job thread, never from inside a tokio +/// task (tokio refuses a nested `block_on`). +/// +/// One runtime for the whole process, because the shared `reqwest::Client` +/// keeps its pooled connections on the runtime that opened them: a runtime +/// per call would leave the pool pointing at dead ones. +pub(super) fn block_on(future: F) -> F::Output { + static RUNTIME: OnceLock = OnceLock::new(); + RUNTIME + .get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("accshift-async") + .enable_all() + .build() + .expect("the async runtime of the real backend could not start") + }) + .block_on(future) +} + +/// `run_locked_blocking_within`, commands/mod.rs lines 84-100: takes the +/// cross-process exclusive lock (`accshift_core::lock`, the file +/// `/state/.accshift.lock` the Tauri app and the CLI take) on +/// this thread, runs `f`, releases it. The nested config writes `f` makes +/// see the guard on their own thread and skip re-locking, which is why the +/// lock and `f` share the thread, as in Tauri. +/// +/// A second egui instance, a leftover Tauri process or the CLI holding the +/// lock past `timeout` makes this fail with `LockContended`, "Another +/// accshift instance is holding the lock". +pub(super) fn locked_within( + c: &AppCtx, + timeout: Duration, + f: impl FnOnce(AppCtx) -> Result, +) -> Result { + let _lock = accshift_core::lock::acquire_exclusive(&**c, timeout)?; + f(c.clone()) +} + +/// `run_locked_blocking`, commands/mod.rs lines 72-82: the 2 s budget. +pub(super) fn locked( + c: &AppCtx, + f: impl FnOnce(AppCtx) -> Result, +) -> Result { + locked_within(c, super::LOCK_TIMEOUT, f) +} diff --git a/crates/accshift-app/src/real/steam.rs b/crates/accshift-app/src/real/steam.rs new file mode 100644 index 00000000..d788a84d --- /dev/null +++ b/crates/accshift-app/src/real/steam.rs @@ -0,0 +1,211 @@ +//! Steam and CS2 bridge commands. Port of `src-tauri/src/commands/steam.rs`. + +use super::runtime::{block_on, locked}; +use super::RealBackend; +use crate::error::BackendResult; +use crate::types::{ + BanInfo, BulkEditRequest, BulkEditResult, CopyableGame, Cs2BridgeAccount, Cs2BridgeSettings, + Cs2BridgeTestResult, ProfileInfo, +}; +use accshift_core::error::PlatformError; +use accshift_core::platforms::ids; +use accshift_core::platforms::steam::{self, cs2_bridge}; +use std::collections::BTreeMap; + +/// `steam_set_api_key`, steam.rs lines 6-16. Encrypts the key into the +/// keyring and rotates the previous entry. +pub(super) fn set_api_key(backend: &RealBackend, key: &str) -> BackendResult<()> { + Ok(steam::set_api_key(backend.ctx.clone(), key.to_string())?) +} + +/// `steam_has_api_key`, steam.rs lines 18-21. +pub(super) fn has_api_key(backend: &RealBackend) -> bool { + steam::has_api_key(backend.ctx.clone()) +} + +/// `steam_open_api_key_page`, steam.rs lines 23-31. +pub(super) fn open_api_key_page() -> BackendResult<()> { + Ok(steam::open_steam_api_key_page()?) +} + +/// `cs2_bridge_get_settings`, steam.rs lines 33-38. +pub(super) fn cs2_get_settings(backend: &RealBackend) -> Cs2BridgeSettings { + cs2_bridge::get_settings(&*backend.ctx) +} + +/// `cs2_bridge_set_settings`, steam.rs lines 40-53. `token`: `None` keeps the +/// stored one (unless the URL moved), `Some("")` clears it. +pub(super) fn cs2_set_settings( + backend: &RealBackend, + enabled: bool, + url: &str, + token: Option<&str>, +) -> BackendResult<()> { + cs2_bridge::set_settings( + &*backend.ctx, + enabled, + url.to_string(), + token.map(str::to_string), + ) + .map_err(|e| PlatformError::from(e).into()) +} + +/// `cs2_bridge_test`, steam.rs lines 55-64: never an error, the failure is +/// in the result. +pub(super) fn cs2_test(backend: &RealBackend) -> BackendResult { + let client = backend.http_client()?; + Ok(block_on(cs2_bridge::test_connection(&*backend.ctx, client))) +} + +/// `cs2_bridge_fetch`, steam.rs lines 66-76, failures recorded as +/// `cs2_bridge_fetch` on Steam. +pub(super) fn cs2_fetch(backend: &RealBackend) -> BackendResult> { + let client = backend.http_client()?; + let result = + block_on(cs2_bridge::fetch_accounts(&*backend.ctx, client)).map_err(PlatformError::from); + backend.track_operation("cs2_bridge_fetch", Some(ids::STEAM), result) +} + +/// `cs2_bridge_check`, steam.rs lines 78-89: `None` while the bridge is off. +pub(super) fn cs2_check( + backend: &RealBackend, + steam_id: &str, +) -> BackendResult> { + let client = backend.http_client()?; + block_on(cs2_bridge::check_account(&*backend.ctx, client, steam_id)) + .map_err(|e| PlatformError::from(e).into()) +} + +/// `steam_switch_account_and_launch_game`, steam.rs lines 91-114: the PIN +/// check, the switch, then the game, all under the operation lock on this +/// thread. +pub(super) fn switch_and_launch_game( + backend: &RealBackend, + username: &str, + app_id: &str, + run_as_admin: bool, + launch_options: &str, + shutdown_mode: &str, +) -> BackendResult<()> { + Ok(locked(&backend.ctx, |c| { + backend.pin.ensure_unlocked(&*c)?; + steam::switch_account_and_launch_game( + c, + username.to_string(), + app_id.to_string(), + run_as_admin, + launch_options.to_string(), + shutdown_mode.to_string(), + ) + })?) +} + +/// `steam_get_profile_info`, steam.rs lines 116-122. +pub(super) fn profile_info( + backend: &RealBackend, + steam_id: &str, +) -> BackendResult> { + let client = backend.http_client()?.clone(); + Ok(block_on(steam::get_profile_info( + steam_id.to_string(), + client, + ))?) +} + +/// `steam_get_profile_infos`, steam.rs lines 124-142: ids without a profile +/// are absent; failures recorded as `avatar_refresh`. The Tauri command +/// answers a `HashMap`; the webview gets the same JSON object either way. +pub(super) fn profile_infos( + backend: &RealBackend, + steam_ids: &[String], +) -> BackendResult> { + let client = backend.http_client()?.clone(); + let result = block_on(steam::get_profile_infos( + backend.ctx.clone(), + steam_ids.to_vec(), + client, + )) + .map(|infos| infos.into_iter().collect()); + backend.track_operation("avatar_refresh", Some(ids::STEAM), result) +} + +/// `steam_get_player_bans`, steam.rs lines 144-157, failures recorded as +/// `ban_check`. No API key answers an empty list. +pub(super) fn player_bans( + backend: &RealBackend, + steam_ids: &[String], +) -> BackendResult> { + let client = backend.http_client()?.clone(); + let result = block_on(steam::get_player_bans( + backend.ctx.clone(), + steam_ids.to_vec(), + client, + )); + backend.track_operation("ban_check", Some(ids::STEAM), result) +} + +/// `steam_copy_game_settings`, steam.rs lines 159-172. +pub(super) fn copy_game_settings( + backend: &RealBackend, + from_steam_id: &str, + to_steam_id: &str, + app_id: &str, +) -> BackendResult<()> { + let result = locked(&backend.ctx, |c| { + steam::copy_game_settings( + c, + from_steam_id.to_string(), + to_steam_id.to_string(), + app_id.to_string(), + ) + }); + backend.track_operation("game_settings_copy", Some(ids::STEAM), result) +} + +/// `steam_get_copyable_games`, steam.rs lines 174-181. +pub(super) fn copyable_games( + backend: &RealBackend, + from_steam_id: &str, + to_steam_id: &str, +) -> BackendResult> { + Ok(steam::get_copyable_games( + backend.ctx.clone(), + from_steam_id.to_string(), + to_steam_id.to_string(), + )?) +} + +/// `steam_open_userdata`, steam.rs lines 183-189. +pub(super) fn open_userdata(backend: &RealBackend, steam_id: &str) -> BackendResult<()> { + Ok(steam::open_userdata( + backend.ctx.clone(), + steam_id.to_string(), + )?) +} + +/// `steam_clear_browser_cache`, steam.rs lines 191-200: stops Steam, then +/// deletes its browser cache, under the operation lock. +pub(super) fn clear_browser_cache(backend: &RealBackend) -> BackendResult<()> { + Ok(locked(&backend.ctx, steam::clear_integrated_browser_cache)?) +} + +/// `steam_bulk_edit`, steam.rs lines 202-213, failures recorded as +/// `bulk_edit`. +pub(super) fn bulk_edit( + backend: &RealBackend, + request: BulkEditRequest, +) -> BackendResult { + let result = locked(&backend.ctx, |c| steam::bulk_edit(c, request)); + backend.track_operation("bulk_edit", Some(ids::STEAM), result) +} + +/// `steam_get_account_games`, steam.rs lines 215-221. +pub(super) fn account_games( + backend: &RealBackend, + steam_id: &str, +) -> BackendResult> { + Ok(steam::get_account_games( + backend.ctx.clone(), + steam_id.to_string(), + )?) +} diff --git a/crates/accshift-app/src/real/telemetry.rs b/crates/accshift-app/src/real/telemetry.rs new file mode 100644 index 00000000..072e49ab --- /dev/null +++ b/crates/accshift-app/src/real/telemetry.rs @@ -0,0 +1,597 @@ +//! Telemetry: the runtime of `src-tauri/src/telemetry_runtime.rs` and the +//! commands of `src-tauri/src/commands/telemetry.rs`, plus the events other +//! commands record (`track_operation`, the switch, the first boot). +//! +//! Same queue (`accshift_core::telemetry::Worker`), same consent states read +//! from the same config, same events and properties, same `/forget`, +//! `/export` and `/consent` calls. The endpoint is the build-time +//! `ACCSHIFT_TELEMETRY_URL` (`accshift_core::telemetry::TELEMETRY_URL`) for +//! [`RealBackend::new`], none for [`RealBackend::with_context`], and whatever +//! a test passes to [`RealBackend::with_telemetry_endpoint`]. +//! +//! The queue starts when the shell calls [`RealBackend::start_telemetry`] +//! (lib.rs lines 171-177 build it in `setup`, after the window). Before that, +//! and for ever on a backend without an endpoint, every event is dropped, as +//! the Tauri app drops them without consent. + +use super::RealBackend; +use crate::error::{BackendError, BackendResult}; +use crate::types::{SettingsSnapshot, TelemetryUiState}; +use accshift_core::config::{self, TelemetryConfig}; +use accshift_core::error::PlatformError; +use accshift_core::platforms::ids; +use accshift_core::telemetry::{self, Event, Handle, QueueParams, Worker}; +use accshift_core::AppContext; +use serde_json::{json, Value}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// How long [`RealBackend::end_telemetry_session`] waits for the final flush +/// (`SHUTDOWN_FLUSH_DEADLINE`, telemetry_runtime.rs lines 66-72). +pub const SHUTDOWN_FLUSH_DEADLINE: Duration = Duration::from_millis(2500); + +/// The `surface` property of every event: the egui app replaces the Tauri +/// GUI, so it reports as the GUI. +pub const SURFACE: &str = "gui"; + +/// The error of a telemetry network command on a backend that has no +/// endpoint ([`RealBackend::with_context`]): nothing leaves the machine. +pub const NO_ENDPOINT: &str = "telemetry_endpoint_unset"; + +/// The app version, the workspace's: the one the Tauri app reported and +/// `tauri.conf.json` carries. +pub fn app_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +/// `TelemetryState` (telemetry_runtime.rs lines 11-16), minus `app_start`, +/// which [`Telemetry`] keeps from the backend's construction. +struct Running { + handle: Handle, + worker: Mutex>, +} + +/// The telemetry half of [`RealBackend`]: where events go and the queue once +/// it runs. +pub(super) struct Telemetry { + endpoint: Option, + app_start: Instant, + running: OnceLock, +} + +impl Telemetry { + pub(super) fn new(endpoint: Option) -> Self { + Self { + endpoint, + app_start: Instant::now(), + running: OnceLock::new(), + } + } + + fn handle(&self) -> Option<&Handle> { + self.running.get().map(|running| &running.handle) + } + + /// `Handle::track`; a no-op until the queue runs. + pub(super) fn track(&self, event: Event) { + if let Some(handle) = self.handle() { + handle.track(event); + } + } + + /// `refresh_consent_from_config`, telemetry_runtime.rs lines 74-80. + fn refresh_consent(&self, c: &dyn AppContext) { + if let Some(handle) = self.handle() { + let cfg = config::load_config(c); + handle.update_consent(telemetry::consent_from_config(&cfg.telemetry)); + } + } + + /// `Handle::wait_for_pending_send`: a batch sent under the old consent + /// lands before a deletion or an export. + fn wait_for_pending_send(&self) { + if let Some(handle) = self.handle() { + handle.wait_for_pending_send(); + } + } + + fn elapsed_ms(&self) -> u64 { + self.app_start + .elapsed() + .as_millis() + .min(u128::from(u64::MAX)) as u64 + } + + /// The endpoint and the blocking client the Tauri commands build for each + /// `/forget`, `/export` and `/consent` call (commands/telemetry.rs lines + /// 100-104, 369-379, 402-406). + fn direct_client(&self) -> Result<(String, String, reqwest::blocking::Client), String> { + let endpoint = self + .endpoint + .clone() + .ok_or_else(|| NO_ENDPOINT.to_string())?; + let ua = telemetry::user_agent(app_version()); + let client = accshift_core::http::blocking_client_builder() + .user_agent(ua.clone()) + .build() + .map_err(|e| format!("client: {e}"))?; + Ok((endpoint, ua, client)) + } +} + +impl RealBackend { + /// [`RealBackend::with_context`] with a telemetry endpoint: the queue, + /// `/forget`, `/export` and `/consent` all go to `endpoint`. For tests + /// against a local fake endpoint, and for a shell that wants another + /// endpoint than the build-time one. + pub fn with_telemetry_endpoint(ctx: accshift_core::AppCtx, endpoint: &str) -> Self { + Self::build(ctx, Some(endpoint.trim_end_matches('/').to_string())) + } + + /// `TelemetryState::new`, telemetry_runtime.rs lines 18-48, with the + /// queue's default timings. Call once, from the shell's setup. False when + /// the backend has no endpoint or the queue already runs. + pub fn start_telemetry(&self) -> bool { + self.start_telemetry_with(QueueParams::default()) + } + + /// [`Self::start_telemetry`] with other queue timings (tests flush sooner). + /// `params.endpoint` is replaced by the backend's endpoint, so a queue can + /// never send somewhere `/forget` would not. + pub fn start_telemetry_with(&self, mut params: QueueParams) -> bool { + let Some(endpoint) = self.telemetry.endpoint.clone() else { + return false; + }; + if self.telemetry.running.get().is_some() { + return false; + } + let c = &*self.ctx; + let mut cfg = config::load_config(c); + if cfg.telemetry.onboarding_completed + && cfg.telemetry.mode_a_enabled + && !telemetry::install_id::is_valid(&cfg.telemetry.anonymous_id) + { + let anonymous_id = telemetry::install_id::generate(); + if config::update_config(c, |current| { + current.telemetry.anonymous_id = anonymous_id.clone(); + }) + .is_ok() + { + cfg.telemetry.anonymous_id = anonymous_id; + } + } + let consent = telemetry::consent_from_config(&cfg.telemetry); + let tctx = telemetry::context_for(app_version(), SURFACE); + params.endpoint = endpoint; + let worker = Worker::spawn(tctx, consent, params); + let running = Running { + handle: worker.handle(), + worker: Mutex::new(Some(worker)), + }; + self.telemetry.running.set(running).is_ok() + } + + /// True once [`Self::start_telemetry`] started the queue. + pub fn telemetry_running(&self) -> bool { + self.telemetry.running.get().is_some() + } + + /// The close handler's telemetry (boot.rs lines 613-622 and + /// `TelemetryState::shutdown`, telemetry_runtime.rs lines 50-62): + /// `session_ended` with the time since start, then the final flush, + /// bounded by [`SHUTDOWN_FLUSH_DEADLINE`]. Call after hiding the window. + pub fn end_telemetry_session(&self) { + let Some(running) = self.telemetry.running.get() else { + return; + }; + running.handle.track(Event::SessionEnded { + duration_ms: self.telemetry.elapsed_ms(), + }); + let taken = running + .worker + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take(); + if let Some(worker) = taken { + worker.shutdown(SHUTDOWN_FLUSH_DEADLINE); + } + } + + /// The deep-link handler's counter (boot.rs lines 715-719): no URL, no + /// account id, the event only. + pub fn track_deep_link(&self) { + self.telemetry.track(Event::DeepLinkUsed); + } + + /// `track_operation`, commands/mod.rs lines 102-130: a failed result + /// records `operation_failed` with its typed error family, then goes back + /// unchanged. A success records nothing. + pub(super) fn track_operation( + &self, + operation: &str, + platform_id: Option<&str>, + result: Result, + ) -> BackendResult { + if let Err(error) = &result { + self.telemetry.track(Event::OperationFailed { + operation: operation.to_string(), + platform: platform_id.map(str::to_string), + error_code: telemetry::error_code_for_kind(error.kind).to_string(), + }); + } + result.map_err(BackendError::from) + } + + /// The telemetry half of `finish_boot` (commands/app.rs lines 153-167): + /// `first_run` once ever, `app_launched`, one `accounts_snapshot` per + /// non-empty platform. Skipped entirely until the queue runs, so a + /// backend that sends nothing does not mark the first run as reported + /// (or read `loginusers.vdf` for nothing). + pub(super) fn record_first_boot(&self) { + if !self.telemetry_running() { + return; + } + let duration_ms = self.telemetry.elapsed_ms(); + self.emit_first_run_once(); + self.telemetry.track(Event::AppLaunched { duration_ms }); + self.emit_accounts_snapshots(); + } + + /// `emit_first_run_once`, commands/app.rs lines 172-191. + fn emit_first_run_once(&self) { + let c = &*self.ctx; + let cfg = config::load_config(c); + if cfg.telemetry.first_run_reported || !cfg.telemetry.onboarding_completed { + return; + } + if !cfg.telemetry.mode_a_enabled && !cfg.telemetry.mode_b_enabled { + return; + } + self.telemetry.track(Event::FirstRun); + let _ = config::update_config(c, |current| { + current.telemetry.first_run_reported = true; + }); + } + + /// `emit_accounts_snapshots`, commands/app.rs lines 193-241: Steam counted + /// from `loginusers.vdf` (zero when Steam is missing), the eight others + /// from the config. + fn emit_accounts_snapshots(&self) { + let cfg = config::load_config(&*self.ctx); + let steam_count = accshift_core::platforms::steam::get_accounts(self.ctx.clone()) + .map(|accounts| accounts.len() as u64) + .unwrap_or(0); + let counts: [(&str, u64); 9] = [ + (ids::STEAM, steam_count), + (ids::RIOT, cfg.riot.profiles.len() as u64), + (ids::BATTLE_NET, cfg.battle_net.accounts.len() as u64), + (ids::UBISOFT, cfg.ubisoft.accounts.len() as u64), + (ids::ROBLOX, cfg.roblox.accounts.len() as u64), + (ids::EPIC, cfg.epic.accounts.len() as u64), + (ids::GOG, cfg.gog.accounts.len() as u64), + (ids::JAGEX, cfg.jagex.accounts.len() as u64), + (ids::DISCORD, cfg.discord.accounts.len() as u64), + ]; + for (platform, count) in counts { + if count > 0 { + self.telemetry.track(Event::AccountsSnapshot { + platform: platform.to_string(), + count, + }); + } + } + } + + /// `retry_pending_forgets`, commands/telemetry.rs lines 87-118. + fn retry_pending_forgets(&self) -> Result<(), String> { + let c = &*self.ctx; + let pending = config::load_config(c).telemetry.pending_forget_install_ids; + if pending.is_empty() { + return Ok(()); + } + self.telemetry.wait_for_pending_send(); + let (endpoint, ua, client) = self.telemetry.direct_client()?; + process_pending_forgets( + &pending, + |install_id| telemetry::forget(&client, &endpoint, &ua, install_id), + |install_id| { + config::update_config(c, |cfg| { + complete_mode_b_forget(&mut cfg.telemetry, install_id); + }) + }, + ) + } +} + +/// `stage_mode_b_opt_out`, commands/telemetry.rs lines 41-55. +pub fn stage_mode_b_opt_out(cfg: &mut TelemetryConfig) { + cfg.mode_b_enabled = false; + let install_id = std::mem::take(&mut cfg.install_id); + if !install_id.is_empty() + && !cfg + .pending_forget_install_ids + .iter() + .any(|pending| pending == &install_id) + { + cfg.pending_forget_install_ids.push(install_id); + } +} + +/// `complete_mode_b_forget`, commands/telemetry.rs lines 57-60. +pub fn complete_mode_b_forget(cfg: &mut TelemetryConfig, install_id: &str) { + cfg.pending_forget_install_ids + .retain(|pending| pending != install_id); +} + +/// `telemetry_install_ids`, commands/telemetry.rs lines 62-73: the active id +/// first, then the pending ones, each once. +pub fn telemetry_install_ids(cfg: &TelemetryConfig) -> Vec { + let mut ids = Vec::with_capacity(1 + cfg.pending_forget_install_ids.len()); + if !cfg.install_id.is_empty() { + ids.push(cfg.install_id.clone()); + } + for id in &cfg.pending_forget_install_ids { + if !id.is_empty() && !ids.contains(id) { + ids.push(id.clone()); + } + } + ids +} + +/// `process_pending_forgets`, commands/telemetry.rs lines 75-85: an id is +/// acknowledged locally only after its `/forget` succeeded, and the first +/// failure stops the run. +pub fn process_pending_forgets( + pending: &[String], + mut forget: impl FnMut(&str) -> Result<(), String>, + mut acknowledge: impl FnMut(&str) -> Result<(), String>, +) -> Result<(), String> { + for install_id in pending { + forget(install_id)?; + acknowledge(install_id)?; + } + Ok(()) +} + +/// `telemetry_get_state`, commands/telemetry.rs lines 21-39. +pub(super) fn get_state(c: &dyn AppContext) -> TelemetryUiState { + let cfg = config::load_config(c).telemetry; + TelemetryUiState { + mode_a_enabled: cfg.mode_a_enabled, + mode_b_enabled: cfg.mode_b_enabled, + install_id_set: !cfg.install_id.is_empty() + || cfg + .pending_forget_install_ids + .iter() + .any(|id| !id.is_empty()), + forget_pending: !cfg.pending_forget_install_ids.is_empty(), + onboarding_completed: cfg.onboarding_completed, + } +} + +/// `telemetry_set_mode_a`, commands/telemetry.rs lines 120-140. +pub(super) fn set_mode_a(backend: &RealBackend, enabled: bool) -> BackendResult<()> { + let c = &*backend.ctx; + config::update_config(c, |cfg| { + cfg.telemetry.mode_a_enabled = enabled; + if enabled && !telemetry::install_id::is_valid(&cfg.telemetry.anonymous_id) { + cfg.telemetry.anonymous_id = telemetry::install_id::generate(); + } + })?; + backend.telemetry.refresh_consent(c); + Ok(()) +} + +/// `telemetry_set_mode_b`, commands/telemetry.rs lines 142-183. Off stages +/// the id in the deletion outbox, refreshes consent, then calls `/forget`; +/// the id leaves the outbox only once the endpoint acknowledged it. +pub(super) fn set_mode_b(backend: &RealBackend, enabled: bool) -> BackendResult<()> { + let c = &*backend.ctx; + if enabled { + config::update_config(c, |cfg| { + cfg.telemetry.mode_b_enabled = true; + if cfg.telemetry.install_id.is_empty() { + cfg.telemetry.install_id = telemetry::install_id::generate(); + } + })?; + backend.telemetry.refresh_consent(c); + Ok(()) + } else { + config::update_config(c, |cfg| stage_mode_b_opt_out(&mut cfg.telemetry))?; + backend.telemetry.refresh_consent(c); + Ok(backend.retry_pending_forgets()?) + } +} + +/// `telemetry_retry_forget`, commands/telemetry.rs lines 185-190. +pub(super) fn retry_forget(backend: &RealBackend) -> BackendResult<()> { + Ok(backend.retry_pending_forgets()?) +} + +/// `telemetry_complete_onboarding`, commands/telemetry.rs lines 320-387. The +/// aggregate `/consent` counter goes out on a thread of its own, refusal +/// included, and never delays the answer. +pub(super) fn complete_onboarding( + backend: &RealBackend, + mode_a_enabled: bool, + mode_b_enabled: bool, +) -> BackendResult<()> { + let choice = match (mode_a_enabled, mode_b_enabled) { + (false, false) => telemetry::ConsentChoice::Refused, + (true, false) => telemetry::ConsentChoice::Basic, + (true, true) => telemetry::ConsentChoice::Enhanced, + (false, true) => return Err("mode_b_requires_mode_a".into()), + }; + let c = &*backend.ctx; + config::update_config(c, |cfg| { + cfg.telemetry.onboarding_completed = true; + cfg.telemetry.mode_a_enabled = mode_a_enabled; + cfg.telemetry.mode_b_enabled = mode_b_enabled; + if mode_a_enabled && !telemetry::install_id::is_valid(&cfg.telemetry.anonymous_id) { + cfg.telemetry.anonymous_id = telemetry::install_id::generate(); + } + if mode_b_enabled && cfg.telemetry.install_id.is_empty() { + cfg.telemetry.install_id = telemetry::install_id::generate(); + } + })?; + backend.telemetry.refresh_consent(c); + + let Some(endpoint) = backend.telemetry.endpoint.clone() else { + return Ok(()); + }; + let handle = backend.telemetry.handle().cloned(); + std::thread::Builder::new() + .name("accshift-telemetry-consent".into()) + .spawn(move || { + if let Some(handle) = handle { + handle.wait_for_pending_send(); + } + let version = app_version(); + let ua = telemetry::user_agent(version); + let client = match accshift_core::http::blocking_client_builder() + .user_agent(ua.clone()) + .build() + { + Ok(client) => client, + Err(e) => { + eprintln!("telemetry: consent client failed: {e}"); + return; + } + }; + if let Err(e) = + telemetry::record_consent_choice(&client, &endpoint, &ua, choice, version) + { + eprintln!("telemetry: consent choice failed: {e}"); + } + }) + .map_err(|e| format!("task: {e}"))?; + Ok(()) +} + +/// `telemetry_export`, commands/telemetry.rs lines 389-419: one export per +/// install id, the bare export for one, `{ "installations": [..] }` for +/// several. +pub(super) fn export(backend: &RealBackend) -> BackendResult { + let cfg = config::load_config(&*backend.ctx).telemetry; + let install_ids = telemetry_install_ids(&cfg); + if install_ids.is_empty() { + return Err("mode_b_disabled".into()); + } + backend.telemetry.wait_for_pending_send(); + let (endpoint, ua, client) = backend.telemetry.direct_client()?; + let mut exports = Vec::with_capacity(install_ids.len()); + for install_id in install_ids { + exports.push(telemetry::export(&client, &endpoint, &ua, &install_id)?); + } + if exports.len() == 1 { + Ok(exports.pop().expect("one export")) + } else { + Ok(json!({ "installations": exports })) + } +} + +/// `telemetry_track_update`, commands/telemetry.rs lines 255-280: an unknown +/// stage is a frontend bug and records nothing. +pub(super) fn update_event( + stage: &str, + target_version: Option<&str>, + error_code: Option<&str>, +) -> Option { + let stage = match stage { + "available" => telemetry::UpdateStage::Available, + "downloaded" => telemetry::UpdateStage::Downloaded, + "applied" => telemetry::UpdateStage::Applied, + "failed" => telemetry::UpdateStage::Failed, + _ => return None, + }; + Some(Event::Update { + stage, + target_version: target_version.map(str::to_string), + error_code: error_code.map(str::to_string), + }) +} + +/// `telemetry_track_settings_snapshot`, commands/telemetry.rs lines 282-311. +pub(super) fn settings_snapshot_event(snapshot: &SettingsSnapshot) -> Event { + Event::SettingsSnapshot { + ui_language: snapshot.ui_language.clone(), + enabled_platforms: snapshot.enabled_platforms.clone(), + personas_enabled: snapshot.personas_enabled, + pin_enabled: snapshot.pin_enabled, + cli_enabled: snapshot.cli_enabled, + deep_links_enabled: snapshot.deep_links_enabled, + streamer_mode: snapshot.streamer_mode.clone(), + animations: snapshot.animations.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const OLD_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + const NEW_ID: &str = "797f20fe-94de-4e89-98a2-ae3a3273ad1e"; + + #[test] + fn opt_out_stages_the_id_once_and_forget_keeps_a_new_opt_in() { + let mut cfg = TelemetryConfig { + mode_b_enabled: true, + install_id: OLD_ID.into(), + pending_forget_install_ids: vec![OLD_ID.into()], + ..Default::default() + }; + stage_mode_b_opt_out(&mut cfg); + assert!(!cfg.mode_b_enabled); + assert!(cfg.install_id.is_empty()); + assert_eq!(cfg.pending_forget_install_ids, [OLD_ID]); + + cfg.install_id = NEW_ID.into(); + cfg.mode_b_enabled = true; + complete_mode_b_forget(&mut cfg, OLD_ID); + assert!(cfg.pending_forget_install_ids.is_empty()); + assert_eq!(cfg.install_id, NEW_ID); + } + + #[test] + fn install_ids_list_the_active_one_first_and_each_once() { + let cfg = TelemetryConfig { + install_id: NEW_ID.into(), + pending_forget_install_ids: vec![OLD_ID.into(), NEW_ID.into(), String::new()], + ..Default::default() + }; + assert_eq!(telemetry_install_ids(&cfg), [NEW_ID, OLD_ID]); + } + + #[test] + fn a_failed_forget_stops_the_run_and_is_not_acknowledged() { + let pending = vec![OLD_ID.to_string(), NEW_ID.to_string()]; + let mut acknowledged = Vec::new(); + let result = process_pending_forgets( + &pending, + |id| { + if id == NEW_ID { + Err("worker unavailable".into()) + } else { + Ok(()) + } + }, + |id| { + acknowledged.push(id.to_string()); + Ok(()) + }, + ); + assert_eq!(result, Err("worker unavailable".into())); + assert_eq!(acknowledged, [OLD_ID]); + } + + #[test] + fn an_unknown_update_stage_records_nothing() { + assert!(update_event("bogus", None, None).is_none()); + assert_eq!( + update_event("failed", Some("1.2.3"), Some("io")) + .unwrap() + .name(), + "update_failed" + ); + } +} diff --git a/crates/accshift-app/src/real/utility.rs b/crates/accshift-app/src/real/utility.rs new file mode 100644 index 00000000..d01680a2 --- /dev/null +++ b/crates/accshift-app/src/real/utility.rs @@ -0,0 +1,43 @@ +//! Utility commands: open a URL, open the logs folder, custom themes, +//! streaming detection. Port of `src-tauri/src/commands/utility.rs`, +//! `themes.rs` and `detect_streaming_software` in `app.rs`. + +use crate::error::BackendResult; +use crate::types::CustomTheme; +use accshift_core::error::PlatformError; +use accshift_core::{os, storage, themes, AppContext}; + +/// `open_url`, utility.rs lines 6-9: `accshift_core::os::open_url`, which +/// trims, refuses any scheme outside `http`, `https`, `mailto`, `steam` and +/// `roblox-player`, then hands the URL to `open::that_detached`. +pub(super) fn open_url(url: &str) -> BackendResult<()> { + os::open_url(url).map_err(|e| PlatformError::from(e).into()) +} + +/// `open_logs_folder`, utility.rs lines 11-20. +pub(super) fn open_logs_folder(c: &dyn AppContext) -> BackendResult<()> { + let dir = storage::app_log_root(c).map_err(PlatformError::other)?; + std::fs::create_dir_all(&dir) + .map_err(|e| PlatformError::other(format!("create log dir: {e}")))?; + os::open_folder(&dir).map_err(|e| PlatformError::from(e).into()) +} + +/// `detect_streaming_software`, app.rs lines 11-17. +pub(super) fn streaming_software_running() -> bool { + os::is_streaming_software_running() +} + +/// `list_custom_themes`, themes.rs lines 6-11. +pub(super) fn list_themes(c: &dyn AppContext) -> BackendResult> { + Ok(themes::list_custom_themes(c)?) +} + +/// `save_custom_theme`, themes.rs lines 13-19. +pub(super) fn save_theme(c: &dyn AppContext, theme: &CustomTheme) -> BackendResult<()> { + Ok(themes::save_custom_theme(c, theme)?) +} + +/// `delete_custom_theme`, themes.rs lines 21-24. +pub(super) fn delete_theme(c: &dyn AppContext, theme_id: &str) -> BackendResult<()> { + Ok(themes::delete_custom_theme(c, theme_id)?) +} diff --git a/crates/accshift-app/src/real/wallpaper.rs b/crates/accshift-app/src/real/wallpaper.rs new file mode 100644 index 00000000..b3215169 --- /dev/null +++ b/crates/accshift-app/src/real/wallpaper.rs @@ -0,0 +1,276 @@ +//! `get_desktop_wallpaper`: the desktop picture behind the window, for the +//! glass themes. Port of the capture in `src-tauri/src/commands/backdrop.rs` +//! (lines 60-400) on `windows-sys`. `None` outside Windows, as there. + +use crate::types::WallpaperSnapshot; + +/// The capture, the cache and the encoding of backdrop.rs, called on the +/// calling thread. +/// +/// The Tauri command runs `PrintWindow` on the main thread (its event loop) +/// and the encoding on the blocking pool. The egui backend runs both on the +/// thread that calls it, which is a job thread: `PrintWindow` on Progman +/// talks to Explorer's own thread across processes, so it needs no window or +/// message loop of ours. +#[cfg(windows)] +pub(super) fn desktop_wallpaper() -> Option { + let key = capture::current_key(); + if let Some(key) = key.as_ref() { + if let Some(hit) = capture::cached(key) { + return Some(hit); + } + } + let snapshot = capture::encode(capture::capture()?)?; + if let Some(key) = key { + capture::store(key, &snapshot); + } + Some(snapshot) +} + +#[cfg(not(windows))] +pub(super) fn desktop_wallpaper() -> Option { + None +} + +#[cfg(windows)] +mod capture { + use super::WallpaperSnapshot; + use base64::Engine; + use std::path::PathBuf; + use std::sync::Mutex; + use std::time::SystemTime; + use windows_sys::core::BOOL; + use windows_sys::Win32::Foundation::{HWND, LPARAM, RECT}; + use windows_sys::Win32::Graphics::Gdi::{ + CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits, + ReleaseDC, SelectObject, SetBrushOrgEx, SetStretchBltMode, StretchBlt, BITMAPINFO, + BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HALFTONE, SRCCOPY, + }; + use windows_sys::Win32::Storage::Xps::{PrintWindow, PRINT_WINDOW_FLAGS}; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetClassNameW, GetSystemMetrics, GetWindowRect, SM_CXVIRTUALSCREEN, + SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, + }; + + /// Makes PrintWindow capture DWM-composed content, where the wallpaper + /// lives on Windows 11. + const PW_RENDERFULLCONTENT: PRINT_WINDOW_FLAGS = 2; + /// Longest edge of the returned snapshot. + const MAX_EDGE: i32 = 3200; + + pub(super) struct CapturedWallpaper { + bgra: Vec, + output_width: i32, + output_height: i32, + x: i32, + y: i32, + width: i32, + height: i32, + } + + unsafe extern "system" fn find_progman(hwnd: HWND, lparam: LPARAM) -> BOOL { + let mut class = [0u16; 16]; + let len = unsafe { GetClassNameW(hwnd, class.as_mut_ptr(), class.len() as i32) }; + let len = usize::try_from(len).unwrap_or(0).min(class.len()); + if String::from_utf16_lossy(&class[..len]) == "Progman" { + unsafe { *(lparam as *mut HWND) = hwnd }; + return 0; + } + 1 + } + + /// Snapshot of the shell's wallpaper as rendered (Progman + + /// PW_RENDERFULLCONTENT), backdrop.rs lines 120-252. + pub(super) fn capture() -> Option { + unsafe { + let mut progman: HWND = std::ptr::null_mut(); + // Fails when the callback stops the enumeration: ignore it. + let _ = EnumWindows(Some(find_progman), &mut progman as *mut HWND as LPARAM); + if progman.is_null() { + return None; + } + let mut rect: RECT = std::mem::zeroed(); + if GetWindowRect(progman, &mut rect) == 0 { + return None; + } + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + if width <= 0 || height <= 0 { + return None; + } + let shrink = (width.max(height) + MAX_EDGE - 1) / MAX_EDGE; + let (out_w, out_h) = ( + (width / shrink.max(1)).max(1), + (height / shrink.max(1)).max(1), + ); + + let screen_dc = GetDC(std::ptr::null_mut()); + if screen_dc.is_null() { + return None; + } + let full_dc = CreateCompatibleDC(screen_dc); + let full_bmp = CreateCompatibleBitmap(screen_dc, width, height); + let small_dc = CreateCompatibleDC(screen_dc); + let small_bmp = CreateCompatibleBitmap(screen_dc, out_w, out_h); + if full_dc.is_null() || small_dc.is_null() || full_bmp.is_null() || small_bmp.is_null() + { + if !full_bmp.is_null() { + DeleteObject(full_bmp); + } + if !small_bmp.is_null() { + DeleteObject(small_bmp); + } + if !full_dc.is_null() { + DeleteDC(full_dc); + } + if !small_dc.is_null() { + DeleteDC(small_dc); + } + ReleaseDC(std::ptr::null_mut(), screen_dc); + return None; + } + + let mut pixels: Option> = None; + let old_full = SelectObject(full_dc, full_bmp); + let old_small = SelectObject(small_dc, small_bmp); + if PrintWindow(progman, full_dc, PW_RENDERFULLCONTENT) != 0 { + SetStretchBltMode(small_dc, HALFTONE); + SetBrushOrgEx(small_dc, 0, 0, std::ptr::null_mut()); + if StretchBlt( + small_dc, 0, 0, out_w, out_h, full_dc, 0, 0, width, height, SRCCOPY, + ) != 0 + { + // GetDIBits wants the bitmap deselected from its DC. + SelectObject(small_dc, old_small); + let mut info: BITMAPINFO = std::mem::zeroed(); + info.bmiHeader = BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: out_w, + biHeight: -out_h, // top-down + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + ..std::mem::zeroed() + }; + let mut bgra = vec![0u8; (out_w as usize) * (out_h as usize) * 4]; + if GetDIBits( + small_dc, + small_bmp, + 0, + out_h as u32, + bgra.as_mut_ptr().cast(), + &mut info, + DIB_RGB_COLORS, + ) == out_h + { + pixels = Some(bgra); + } + } else { + SelectObject(small_dc, old_small); + } + } else { + SelectObject(small_dc, old_small); + } + SelectObject(full_dc, old_full); + DeleteObject(small_bmp); + DeleteObject(full_bmp); + DeleteDC(small_dc); + DeleteDC(full_dc); + ReleaseDC(std::ptr::null_mut(), screen_dc); + + Some(CapturedWallpaper { + bgra: pixels?, + output_width: out_w, + output_height: out_h, + x: rect.left, + y: rect.top, + width, + height, + }) + } + } + + /// BGRA to RGB in place, JPEG at quality 82, base64 data URL, + /// backdrop.rs lines 254-292. + pub(super) fn encode(capture: CapturedWallpaper) -> Option { + let mut bgra = capture.bgra; + let n = bgra.len() / 4; + for i in 0..n { + let (b, g, r) = (bgra[4 * i], bgra[4 * i + 1], bgra[4 * i + 2]); + bgra[3 * i] = r; + bgra[3 * i + 1] = g; + bgra[3 * i + 2] = b; + } + bgra.truncate(n * 3); + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 82) + .encode( + &bgra, + capture.output_width as u32, + capture.output_height as u32, + image::ExtendedColorType::Rgb8, + ) + .ok()?; + const PREFIX: &str = "data:image/jpeg;base64,"; + let mut data_url = String::with_capacity(PREFIX.len() + jpeg.len().div_ceil(3) * 4); + data_url.push_str(PREFIX); + base64::engine::general_purpose::STANDARD.encode_string(&jpeg, &mut data_url); + Some(WallpaperSnapshot { + data_url, + x: capture.x, + y: capture.y, + width: capture.width, + height: capture.height, + }) + } + + /// What the last answer was computed from: `TranscodedWallpaper`'s size + /// and mtime plus the virtual-screen rect, backdrop.rs lines 294-350. + #[derive(PartialEq, Eq)] + pub(super) struct WallpaperKey { + modified: Option, + len: u64, + virtual_rect: (i32, i32, i32, i32), + } + + static CACHE: Mutex> = Mutex::new(None); + + fn transcoded_wallpaper_path() -> Option { + let appdata = std::env::var_os("APPDATA")?; + let mut path = PathBuf::from(appdata); + path.push("Microsoft"); + path.push("Windows"); + path.push("Themes"); + path.push("TranscodedWallpaper"); + Some(path) + } + + /// `None` when the identity cannot be read, which disables caching. + pub(super) fn current_key() -> Option { + let meta = std::fs::metadata(transcoded_wallpaper_path()?).ok()?; + let virtual_rect = unsafe { + ( + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + GetSystemMetrics(SM_CXVIRTUALSCREEN), + GetSystemMetrics(SM_CYVIRTUALSCREEN), + ) + }; + Some(WallpaperKey { + modified: meta.modified().ok(), + len: meta.len(), + virtual_rect, + }) + } + + pub(super) fn cached(key: &WallpaperKey) -> Option { + let guard = CACHE.lock().unwrap_or_else(|error| error.into_inner()); + let (stored, snapshot) = guard.as_ref()?; + (stored == key).then(|| snapshot.clone()) + } + + pub(super) fn store(key: WallpaperKey, snapshot: &WallpaperSnapshot) { + let mut guard = CACHE.lock().unwrap_or_else(|error| error.into_inner()); + *guard = Some((key, snapshot.clone())); + } +} diff --git a/crates/accshift-app/src/secure/inactivity.rs b/crates/accshift-app/src/secure/inactivity.rs new file mode 100644 index 00000000..aa229ece --- /dev/null +++ b/crates/accshift-app/src/secure/inactivity.rs @@ -0,0 +1,153 @@ +//! The inactivity timer, ported from `src/lib/shared/useInactivityBlur.svelte.ts`. +//! +//! The TS version arms a `setTimeout` and listens to `mousemove`, `mousedown` +//! and `keydown` on the document. Here the timer is a deadline the UI checks +//! each frame ([`InactivityBlur::tick`], [`InactivityBlur::next_deadline`]), +//! and the UI feeds the three input events while the listeners are +//! attached. The threshold is passed in on every call, as TS reads +//! `peekSettings().inactivityBlurSeconds` each time. Drawing the blur is UI. + +use std::time::{Duration, Instant}; + +/// `POINTER_ACTIVITY_THROTTLE_MS` (line 4). +pub const POINTER_ACTIVITY_THROTTLE: Duration = Duration::from_millis(200); + +fn threshold_of(seconds: i64) -> Option { + (seconds > 0).then(|| Duration::from_secs(seconds as u64)) +} + +/// `createInactivityBlur`. +#[derive(Debug, Clone)] +pub struct InactivityBlur { + last_activity: Instant, + /// `None` is the TS `0`: the first pointer event always counts. + last_pointer_activity: Option, + blurred: bool, + /// The armed `blurTimeoutId`. + deadline: Option, + /// `attachListeners` ran and `detachListeners` did not since. + listening: bool, +} + +impl InactivityBlur { + /// `lastActivity = Date.now()` at creation. + pub fn new(now: Instant) -> Self { + Self { + last_activity: now, + last_pointer_activity: None, + blurred: false, + deadline: None, + listening: false, + } + } + + /// `isBlurred`. + pub fn is_blurred(&self) -> bool { + self.blurred + } + + /// Whether the UI should feed input events. + pub fn is_listening(&self) -> bool { + self.listening + } + + /// When [`Self::tick`] next has work, for the repaint request. + pub fn next_deadline(&self) -> Option { + self.deadline + } + + /// `scheduleBlurCheck` (lines 18-41). + fn schedule(&mut self, now: Instant, threshold_secs: i64) { + self.deadline = None; + let Some(threshold) = threshold_of(threshold_secs) else { + self.blurred = false; + return; + }; + let elapsed = now.saturating_duration_since(self.last_activity); + self.deadline = Some(now + threshold.saturating_sub(elapsed)); + } + + /// The timeout callback (lines 27-39). True when `is_blurred` changed. + pub fn tick(&mut self, now: Instant, threshold_secs: i64) -> bool { + match self.deadline { + Some(deadline) if now >= deadline => {} + _ => return false, + } + self.deadline = None; + let before = self.blurred; + match threshold_of(threshold_secs) { + None => self.blurred = false, + Some(threshold) if now.saturating_duration_since(self.last_activity) >= threshold => { + self.blurred = true; + } + Some(_) => self.schedule(now, threshold_secs), + } + before != self.blurred + } + + /// `resetActivity` (lines 43-49). + pub fn reset_activity(&mut self, now: Instant, threshold_secs: i64) { + self.last_activity = now; + self.blurred = false; + self.schedule(now, threshold_secs); + } + + /// `resetPointerActivity` (lines 51-58): at most one reset per 200 ms. + pub fn reset_pointer_activity(&mut self, now: Instant, threshold_secs: i64) { + if let Some(last) = self.last_pointer_activity { + if now.saturating_duration_since(last) < POINTER_ACTIVITY_THROTTLE { + return; + } + } + self.last_pointer_activity = Some(now); + self.reset_activity(now, threshold_secs); + } + + /// `start` (lines 60-70). + pub fn start(&mut self, now: Instant, threshold_secs: i64) { + self.stop(); + if threshold_secs == 0 { + self.blurred = false; + return; + } + self.last_activity = now; + self.last_pointer_activity = Some(now); + self.schedule(now, threshold_secs); + } + + /// `stop` (lines 72-74): disarms the timer, keeps `is_blurred`. + pub fn stop(&mut self) { + self.deadline = None; + } + + /// `attachListeners` (lines 76-80). + pub fn attach_listeners(&mut self) { + self.listening = true; + } + + /// `detachListeners` (lines 82-86). + pub fn detach_listeners(&mut self) { + self.listening = false; + } + + /// A `mousemove` on the window. Ignored while detached. + pub fn on_pointer_move(&mut self, now: Instant, threshold_secs: i64) { + if self.listening { + self.reset_pointer_activity(now, threshold_secs); + } + } + + /// A `mousedown` on the window. Ignored while detached. + pub fn on_mouse_down(&mut self, now: Instant, threshold_secs: i64) { + if self.listening { + self.reset_activity(now, threshold_secs); + } + } + + /// A `keydown` on the window. Ignored while detached. + pub fn on_key_down(&mut self, now: Instant, threshold_secs: i64) { + if self.listening { + self.reset_activity(now, threshold_secs); + } + } +} diff --git a/crates/accshift-app/src/secure/mod.rs b/crates/accshift-app/src/secure/mod.rs new file mode 100644 index 00000000..811668c0 --- /dev/null +++ b/crates/accshift-app/src/secure/mod.rs @@ -0,0 +1,60 @@ +//! The security layer of the app: the PIN, the inactivity timer, the window +//! activity state and the secure screen that combines them. +//! +//! - [`pin`]: `pin.ts` and `pinSession.ts` over `accshift_core::pin`. +//! - The inactivity timer of `useInactivityBlur.svelte.ts` is +//! [`InactivityBlur`]. +//! - The state machine of `useWindowActivity.svelte.ts` is +//! [`WindowActivity`]. +//! - [`SecureScreen`]: `useSecureScreen.svelte.ts` plus the logic of +//! `AppScreenOverlays.svelte`. +//! - [`ScreenCover`]: what App.svelte hides behind the secure screen and the +//! streamer overlay. + +pub mod inactivity; +pub mod pin; +pub mod screen; +pub mod window_activity; + +pub use inactivity::InactivityBlur; +pub use screen::{SecureEvent, SecureInputs, SecureScreen, WindowView}; +pub use window_activity::WindowActivity; + +/// What the secure screen and streamer mode take away from the app, as +/// App.svelte applies it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ScreenCover { + /// `.app-stage.locked` (line 783): pointer events off under the PIN. + pub stage_locked: bool, + /// `.app-shell.obscured` (line 789): the app is blurred. + pub shell_obscured: bool, + /// `inert` on `.app-shell` (line 790): no focus, no click, no key reaches + /// the app. + pub shell_inert: bool, + /// `.inactivity-frost.visible` (line 822). + pub frost_visible: bool, + /// The keyboard scope is `locked` (line 561): only the overlay keys. + pub keys_locked: bool, + /// `isGridHidden` for visible priming (line 426): no avatar priming. + pub grid_hidden: bool, + /// Titlebar, panels and onboarding are not drawn (`renderSuspended`, + /// lines 780, 816, 935, 979). + pub render_suspended: bool, +} + +impl ScreenCover { + /// `streamer_active` is [`crate::streamer::StreamerModeController::active`], + /// `settings_open` is `settingsPanel.showSettings`. + pub fn compute(secure: &SecureScreen, streamer_active: bool, settings_open: bool) -> Self { + let suspended = secure.render_suspended(); + Self { + stage_locked: secure.is_pin_locked(), + shell_obscured: secure.is_obscured() || streamer_active, + shell_inert: secure.is_pin_locked() || streamer_active, + frost_visible: !suspended && secure.is_obscured(), + keys_locked: secure.is_pin_locked() || streamer_active || suspended, + grid_hidden: settings_open || !secure.window_foreground() || suspended, + render_suspended: suspended, + } + } +} diff --git a/crates/accshift-app/src/secure/pin.rs b/crates/accshift-app/src/secure/pin.rs new file mode 100644 index 00000000..ec28ebaf --- /dev/null +++ b/crates/accshift-app/src/secure/pin.rs @@ -0,0 +1,158 @@ +//! The PIN helpers of the frontend, ported from `src/lib/shared/pin.ts` and +//! `src/lib/shared/pinSession.ts`. +//! +//! Hashing and verification are `accshift_core::pin`, the very code the CLI +//! and the backend run (PBKDF2-HMAC-SHA256, 100 000 rounds, 16-byte salt, +//! `salt_hex:hash_hex`, legacy unsalted SHA-256 accepted for migration): the +//! TS file was a second copy of it and pin.test.ts pins both on the same +//! vectors. This module adds the frontend's view on top: the +//! `{ matches, rehashed }` verdict, the backend unlock with its local +//! fallback, and the wait after a wrong code. +//! +//! Nothing here logs or prints a PIN. PBKDF2 blocks for a while: run +//! [`unlock_pin_session`] and [`verify_pin_code`] on the job pool. + +use crate::backend::Backend; +use crate::error::BackendError; +use accshift_core::pin::{self as core_pin, PinVerdict}; + +pub use accshift_core::pin::{hash_pin_code, is_valid_pin_hash, sanitize_pin_digits}; + +/// `PIN_CODE_LENGTH` (pin.ts line 1). +pub const PIN_CODE_LENGTH: usize = core_pin::PIN_CODE_LENGTH; + +/// `PinVerification` (pin.ts lines 67-76). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PinVerification { + /// The attempt matches the stored hash. + pub matches: bool, + /// A PBKDF2 replacement for a legacy hash the attempt just cleared, to be + /// persisted by the caller. `None` on a failed attempt and on a hash that + /// is already PBKDF2. + pub rehashed: Option, +} + +/// `verifyPinCode` (pin.ts lines 87-107): a legacy hash that verifies comes +/// back with its PBKDF2 replacement. +pub fn verify_pin_code(code: &str, stored_hash: &str) -> PinVerification { + match core_pin::verify_pin_code(code, stored_hash) { + PinVerdict::Rejected => PinVerification { + matches: false, + rehashed: None, + }, + PinVerdict::Accepted => PinVerification { + matches: true, + rehashed: None, + }, + PinVerdict::AcceptedLegacy => PinVerification { + matches: true, + rehashed: hash_pin_code(code), + }, + } +} + +/// `PIN_LOCKED_PREFIX` (pinSession.ts line 10): the start of the error every +/// switch command returns while the backend session is locked. +pub const PIN_LOCKED_PREFIX: &str = "pin_locked:"; + +/// `isPinLockedError` (pinSession.ts lines 12-15) on an error message. +pub fn is_pin_locked_message(message: &str) -> bool { + message.starts_with(PIN_LOCKED_PREFIX) +} + +/// `isPinLockedError` on a backend error. +pub fn is_pin_locked_error(error: &BackendError) -> bool { + error.is_pin_locked() +} + +/// `PinCheck` (pinSession.ts lines 45-52). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PinCheck { + Match { + /// PBKDF2 replacement for a legacy hash the code just cleared. + rehashed: Option, + }, + Invalid { + retry_after_ms: u64, + }, + RetryLater { + retry_after_ms: u64, + }, +} + +/// `unlockPinSession` (pinSession.ts lines 63-82): the backend checks the +/// code and unlocks its session; when it holds no PIN (`not_configured`) or +/// the call fails, the hash this window holds decides. Blocks. +pub fn unlock_pin_session(backend: &dyn Backend, code: &str, stored_hash: &str) -> PinCheck { + // A failed call is ignored like the TS `console.error`: the local hash + // decides below. The error is not logged, it could echo the request. + if let Ok(result) = backend.pin_unlock(code) { + match result.status.as_str() { + "unlocked" => { + return PinCheck::Match { + rehashed: if result.legacy { + hash_pin_code(code) + } else { + None + }, + } + } + "invalid" => { + return PinCheck::Invalid { + retry_after_ms: result.retry_after_ms, + } + } + "retry_later" => { + return PinCheck::RetryLater { + retry_after_ms: result.retry_after_ms, + } + } + _ => {} + } + } + if !is_valid_pin_hash(stored_hash) { + // No PIN here either: nothing to protect. + return PinCheck::Match { rehashed: None }; + } + let verification = verify_pin_code(code, stored_hash); + if verification.matches { + PinCheck::Match { + rehashed: verification.rehashed, + } + } else { + PinCheck::Invalid { retry_after_ms: 0 } + } +} + +/// `lockPinSession` (pinSession.ts lines 85-89). Fire and forget: the screen +/// locks either way, so a failure is swallowed. +pub fn lock_pin_session(backend: &dyn Backend) { + let _ = backend.pin_lock(); +} + +/// `PIN_FAILURE_DELAY_MS` (pinSession.ts line 92): the shortest wait after +/// a wrong code, whatever the backend says. +pub const PIN_FAILURE_DELAY_MS: u64 = 1200; + +/// What `pinFailureWait` returns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PinFailureWait { + pub wait_ms: u64, + /// Say the attempts ran out rather than "wrong PIN". + pub too_many_attempts: bool, +} + +impl PinCheck { + /// `pinFailureWait` (pinSession.ts lines 99-107). `None` for a match. + pub fn failure_wait(&self) -> Option { + let (retry_after_ms, retry_later) = match *self { + PinCheck::Match { .. } => return None, + PinCheck::Invalid { retry_after_ms } => (retry_after_ms, false), + PinCheck::RetryLater { retry_after_ms } => (retry_after_ms, true), + }; + Some(PinFailureWait { + wait_ms: PIN_FAILURE_DELAY_MS.max(retry_after_ms), + too_many_attempts: retry_later || retry_after_ms > PIN_FAILURE_DELAY_MS, + }) + } +} diff --git a/crates/accshift-app/src/secure/screen.rs b/crates/accshift-app/src/secure/screen.rs new file mode 100644 index 00000000..68317c27 --- /dev/null +++ b/crates/accshift-app/src/secure/screen.rs @@ -0,0 +1,590 @@ +//! The secure screen: the AFK cover and the PIN lock, ported from +//! `src/lib/app/useSecureScreen.svelte.ts` with the logic +//! `src/lib/app/AppScreenOverlays.svelte` and `src/App.svelte` keep inline. +//! +//! The Svelte `$effect`s become [`SecureScreen::update`], run once per frame +//! after the window and settings inputs are known: it applies finished +//! unlocks, fires due timers and replays the effects in source order. Timers +//! are deadlines ([`SecureScreen::next_deadline`]); the backend calls run on +//! [`Jobs`]. What the UI must do in return comes back as [`SecureEvent`]s. + +use super::inactivity::InactivityBlur; +use super::pin::{ + is_pin_locked_error, is_valid_pin_hash, lock_pin_session, sanitize_pin_digits, + unlock_pin_session, PinCheck, PIN_CODE_LENGTH, +}; +use super::window_activity::WindowActivity; +use crate::error::BackendError; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::settings::AppSettings; +use crate::stores::ClientStores; +use crate::text::Text; +use std::time::{Duration, Instant}; + +/// `AFK_TEXT_FADE_MS` (line 57): how long the wave keeps running while the +/// AFK text fades out. +pub const AFK_TEXT_FADE: Duration = Duration::from_millis(900); + +/// `AFK_TEXT_REVEAL_DELAY_MS` (line 58): the `startDelayMs` of the wave text. +pub const AFK_TEXT_REVEAL_DELAY_MS: u64 = 2500; + +/// The pause between a matching code and the overlay going away (line 246). +pub const UNLOCK_SETTLE: Duration = Duration::from_millis(240); + +/// What the screen reads from the window (`windowActivity` deps). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowView { + pub foreground: bool, + pub minimized: bool, + pub page_visible: bool, +} + +impl Default for WindowView { + fn default() -> Self { + Self::from(&WindowActivity::new()) + } +} + +impl From<&WindowActivity> for WindowView { + fn from(activity: &WindowActivity) -> Self { + Self { + foreground: activity.is_foreground(), + minimized: activity.is_minimized(), + page_visible: activity.is_page_visible(), + } + } +} + +/// The per-frame inputs (`SecureScreenDeps` getters). +#[derive(Debug, Clone, Copy)] +pub struct SecureInputs<'a> { + /// `getSettings()`: the shell's settings. + pub settings: &'a AppSettings, + /// `getIsAccountSelectionView()`: no settings panel and an adapter + /// (App.svelte line 145). The AFK cover only covers the account grid; + /// the PIN lock engages in every view. + pub is_account_selection_view: bool, + pub window: WindowView, + /// `getAppVersion()`, empty until known. + pub app_version: &'a str, +} + +/// What the UI does in return. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SecureEvent { + /// `onCloseContextMenu()`. + CloseContextMenu, + /// Put keyboard focus in the PIN field (the `setTimeout` focus calls and + /// the AppScreenOverlays focus effect, lines 74-76). + FocusPinInput, + /// `persistPinHash(hash)`: a PBKDF2 hash replaced a legacy one. Write it + /// with [`persist_pin_hash`]. + PersistPinHash(String), +} + +#[derive(Debug, Clone, Default)] +struct Snapshot { + pin_enabled: bool, + pin_hash: String, + inactivity_blur_seconds: i64, + suspend_graphics_when_minimized: bool, + is_account_selection_view: bool, + window: WindowView, + app_version: String, +} + +impl Snapshot { + fn read(&mut self, inputs: &SecureInputs<'_>) { + let settings = inputs.settings; + self.pin_enabled = settings.pin_enabled; + if self.pin_hash != settings.pin_hash { + self.pin_hash = settings.pin_hash.clone(); + } + self.inactivity_blur_seconds = settings.inactivity_blur_seconds; + self.suspend_graphics_when_minimized = settings.suspend_graphics_when_minimized; + self.is_account_selection_view = inputs.is_account_selection_view; + self.window = inputs.window; + if self.app_version != inputs.app_version { + self.app_version = inputs.app_version.to_string(); + } + } + + fn has_valid_pin(&self) -> bool { + is_valid_pin_hash(&self.pin_hash) + } +} + +/// `createSecureScreenController`. +pub struct SecureScreen { + snap: Snapshot, + blur: InactivityBlur, + is_pin_locked: bool, + is_pin_unlocking: bool, + is_pin_retry_locked: bool, + pin_attempt: String, + pin_error: Option, + retry_deadline: Option, + settle_deadline: Option, + unlock: Option>, + /// The backend refused a switch for want of the PIN. The screen stays + /// locked on that alone, even when this window holds no PIN: only a code + /// the backend accepts clears it (lines 82-85). + backend_locked: bool, + afk_listeners_attached: bool, + afk_wave_active: bool, + afk_wave_stop_deadline: Option, + prev_afk_visible: bool, + prev_render_suspended: bool, + prev_wants_focus: bool, + events: Vec, +} + +impl SecureScreen { + /// Starts locked when the settings carry an enabled, well-formed PIN + /// (`startupPinLocked`, lines 71-73). + pub fn new(settings: &AppSettings, now: Instant) -> Self { + let mut snap = Snapshot::default(); + snap.read(&SecureInputs { + settings, + is_account_selection_view: false, + window: WindowView::default(), + app_version: "", + }); + let startup_locked = snap.pin_enabled && snap.has_valid_pin(); + Self { + snap, + blur: InactivityBlur::new(now), + is_pin_locked: startup_locked, + is_pin_unlocking: false, + is_pin_retry_locked: false, + pin_attempt: String::new(), + pin_error: None, + retry_deadline: None, + settle_deadline: None, + unlock: None, + backend_locked: false, + afk_listeners_attached: false, + afk_wave_active: false, + afk_wave_stop_deadline: None, + prev_afk_visible: false, + prev_render_suspended: false, + prev_wants_focus: false, + events: Vec::new(), + } + } + + // ------------------------------------------------------------ state + + pub fn blur(&self) -> &InactivityBlur { + &self.blur + } + + pub fn is_pin_locked(&self) -> bool { + self.is_pin_locked + } + + pub fn is_pin_unlocking(&self) -> bool { + self.is_pin_unlocking + } + + pub fn is_pin_retry_locked(&self) -> bool { + self.is_pin_retry_locked + } + + /// The digits typed so far. Draw it masked. + pub fn pin_attempt(&self) -> &str { + &self.pin_attempt + } + + /// `pinError`: `pin.invalid` or `pin.tooManyAttempts { seconds }`. + pub fn pin_error(&self) -> Option<&Text> { + self.pin_error.as_ref() + } + + /// `pinCodeLength`. + pub fn pin_code_length(&self) -> usize { + PIN_CODE_LENGTH + } + + /// `afkTextRevealDelayMs`. + pub fn afk_text_reveal_delay_ms(&self) -> u64 { + AFK_TEXT_REVEAL_DELAY_MS + } + + /// The backend said it is locked and no accepted code cleared it yet. + pub fn is_backend_locked(&self) -> bool { + self.backend_locked + } + + /// `windowForeground`. + pub fn window_foreground(&self) -> bool { + self.snap.window.foreground + } + + fn window_renderable(&self) -> bool { + self.snap.window.page_visible && !self.snap.window.minimized + } + + /// `renderSuspended`: minimized with "suspend graphics" on. + pub fn render_suspended(&self) -> bool { + self.snap.suspend_graphics_when_minimized && self.snap.window.minimized + } + + fn inactivity_enabled(&self) -> bool { + self.snap.inactivity_blur_seconds > 0 + } + + fn pin_overlay_up(&self) -> bool { + self.is_pin_locked || self.is_pin_unlocking || self.is_pin_retry_locked + } + + fn afk_covering(&self) -> bool { + self.inactivity_enabled() && self.blur.is_blurred() && self.snap.is_account_selection_view + } + + /// `isObscured` (lines 96-101): the frost over the app. + pub fn is_obscured(&self) -> bool { + self.afk_covering() || self.pin_overlay_up() + } + + /// `afkOverlayVisible` (lines 102-111). + pub fn afk_overlay_visible(&self) -> bool { + self.afk_covering() + && !self.pin_overlay_up() + && self.window_renderable() + && !self.render_suspended() + } + + /// `motionPaused`. + pub fn motion_paused(&self) -> bool { + !self.window_renderable() || self.render_suspended() + } + + /// `afkWaveActive`: the AFK wave text runs (it keeps running for + /// [`AFK_TEXT_FADE`] after the cover goes). + pub fn afk_wave_active(&self) -> bool { + self.afk_wave_active + } + + /// `afkVersionLabel`. + pub fn afk_version_label(&self) -> Option<&str> { + (self.afk_overlay_visible() && !self.snap.app_version.is_empty()) + .then_some(self.snap.app_version.as_str()) + } + + /// The PIN overlay is drawn (AppScreenOverlays lines 113 and 130). + pub fn pin_overlay_visible(&self) -> bool { + !self.render_suspended() && self.pin_overlay_up() + } + + /// The PIN field takes input (AppScreenOverlays line 161). + pub fn pin_input_enabled(&self) -> bool { + !self.is_pin_unlocking && !self.is_pin_retry_locked + } + + /// An unlock is waiting for the backend. + pub fn is_busy(&self) -> bool { + self.unlock.is_some() + } + + /// The earliest timer, for the repaint request. + pub fn next_deadline(&self) -> Option { + [ + self.retry_deadline, + self.settle_deadline, + self.afk_wave_stop_deadline, + self.blur.next_deadline(), + ] + .into_iter() + .flatten() + .min() + } + + // ------------------------------------------------------------ input + + /// `setPinAttempt`: keeps the digits only, four at most (the attempt + /// effect, lines 197-207). The unlock starts on the next + /// [`Self::update`]. + pub fn set_pin_attempt(&mut self, value: &str) { + self.pin_attempt = sanitize_pin_digits(value); + } + + /// A pointer move over the window (the blur's `mousemove` listener). + pub fn on_pointer_move(&mut self, now: Instant) { + self.blur + .on_pointer_move(now, self.snap.inactivity_blur_seconds); + } + + /// A mouse button press (`mousedown`). + pub fn on_mouse_down(&mut self, now: Instant) { + self.blur + .on_mouse_down(now, self.snap.inactivity_blur_seconds); + } + + /// A key press (`keydown`). + pub fn on_key_down(&mut self, now: Instant) { + self.blur + .on_key_down(now, self.snap.inactivity_blur_seconds); + } + + // ------------------------------------------------------------ lifecycle + + /// `handleAppMounted` (lines 261-276). Call once after the first + /// [`Self::update`] fed the real settings. + pub fn handle_app_mounted(&mut self, jobs: &Jobs, now: Instant) { + self.blur.start(now, self.snap.inactivity_blur_seconds); + self.blur.attach_listeners(); + self.afk_listeners_attached = true; + if self.is_pin_locked { + // The backend locks itself at start when the store holds a PIN. + // Say it anyway: this may be a reload of an unlocked session. + tell_backend_lock(jobs); + self.is_pin_retry_locked = false; + self.pin_attempt.clear(); + self.pin_error = None; + self.events.push(SecureEvent::FocusPinInput); + } + } + + /// `handleAppDestroyed` (lines 278-293). + pub fn handle_app_destroyed(&mut self) { + self.afk_wave_stop_deadline = None; + self.retry_deadline = None; + if self.afk_listeners_attached { + self.blur.detach_listeners(); + } + self.blur.stop(); + } + + /// `handleSettingsClosed` (lines 253-259): the timer starts over. + pub fn handle_settings_closed(&mut self, now: Instant) { + self.blur.start(now, self.snap.inactivity_blur_seconds); + if !self.afk_listeners_attached { + self.blur.attach_listeners(); + self.afk_listeners_attached = true; + } + } + + /// `handleBackendLocked` (lines 190-195): the backend refused a command + /// for want of the PIN. + pub fn handle_backend_locked(&mut self) { + self.backend_locked = true; + if self.is_pin_locked || self.is_pin_unlocking { + return; + } + // The backend is locked already: no need to tell it. + self.engage_lock(None); + } + + /// `reportPinLockedError` (pinSession.ts lines 33-37) wired to this + /// screen: true when `error` was the PIN refusal, so the caller skips + /// its generic failure message. + pub fn report_backend_error(&mut self, error: &BackendError) -> bool { + if !is_pin_locked_error(error) { + return false; + } + self.handle_backend_locked(); + true + } + + // ------------------------------------------------------------ frame + + /// One frame: read the inputs, apply a finished unlock, fire due timers, + /// then run the effects of lines 115-207 in order. + pub fn update( + &mut self, + inputs: &SecureInputs<'_>, + jobs: &Jobs, + now: Instant, + ) -> Vec { + self.snap.read(inputs); + self.poll_unlock(now); + self.fire_timers(now); + + // AFK wave (lines 115-131). + let afk_visible = self.afk_overlay_visible(); + if afk_visible && !self.prev_afk_visible { + self.afk_wave_stop_deadline = None; + self.events.push(SecureEvent::CloseContextMenu); + self.afk_wave_active = true; + } else if !afk_visible && self.prev_afk_visible && self.afk_wave_active { + self.afk_wave_stop_deadline = Some(now + AFK_TEXT_FADE); + } + self.prev_afk_visible = afk_visible; + + // Render suspend closes the context menu (lines 133-137). + let suspended = self.render_suspended(); + if suspended && !self.prev_render_suspended { + self.events.push(SecureEvent::CloseContextMenu); + } + self.prev_render_suspended = suspended; + + // Listeners follow render suspend (lines 139-151). + if suspended { + if self.afk_listeners_attached { + self.blur.detach_listeners(); + self.afk_listeners_attached = false; + } + } else if !self.afk_listeners_attached { + self.blur.attach_listeners(); + self.afk_listeners_attached = true; + } + + // No PIN any more: drop the lock (lines 153-162). + if (!self.snap.pin_enabled || !self.snap.has_valid_pin()) && !self.backend_locked { + self.is_pin_locked = false; + self.is_pin_retry_locked = false; + self.pin_attempt.clear(); + self.pin_error = None; + } + + // Inactivity engages the PIN lock (lines 164-177). + if self.blur.is_blurred() + && self.snap.pin_enabled + && self.snap.has_valid_pin() + && !self.is_pin_locked + && !self.is_pin_unlocking + { + self.engage_lock(Some(jobs)); + } + + // Four digits typed: unlock (lines 197-207). + if self.is_pin_locked + && !self.is_pin_unlocking + && !self.is_pin_retry_locked + && self.pin_attempt.len() == PIN_CODE_LENGTH + { + self.start_unlock(jobs); + } + + // The field holds focus while the lock waits for a code + // (AppScreenOverlays lines 74-76). + let wants_focus = self.is_pin_locked && !self.is_pin_unlocking; + if wants_focus && !self.prev_wants_focus { + self.events.push(SecureEvent::FocusPinInput); + } + self.prev_wants_focus = wants_focus; + + let mut events = std::mem::take(&mut self.events); + let mut seen = Vec::with_capacity(events.len()); + events.retain(|event| { + if seen.contains(event) { + return false; + } + seen.push(event.clone()); + true + }); + events + } + + fn fire_timers(&mut self, now: Instant) { + if self.retry_deadline.is_some_and(|due| now >= due) { + self.retry_deadline = None; + self.is_pin_retry_locked = false; + self.events.push(SecureEvent::FocusPinInput); + } + if self.settle_deadline.is_some_and(|due| now >= due) { + self.settle_deadline = None; + self.is_pin_locked = false; + self.is_pin_unlocking = false; + self.blur + .reset_activity(now, self.snap.inactivity_blur_seconds); + } + if self.afk_wave_stop_deadline.is_some_and(|due| now >= due) { + self.afk_wave_stop_deadline = None; + self.afk_wave_active = false; + } + self.blur.tick(now, self.snap.inactivity_blur_seconds); + } + + /// `engageLock` (lines 180-188). `None` skips telling the backend. + fn engage_lock(&mut self, tell_backend: Option<&Jobs>) { + if let Some(jobs) = tell_backend { + tell_backend_lock(jobs); + } + self.is_pin_locked = true; + self.is_pin_retry_locked = false; + self.pin_attempt.clear(); + self.pin_error = None; + self.events.push(SecureEvent::CloseContextMenu); + self.events.push(SecureEvent::FocusPinInput); + } + + /// The first half of `unlockWithPin` (lines 209-221). + fn start_unlock(&mut self, jobs: &Jobs) { + let expected = self.snap.pin_hash.clone(); + if !is_valid_pin_hash(&expected) && !self.backend_locked { + self.is_pin_locked = false; + return; + } + let attempt = sanitize_pin_digits(&self.pin_attempt); + if attempt.len() != PIN_CODE_LENGTH || self.is_pin_retry_locked { + return; + } + self.is_pin_unlocking = true; + self.pin_error = None; + // The backend checks the code and unlocks the switch commands. It + // also rate limits wrong codes, so its wait wins over the local one. + self.unlock = + Some(jobs.run(move |backend| unlock_pin_session(backend, &attempt, &expected))); + } + + fn poll_unlock(&mut self, now: Instant) { + let Some(pending) = self.unlock.as_mut() else { + return; + }; + let check = match pending.poll() { + Poll::Waiting => return, + Poll::Ready(check) => check, + // The check panicked: a plain wrong code, so the screen never + // hangs in "unlocking". + Poll::Gone => PinCheck::Invalid { retry_after_ms: 0 }, + }; + self.unlock = None; + self.apply_unlock(check, now); + } + + /// The second half of `unlockWithPin` (lines 222-251). + fn apply_unlock(&mut self, check: PinCheck, now: Instant) { + if let Some(wait) = check.failure_wait() { + self.is_pin_unlocking = false; + self.is_pin_retry_locked = true; + self.pin_error = Some(if wait.too_many_attempts { + Text::with( + "pin.tooManyAttempts", + [("seconds", wait.wait_ms.div_ceil(1000))], + ) + } else { + Text::key("pin.invalid") + }); + self.pin_attempt.clear(); + self.retry_deadline = Some(now + Duration::from_millis(wait.wait_ms)); + return; + } + self.backend_locked = false; + if let PinCheck::Match { + rehashed: Some(hash), + } = check + { + // Unlocked against the old unsalted hash: store the PBKDF2 one + // now, so the next unlock (here or in the CLI) runs salted. + self.events.push(SecureEvent::PersistPinHash(hash)); + } + self.pin_attempt.clear(); + self.settle_deadline = Some(now + UNLOCK_SETTLE); + } +} + +fn tell_backend_lock(jobs: &Jobs) { + // Fire and forget, as `lockPinSession`: the result is dropped. + drop(jobs.run(lock_pin_session)); +} + +/// `persistPinHash` (App.svelte lines 256-261): re-read the store rather +/// than write the shell's settings back, since the settings panel may hold a +/// draft; only the hash travels. +pub fn persist_pin_hash(stores: &mut ClientStores, hash: &str) { + let mut latest = stores.settings().clone(); + latest.pin_hash = hash.to_string(); + stores.save_settings(&latest); +} diff --git a/crates/accshift-app/src/secure/window_activity.rs b/crates/accshift-app/src/secure/window_activity.rs new file mode 100644 index 00000000..4dfebc62 --- /dev/null +++ b/crates/accshift-app/src/secure/window_activity.rs @@ -0,0 +1,144 @@ +//! Window focus and visibility, ported from +//! `src/lib/shared/useWindowActivity.svelte.ts` (the state machine only). +//! +//! TS listens to the DOM `focus`, `blur` and `visibilitychange` events and to +//! Tauri's `onFocusChanged` and `onResized`, and asks the native window +//! (`isFocused`, `isMinimized`) on a blur, a resize or a return to visible. +//! The egui shell feeds the same events from its viewport info and answers +//! the native query itself: [`WindowActivity::take_sync_request`] says a +//! query is due, [`WindowActivity::apply_sync`] takes its answer. +//! +//! The async registration race of TS (`takeNativeUnlisteners`, the +//! `generation` counter) has no equivalent: egui events are synchronous, so +//! "a listener that lands after stop" becomes "an event fed while stopped", +//! which is dropped. + +/// `createWindowActivity`. +#[derive(Debug, Clone)] +pub struct WindowActivity { + is_focused: bool, + is_minimized: bool, + is_page_visible: bool, + started: bool, + /// A `sync()` is due. Several requests before the answer collapse into + /// one, as the `syncing` / `pendingSync` pair does (lines 38-66). + sync_requested: bool, +} + +impl Default for WindowActivity { + fn default() -> Self { + Self::new() + } +} + +impl WindowActivity { + /// Starts focused, not minimized, visible (lines 22-24). + pub fn new() -> Self { + Self { + is_focused: true, + is_minimized: false, + is_page_visible: true, + started: false, + sync_requested: false, + } + } + + pub fn is_focused(&self) -> bool { + self.is_focused + } + + pub fn is_minimized(&self) -> bool { + self.is_minimized + } + + pub fn is_page_visible(&self) -> bool { + self.is_page_visible + } + + /// `isForeground` (lines 155-157). + pub fn is_foreground(&self) -> bool { + self.is_focused && self.is_page_visible && !self.is_minimized + } + + pub fn is_started(&self) -> bool { + self.started + } + + /// `start` (lines 69-134): takes the current visibility and asks for a + /// first native sync. A second start is a no-op. + pub fn start(&mut self, page_visible: bool) { + if self.started { + return; + } + self.started = true; + self.is_page_visible = page_visible; + self.sync_requested = true; + } + + /// `stop` (lines 136-143): events are dropped until the next start. + pub fn stop(&mut self) { + if !self.started { + return; + } + self.started = false; + self.sync_requested = false; + } + + /// The window gained or lost focus: the DOM `focus`/`blur` pair (lines + /// 79-87) and Tauri's `onFocusChanged` (lines 110-116), which fire + /// together in the webview. A focused window is not minimized; a blur + /// asks the native window whether it got minimized. + pub fn on_focus_changed(&mut self, focused: bool, page_visible: bool) { + if !self.started { + return; + } + self.is_focused = focused; + self.is_page_visible = page_visible; + if focused { + self.is_minimized = false; + } else { + self.sync_requested = true; + } + } + + /// `visibilitychange` (lines 95-100): back to visible asks for a sync. + pub fn on_visibility_changed(&mut self, visible: bool) { + if !self.started { + return; + } + self.is_page_visible = visible; + if visible { + self.sync_requested = true; + } + } + + /// `onResized` (lines 117-119): minimizing is a resize. + pub fn on_resized(&mut self) { + if self.started { + self.sync_requested = true; + } + } + + /// True once per due `sync()`: the shell then reads the native focus and + /// minimized state and calls [`Self::apply_sync`]. + pub fn take_sync_request(&mut self) -> bool { + std::mem::take(&mut self.sync_requested) + } + + /// The answer of `sync()` (lines 38-67). A `None` is a query that failed + /// (`Promise.allSettled` rejected) and keeps the old value. + pub fn apply_sync( + &mut self, + page_visible: bool, + focused: Option, + minimized: Option, + ) { + self.is_page_visible = page_visible; + if let Some(focused) = focused { + self.is_focused = focused; + } + if let Some(minimized) = minimized { + self.is_minimized = minimized; + } + } +} diff --git a/crates/accshift-app/src/settings.rs b/crates/accshift-app/src/settings.rs new file mode 100644 index 00000000..59067f34 --- /dev/null +++ b/crates/accshift-app/src/settings.rs @@ -0,0 +1,509 @@ +//! The settings store, ported from `src/lib/features/settings/{types,store, +//! settingsPatch,apiKeyDraft,onboardingConsent}.ts` and the Steam entry of +//! `platformSettings` (`src/lib/platforms/steam/settingsSchema.ts`). +//! +//! [`sanitize_settings`] is the TS `sanitizeSettings`: any JSON in, a full +//! [`AppSettings`] out, with the same defaults, clamps and legacy keys. + +use crate::i18n::{self, Locale}; +use crate::js; +use crate::stores::StoreContext; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataRefreshSettings { + pub avatar_cache_days: i64, + pub ban_check_days: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountDisplaySettings { + pub show_usernames: bool, + pub show_last_login_per_platform: BTreeMap, + pub show_card_notes_inline: bool, + pub expanded_folders: bool, +} + +/// `"system"` follows the OS reduced-motion preference; `"on"`/`"off"` force it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum AnimationsMode { + #[default] + System, + On, + Off, +} + +/// `"auto"` blurs the UI when streaming software is detected; `"off"` never does. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum StreamerMode { + #[default] + Auto, + Off, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum SteamShutdownMode { + #[default] + Graceful, + Force, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SteamPlatformSettings { + pub run_as_admin: bool, + pub launch_options: String, + pub shutdown_mode: SteamShutdownMode, +} + +/// The per-platform settings. Only Steam has a schema today; a platform with +/// none has no entry, as in TS. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PlatformSettings { + pub steam: SteamPlatformSettings, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSettings { + pub language: Locale, + pub theme_id: String, + pub background_opacity: i64, + pub ui_scale_percent: i64, + pub animations: AnimationsMode, + pub streamer_mode: StreamerMode, + pub suspend_graphics_when_minimized: bool, + pub minimize_on_account_switch: bool, + /// Windows only: launch at sign-in, minimized to the tray + /// ([`crate::lifecycle::autostart`]). + pub start_with_system: bool, + /// Windows only: the close button hides to the tray instead of quitting. + pub close_to_tray: bool, + pub data_refresh: DataRefreshSettings, + pub enabled_platforms: Vec, + /// Per-platform opt-out for network account health checks. + pub health_check_per_platform: BTreeMap, + pub personas_enabled: bool, + pub default_platform_id: String, + pub inactivity_blur_seconds: i64, + pub deep_links_enabled: bool, + /// Mirrored by the CLI binary, which refuses every subcommand when false. + pub cli_enabled: bool, + pub platform_settings: PlatformSettings, + pub account_display: AccountDisplaySettings, + pub pin_enabled: bool, + pub pin_hash: String, +} + +impl AppSettings { + /// The settings as the JSON the store holds. + pub fn to_value(&self) -> Value { + serde_json::to_value(self).unwrap_or(Value::Null) + } +} + +pub const DEFAULT_THEME_ID: &str = "dark"; +const DEFAULT_ENABLED_PLATFORM: &str = "steam"; +const DEFAULT_PLATFORM_ID: &str = "steam"; +const LAUNCH_OPTIONS_MAX: usize = 256; + +/// `DEFAULTS.healthCheckPerPlatform`. +pub const HEALTH_CHECK_DEFAULTS: [(&str, bool); 1] = [("roblox", true)]; + +/// `DEFAULTS.accountDisplay.showLastLoginPerPlatform`: what each platform +/// shows for an id missing from storage. Any other platform shows `false`. +pub const SHOW_LAST_LOGIN_DEFAULTS: [(&str, bool); 9] = [ + ("steam", false), + ("riot", false), + ("battle-net", false), + ("roblox", true), + ("epic", true), + ("ubisoft", true), + ("gog", true), + ("jagex", true), + ("discord", true), +]; + +/// Flat keys that predate `showLastLoginPerPlatform`. +const LEGACY_LAST_LOGIN_KEYS: [(&str, &str); 3] = [ + ("steam", "showLastLogin"), + ("riot", "showRiotLastLogin"), + ("battle-net", "showBattleNetLastLogin"), +]; + +/// `isValidPinHash` (`src/lib/shared/pin.ts`): `salt:hash` in hex (16 and 32 +/// bytes) or the legacy bare 32-byte hash. +pub fn is_valid_pin_hash(value: &str) -> bool { + let hex = |part: &str, len: usize| { + part.len() == len && part.bytes().all(|byte| byte.is_ascii_hexdigit()) + }; + match value.split_once(':') { + Some((salt, hash)) => hex(salt, 32) && hex(hash, 64), + None => hex(value, 64), + } +} + +fn sanitize_pin_hash(value: Option<&Value>) -> String { + let Some(text) = js::as_str(value) else { + return String::new(); + }; + let normalized = js::trim(text).to_lowercase(); + if is_valid_pin_hash(&normalized) { + normalized + } else { + String::new() + } +} + +fn field<'a>(record: Option<&'a Map>, key: &str) -> Option<&'a Value> { + record.and_then(|map| map.get(key)) +} + +fn has_field(record: Option<&Map>, key: &str) -> bool { + record.is_some_and(|map| map.contains_key(key)) +} + +/// `steamSettingsSchema.sanitize`. Legacy flat keys (`steamRunAsAdmin`, +/// `steamLaunchOptions`) still resolve. +pub fn sanitize_steam_settings( + raw: Option<&Value>, + legacy_root: Option<&Map>, +) -> SteamPlatformSettings { + let record = js::as_record(raw); + let launch_options = js::nullish_or( + field(record, "launchOptions"), + field(legacy_root, "steamLaunchOptions"), + ); + SteamPlatformSettings { + run_as_admin: js::truthy(js::nullish_or( + field(record, "runAsAdmin"), + field(legacy_root, "steamRunAsAdmin"), + )), + launch_options: js::as_str(launch_options) + .map(|text| js::utf16_prefix(js::trim(text), LAUNCH_OPTIONS_MAX)) + .unwrap_or_default(), + shutdown_mode: if js::as_str(field(record, "shutdownMode")) == Some("force") { + SteamShutdownMode::Force + } else { + SteamShutdownMode::Graceful + }, + } +} + +fn sanitize_show_last_login( + raw_account_display: Option<&Map>, + ctx: &StoreContext, +) -> BTreeMap { + let raw_map = js::as_record(field(raw_account_display, "showLastLoginPerPlatform")); + let mut result = BTreeMap::new(); + for id in ctx.platform_ids() { + let legacy_key = LEGACY_LAST_LOGIN_KEYS + .iter() + .find(|(platform, _)| *platform == id) + .map(|(_, key)| *key); + let value = if has_field(raw_map, id) { + js::truthy(field(raw_map, id)) + } else if let Some(key) = legacy_key.filter(|key| has_field(raw_account_display, key)) { + js::truthy(field(raw_account_display, key)) + } else { + SHOW_LAST_LOGIN_DEFAULTS + .iter() + .find(|(platform, _)| *platform == id) + .is_some_and(|(_, shown)| *shown) + }; + result.insert(id.to_string(), value); + } + result +} + +fn sanitize_health_check(raw: Option<&Map>) -> BTreeMap { + let raw_map = js::as_record(field(raw, "healthCheckPerPlatform")); + HEALTH_CHECK_DEFAULTS + .iter() + .map(|(id, default)| { + let value = if has_field(raw_map, id) { + js::not_false(field(raw_map, id)) + } else { + *default + }; + (id.to_string(), value) + }) + .collect() +} + +/// `sanitizeSettings`: every field present, every value in range. +pub fn sanitize_settings(value: &Value, ctx: &StoreContext) -> AppSettings { + let raw = js::as_record(Some(value)); + let raw_data_refresh = js::as_record(field(raw, "dataRefresh")); + let raw_platform_settings = js::as_record(field(raw, "platformSettings")); + let raw_account_display = js::as_record(field(raw, "accountDisplay")); + + let mut enabled_platforms: Vec = Vec::new(); + if let Some(Value::Array(items)) = field(raw, "enabledPlatforms") { + for item in items { + if let Value::String(id) = item { + if ctx.knows_platform(id) && !enabled_platforms.contains(id) { + enabled_platforms.push(id.clone()); + } + } + } + } + if enabled_platforms.is_empty() { + enabled_platforms.push(DEFAULT_ENABLED_PLATFORM.to_string()); + } + let default_platform_raw = + js::as_str(field(raw, "defaultPlatformId")).unwrap_or(DEFAULT_PLATFORM_ID); + let default_platform_id = if enabled_platforms + .iter() + .any(|id| id == default_platform_raw) + { + default_platform_raw.to_string() + } else { + enabled_platforms[0].clone() + }; + + let requested_pin = js::truthy(field(raw, "pinEnabled")); + let pin_hash = if requested_pin { + sanitize_pin_hash(field(raw, "pinHash")) + } else { + String::new() + }; + // An enabled flag without a usable hash protects nothing: setup again. + let pin_enabled = requested_pin && !pin_hash.is_empty(); + + let theme_requested = match field(raw, "themeId") { + Some(Value::String(id)) => id.as_str(), + _ if js::as_str(field(raw, "theme")) == Some("light") => "light", + _ => DEFAULT_THEME_ID, + }; + + AppSettings { + language: if has_field(raw, "language") { + i18n::normalize_locale(field(raw, "language")) + } else { + i18n::detect_preferred_locale(&ctx.preferred_languages) + }, + theme_id: ctx.resolve_theme_id(theme_requested).to_string(), + background_opacity: js::clamp_int(field(raw, "backgroundOpacity"), 0, 100, 100), + ui_scale_percent: js::clamp_int(field(raw, "uiScalePercent"), 75, 150, 100), + animations: match js::as_str(field(raw, "animations")) { + Some("on") => AnimationsMode::On, + Some("off") => AnimationsMode::Off, + _ => AnimationsMode::System, + }, + streamer_mode: if js::as_str(field(raw, "streamerMode")) == Some("off") { + StreamerMode::Off + } else { + StreamerMode::Auto + }, + suspend_graphics_when_minimized: js::not_false(field(raw, "suspendGraphicsWhenMinimized")), + minimize_on_account_switch: js::truthy(field(raw, "minimizeOnAccountSwitch")), + start_with_system: js::truthy(field(raw, "startWithSystem")), + close_to_tray: js::truthy(field(raw, "closeToTray")), + data_refresh: DataRefreshSettings { + avatar_cache_days: js::clamp_int( + js::nullish_or( + field(raw_data_refresh, "avatarCacheDays"), + field(raw, "avatarCacheDays"), + ), + 0, + 90, + 7, + ), + ban_check_days: js::clamp_int( + js::nullish_or( + field(raw_data_refresh, "banCheckDays"), + field(raw, "banCheckDays"), + ), + 0, + 90, + 7, + ), + }, + enabled_platforms, + health_check_per_platform: sanitize_health_check(raw), + personas_enabled: js::not_false(field(raw, "personasEnabled")), + default_platform_id, + inactivity_blur_seconds: js::clamp_int(field(raw, "inactivityBlurSeconds"), 0, 3600, 60), + deep_links_enabled: js::not_false(field(raw, "deepLinksEnabled")), + cli_enabled: js::not_false(field(raw, "cliEnabled")), + platform_settings: PlatformSettings { + steam: sanitize_steam_settings(field(raw_platform_settings, "steam"), raw), + }, + account_display: AccountDisplaySettings { + show_usernames: js::not_false(field(raw_account_display, "showUsernames")) + && js::not_false(field(raw, "showUsernames")), + show_last_login_per_platform: sanitize_show_last_login(raw_account_display, ctx), + show_card_notes_inline: js::truthy(js::nullish_or( + field(raw_account_display, "showCardNotesInline"), + field(raw, "showCardNotesInline"), + )), + expanded_folders: js::truthy(js::nullish_or( + field(raw_account_display, "expandedFolders"), + field(raw, "expandedFolders"), + )), + }, + pin_enabled, + pin_hash, + } +} + +/// `getCacheDuration`: how long a cached avatar stays fresh, in milliseconds +/// (0 means never cache). +pub fn cache_duration_ms(settings: &AppSettings) -> u64 { + let days = settings.data_refresh.avatar_cache_days.max(0) as u64; + days * 24 * 60 * 60 * 1000 +} + +// ------------------------------------------------------------ settingsPatch + +/// One setting that changed: where it sits and its new value (`None` when the +/// key is gone, `undefined` in TS). +#[derive(Debug, Clone, PartialEq)] +pub struct SettingsChange { + pub path: Vec, + pub value: Option, +} + +/// `diffSettings`: every leaf that differs between `before` and `after`. +/// Objects are walked key by key (sorted, where TS takes `before`'s key order +/// then `after`'s); lists and scalars are compared and replaced whole. +pub fn diff_settings(before: Option<&Value>, after: Option<&Value>) -> Vec { + let mut out = Vec::new(); + diff_into(before, after, &mut Vec::new(), &mut out); + out +} + +fn diff_into( + before: Option<&Value>, + after: Option<&Value>, + path: &mut Vec, + out: &mut Vec, +) { + if let (Some(Value::Object(left)), Some(Value::Object(right))) = (before, after) { + let mut keys: Vec<&String> = left.keys().chain(right.keys()).collect(); + keys.sort(); + keys.dedup(); + for key in keys { + path.push(key.clone()); + diff_into(left.get(key), right.get(key), path, out); + path.pop(); + } + return; + } + if before != after { + out.push(SettingsChange { + path: path.clone(), + value: after.cloned(), + }); + } +} + +fn apply_change(target: &mut Map, change: &SettingsChange) { + let Some((leaf, parents)) = change.path.split_last() else { + return; + }; + let mut node = target; + for key in parents { + let slot = node + .entry(key.clone()) + .or_insert_with(|| Value::Object(Map::new())); + if !slot.is_object() { + *slot = Value::Object(Map::new()); + } + let Value::Object(next) = slot else { + return; + }; + node = next; + } + match &change.value { + None => { + node.remove(leaf); + } + Some(value) => { + node.insert(leaf.clone(), value.clone()); + } + } +} + +/// `mergeSettingsDraft` on JSON: `current` with only the leaves the user +/// changed between `baseline` and `draft`. +pub fn merge_settings_draft_value(current: &Value, baseline: &Value, draft: &Value) -> Value { + let mut merged = match current { + Value::Object(map) => map.clone(), + _ => Map::new(), + }; + for change in diff_settings(Some(baseline), Some(draft)) { + if !change.path.is_empty() { + apply_change(&mut merged, &change); + } + } + Value::Object(merged) +} + +/// `mergeSettingsDraft`: the settings to save from an open panel, so a field +/// something else wrote meanwhile (zoom shortcut, streamer banner, a PIN hash +/// upgraded on unlock) is not put back. +pub fn merge_settings_draft( + current: &AppSettings, + baseline: &AppSettings, + draft: &AppSettings, +) -> Result { + let merged = + merge_settings_draft_value(¤t.to_value(), &baseline.to_value(), &draft.to_value()); + serde_json::from_value(merged) +} + +// ------------------------------------------------------------ apiKeyDraft + +/// The API key field after a successful save. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiKeyDraft { + pub api_key: String, + pub touched: bool, +} + +/// `apiKeyDraftAfterSuccessfulSave`: clear the field only when it still holds +/// the key just written; a replacement typed meanwhile stays dirty. +pub fn api_key_draft_after_successful_save(current: &str, saved: &str) -> ApiKeyDraft { + if js::trim(current) == saved { + ApiKeyDraft { + api_key: String::new(), + touched: false, + } + } else { + ApiKeyDraft { + api_key: current.to_string(), + touched: true, + } + } +} + +// ------------------------------------------------------------ onboardingConsent + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnboardingConsentOutcome { + Saved, + Failed, +} + +/// `completeOnboardingConsent`: a failure stays in the dialog, so the caller +/// shows it inline instead of a toast. +pub fn complete_onboarding_consent( + persist: impl FnOnce() -> Result, +) -> OnboardingConsentOutcome { + match persist() { + Ok(_) => OnboardingConsentOutcome::Saved, + Err(error) => { + eprintln!("telemetry_complete_onboarding failed: {error}"); + OnboardingConsentOutcome::Failed + } + } +} diff --git a/crates/accshift-app/src/storage.rs b/crates/accshift-app/src/storage.rs new file mode 100644 index 00000000..a9c58a88 --- /dev/null +++ b/crates/accshift-app/src/storage.rs @@ -0,0 +1,544 @@ +//! The client storage layer, ported from `src/lib/storage/clientStorage.ts` +//! and `storeIds.ts`. +//! +//! [`ClientStorage`] holds the raw JSON of every client store, exactly what +//! the backend's `load_client_storage_snapshot` hands over and what +//! `save_client_storage_store` writes. It never calls the backend itself +//! during a frame: a write marks its store pending, and the caller drains +//! [`ClientStorage::take_due_saves`] into the job pool, runs [`persist`] there +//! and reports back with [`ClientStorage::finish_save`]. The debounce, the +//! one-save-at-a-time rule per store, the manifest bookkeeping and the +//! external-refresh rules are the TS ones. +//! +//! Legacy `localStorage` keys cannot be read from Rust. [`initialize`] takes +//! them as an optional map (`key -> raw string`, as `localStorage.getItem` +//! returns it) for whoever reads them later. + +use crate::backend::Backend; +use crate::error::{BackendError, BackendResult}; +use crate::types::{ClientStorageSnapshot, StorageManifest}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::time::{Duration, Instant}; + +pub use accshift_core::storage::{ + STORAGE_SCHEMA_VERSION, STORE_ACCOUNT_CARD_COLORS as CLIENT_STORE_ACCOUNT_CARD_COLORS, + STORE_ACCOUNT_CARD_NOTES as CLIENT_STORE_ACCOUNT_CARD_NOTES, + STORE_ACCOUNT_DEFAULT_GAME as CLIENT_STORE_ACCOUNT_DEFAULT_GAME, + STORE_FOLDERS as CLIENT_STORE_FOLDERS, + STORE_FOLDER_CARD_COLORS as CLIENT_STORE_FOLDER_CARD_COLORS, + STORE_PERSONAS as CLIENT_STORE_PERSONAS, + STORE_ROBLOX_PROFILE_CACHE as CLIENT_STORE_ROBLOX_PROFILE_CACHE, + STORE_SETTINGS as CLIENT_STORE_SETTINGS, + STORE_STEAM_BAN_CHECK_STATE as CLIENT_STORE_STEAM_BAN_CHECK_STATE, + STORE_STEAM_BAN_INFO_CACHE as CLIENT_STORE_STEAM_BAN_INFO_CACHE, + STORE_STEAM_PROFILE_CACHE as CLIENT_STORE_STEAM_PROFILE_CACHE, + STORE_VIEW_MODE as CLIENT_STORE_VIEW_MODE, + TARGET_APP_CONFIG_LOCAL as STORAGE_TARGET_APP_CONFIG_LOCAL, + TARGET_APP_CONFIG_PORTABLE as STORAGE_TARGET_APP_CONFIG_PORTABLE, + TARGET_CUSTOM_THEMES as STORAGE_TARGET_CUSTOM_THEMES, + TARGET_DISCORD_SNAPSHOTS as STORAGE_TARGET_DISCORD_SNAPSHOTS, + TARGET_EPIC_SNAPSHOTS as STORAGE_TARGET_EPIC_SNAPSHOTS, + TARGET_GOG_SNAPSHOTS as STORAGE_TARGET_GOG_SNAPSHOTS, + TARGET_JAGEX_SNAPSHOTS as STORAGE_TARGET_JAGEX_SNAPSHOTS, + TARGET_RIOT_SNAPSHOTS as STORAGE_TARGET_RIOT_SNAPSHOTS, + TARGET_UBISOFT_SNAPSHOTS as STORAGE_TARGET_UBISOFT_SNAPSHOTS, +}; + +/// A client store id: one of [`CLIENT_STORE_IDS`]. +pub type StoreId = &'static str; + +/// Every client store, in the TS order. +pub const CLIENT_STORE_IDS: [StoreId; 12] = [ + CLIENT_STORE_SETTINGS, + CLIENT_STORE_FOLDERS, + CLIENT_STORE_PERSONAS, + CLIENT_STORE_ACCOUNT_CARD_NOTES, + CLIENT_STORE_ACCOUNT_CARD_COLORS, + CLIENT_STORE_ACCOUNT_DEFAULT_GAME, + CLIENT_STORE_FOLDER_CARD_COLORS, + CLIENT_STORE_VIEW_MODE, + CLIENT_STORE_STEAM_PROFILE_CACHE, + CLIENT_STORE_ROBLOX_PROFILE_CACHE, + CLIENT_STORE_STEAM_BAN_CHECK_STATE, + CLIENT_STORE_STEAM_BAN_INFO_CACHE, +]; + +/// The `localStorage` key each store lived under before the file stores. +pub const LEGACY_LOCAL_STORAGE_KEYS: [(StoreId, &str); 12] = [ + (CLIENT_STORE_SETTINGS, "accshift_settings"), + (CLIENT_STORE_FOLDERS, "accshift_folders"), + (CLIENT_STORE_PERSONAS, "accshift_personas"), + ( + CLIENT_STORE_ACCOUNT_CARD_NOTES, + "accshift_account_card_notes", + ), + ( + CLIENT_STORE_ACCOUNT_CARD_COLORS, + "accshift_account_card_colors", + ), + ( + CLIENT_STORE_ACCOUNT_DEFAULT_GAME, + "accshift_account_default_game", + ), + ( + CLIENT_STORE_FOLDER_CARD_COLORS, + "accshift_folder_card_colors", + ), + (CLIENT_STORE_VIEW_MODE, "accshift_viewMode"), + (CLIENT_STORE_STEAM_PROFILE_CACHE, "accshift_avatars"), + (CLIENT_STORE_ROBLOX_PROFILE_CACHE, "accshift_roblox_avatars"), + ( + CLIENT_STORE_STEAM_BAN_CHECK_STATE, + "accshift_ban_check_state_v2", + ), + ( + CLIENT_STORE_STEAM_BAN_INFO_CACHE, + "accshift_ban_info_cache_v1", + ), +]; + +/// How long a write waits before it is due, like the TS `scheduleSave`. +pub const SAVE_DEBOUNCE: Duration = Duration::from_millis(120); + +/// Raw legacy `localStorage` values by key, as `getItem` returns them. +pub type LegacyLocalStorage = BTreeMap; + +/// The [`StoreId`] spelled `id`, if it is one. +pub fn client_store_id(id: &str) -> Option { + CLIENT_STORE_IDS.iter().copied().find(|known| *known == id) +} + +/// `isClientStoreId`. +pub fn is_client_store_id(id: &str) -> bool { + client_store_id(id).is_some() +} + +/// The legacy `localStorage` key of a store. +pub fn legacy_local_storage_key(store_id: &str) -> Option<&'static str> { + LEGACY_LOCAL_STORAGE_KEYS + .iter() + .find(|(id, _)| *id == store_id) + .map(|(_, key)| *key) +} + +/// `readLegacyLocalStorageValue`: the view mode is a bare string, every other +/// store JSON; a value that does not parse counts as absent. +pub fn read_legacy_value(store_id: StoreId, legacy: &LegacyLocalStorage) -> Option { + let raw = legacy.get(legacy_local_storage_key(store_id)?)?; + if store_id == CLIENT_STORE_VIEW_MODE { + return Some(Value::String(raw.clone())); + } + serde_json::from_str(raw).ok() +} + +/// One store write to run off the UI thread with [`persist`]. +#[derive(Debug, Clone, PartialEq)] +pub struct PendingSave { + pub store_id: StoreId, + /// `null` deletes the store file. + pub value: Value, +} + +/// `persistStore`: writes the store and answers its new fingerprint (`None` +/// from the mock). +pub fn persist(backend: &dyn Backend, save: &PendingSave) -> BackendResult> { + backend.save_client_storage_store(save.store_id, &save.value) +} + +/// `emitStorageLog`: an info line under `frontend.storage`. Failures are +/// dropped, as in TS. +pub fn storage_log(backend: &dyn Backend, message: &str, details: Option<&Value>) { + let details = details.map(Value::to_string); + let _ = backend.log_app_event("info", "frontend.storage", message, details.as_deref()); +} + +/// What [`initialize`] did. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct InitReport { + /// The snapshot could not be read: every store is empty and nothing was + /// migrated, so a later launch can still read the files. + pub load_error: Option, + /// Stores filled from the legacy keys and written to disk. + pub migrated: Vec, + /// Legacy stores whose write failed (they stay in memory). + pub failed_migrations: Vec<(StoreId, String)>, +} + +/// `initializeClientStorage`, blocking: run it before the first frame or in a +/// job. `boot_snapshot` is the one the boot payload carried; without it the +/// snapshot is loaded through the backend. +pub fn initialize( + backend: &dyn Backend, + boot_snapshot: Option, + legacy: Option<&LegacyLocalStorage>, + mock_backend: bool, +) -> (ClientStorage, InitReport) { + let mut report = InitReport::default(); + let snapshot = match boot_snapshot { + Some(snapshot) => Ok(snapshot), + None => backend.load_client_storage_snapshot(), + }; + let snapshot = match snapshot { + Ok(snapshot) => snapshot, + Err(error) => { + report.load_error = Some(error.message); + return (ClientStorage::new(), report); + } + }; + let mut storage = ClientStorage::from_snapshot(snapshot); + storage_log( + backend, + "Loaded client storage into memory", + Some(&json!({ "storeCount": storage.snapshot_store_count })), + ); + + // The legacy keys are the real user's data; the mock saves nothing, so + // migrating there would only put that data on screen. + if mock_backend { + return (storage, report); + } + let Some(legacy) = legacy else { + return (storage, report); + }; + let migrated = storage.migrate_legacy(legacy); + if migrated.is_empty() { + return (storage, report); + } + for save in storage.take_all_saves() { + let result = persist(backend, &save); + if let Err(error) = &result { + report + .failed_migrations + .push((save.store_id, error.message.clone())); + } + storage.finish_save(save.store_id, &result); + } + storage_log( + backend, + "Migrated legacy localStorage stores", + Some(&json!({ "storeIds": migrated })), + ); + if let Ok(manifest) = backend.get_storage_manifest() { + storage.replace_manifest(manifest); + } + report.migrated = migrated; + (storage, report) +} + +/// The raw stores, their revisions, the last manifest seen and the saves +/// still owed to disk. +#[derive(Debug, Clone)] +pub struct ClientStorage { + values: BTreeMap, + revisions: BTreeMap, + manifest: StorageManifest, + /// Debounced writes not handed out yet, with the instant each is due. + pending: BTreeMap, + /// Writes handed out and not reported back. + in_flight: BTreeSet, + snapshot_store_count: usize, +} + +impl Default for ClientStorage { + fn default() -> Self { + Self::new() + } +} + +impl ClientStorage { + /// Every store empty, the manifest at schema 1 with no entry. + pub fn new() -> Self { + Self { + values: BTreeMap::new(), + revisions: BTreeMap::new(), + manifest: StorageManifest { + schema_version: STORAGE_SCHEMA_VERSION, + stores: BTreeMap::new(), + }, + pending: BTreeMap::new(), + in_flight: BTreeSet::new(), + snapshot_store_count: 0, + } + } + + pub fn from_snapshot(snapshot: ClientStorageSnapshot) -> Self { + let mut storage = Self::new(); + storage.apply_snapshot(snapshot); + storage + } + + /// `applySnapshot`: takes the snapshot's manifest and every store's value, + /// bumping every revision. + pub fn apply_snapshot(&mut self, snapshot: ClientStorageSnapshot) { + self.snapshot_store_count = snapshot.stores.len(); + self.manifest = snapshot.manifest; + let mut stores = snapshot.stores; + for id in CLIENT_STORE_IDS { + let value = stores.remove(id).unwrap_or(Value::Null); + self.values.insert(id, value); + self.bump(id); + } + } + + fn bump(&mut self, id: StoreId) { + *self.revisions.entry(id).or_insert(0) += 1; + } + + /// `getClientStoreValue`: `None` for a missing or `null` store. + pub fn get(&self, store_id: &str) -> Option<&Value> { + self.values.get(store_id).filter(|value| !value.is_null()) + } + + /// `getClientStoreRevision`: bumped by every write, snapshot and reload. + pub fn revision(&self, store_id: &str) -> u64 { + self.revisions.get(store_id).copied().unwrap_or(0) + } + + /// The last manifest seen, with this session's own writes recorded. + pub fn manifest(&self) -> &StorageManifest { + &self.manifest + } + + /// Takes `manifest` whole, as the TS migration path does after its saves. + pub fn replace_manifest(&mut self, manifest: StorageManifest) { + self.manifest = manifest; + } + + /// `setClientStoreValue` with the 120 ms debounce. + pub fn set(&mut self, store_id: StoreId, value: Value) { + self.set_with_delay(store_id, value, SAVE_DEBOUNCE); + } + + /// `setClientStoreValue(..., { immediate: true })`. + pub fn set_immediate(&mut self, store_id: StoreId, value: Value) { + self.set_with_delay(store_id, value, Duration::ZERO); + } + + fn set_with_delay(&mut self, store_id: StoreId, value: Value, delay: Duration) { + self.values.insert(store_id, value); + self.bump(store_id); + // A new write restarts the debounce, like clearTimeout + setTimeout. + self.pending.insert(store_id, Instant::now() + delay); + } + + /// `migrate`: every store still empty takes its legacy value, due at once. + /// Returns the stores it filled. + pub fn migrate_legacy(&mut self, legacy: &LegacyLocalStorage) -> Vec { + let mut migrated = Vec::new(); + for id in CLIENT_STORE_IDS { + if self.get(id).is_some() { + continue; + } + if let Some(value) = read_legacy_value(id, legacy) { + self.values.insert(id, value); + self.pending.insert(id, Instant::now()); + migrated.push(id); + } + } + migrated + } + + /// `hasPendingSave`: a local edit not yet on disk, debounced or writing. + pub fn has_pending_save(&self, store_id: &str) -> bool { + self.pending.contains_key(store_id) || self.in_flight.contains(store_id) + } + + /// True while any write is owed to disk. + pub fn has_unsaved(&self) -> bool { + !self.pending.is_empty() || !self.in_flight.is_empty() + } + + /// When the next debounced write falls due, for a timed repaint. + pub fn next_save_due(&self) -> Option { + self.pending + .iter() + .filter(|(id, _)| !self.in_flight.contains(*id)) + .map(|(_, due)| *due) + .min() + } + + /// The writes due at `now`. A store whose previous write is still in + /// flight waits for it, so two writes of one store never race. + pub fn take_due_saves(&mut self, now: Instant) -> Vec { + self.take_saves(|due| due <= now) + } + + /// `flushPendingSaves`: every pending write, due or not. Writes in flight + /// are the caller's to wait for; their store is handed out again here once + /// they are reported, if it changed meanwhile. + pub fn take_all_saves(&mut self) -> Vec { + self.take_saves(|_| true) + } + + fn take_saves(&mut self, due: impl Fn(Instant) -> bool) -> Vec { + let ready: Vec = self + .pending + .iter() + .filter(|(id, at)| !self.in_flight.contains(*id) && due(**at)) + .map(|(id, _)| *id) + .collect(); + ready + .into_iter() + .map(|id| { + self.pending.remove(id); + self.in_flight.insert(id); + PendingSave { + store_id: id, + value: self.values.get(id).cloned().unwrap_or(Value::Null), + } + }) + .collect() + } + + /// Reports a write handed out by `take_*_saves`. The fingerprint the + /// backend answers marks the write as our own, so the next manifest diff + /// does not read it back as an external change. A failed write is not + /// retried, as in TS: the value stays in memory until the next edit. + pub fn finish_save(&mut self, store_id: StoreId, result: &BackendResult>) { + self.in_flight.remove(store_id); + if let Ok(Some(fingerprint)) = result { + self.manifest + .stores + .insert(store_id.to_string(), fingerprint.clone()); + } + } + + /// Writes every pending store now and waits for each, like awaiting + /// `flushPendingSaves` before the window closes. Writes handed to the job + /// pool earlier are not awaited here. + pub fn flush_blocking(&mut self, backend: &dyn Backend) -> Vec<(StoreId, BackendError)> { + let mut failures = Vec::new(); + for save in self.take_all_saves() { + let result = persist(backend, &save); + if let Err(error) = &result { + failures.push((save.store_id, error.clone())); + } + self.finish_save(save.store_id, &result); + } + failures + } + + /// `recordManifestEntries`: marks only `keys` of `manifest` as seen. + fn record_manifest_entries(&mut self, manifest: &StorageManifest, keys: &[String]) { + for key in keys { + match manifest.stores.get(key) { + Some(fingerprint) => { + self.manifest + .stores + .insert(key.clone(), fingerprint.clone()); + } + None => { + self.manifest.stores.remove(key); + } + } + } + self.manifest.schema_version = manifest.schema_version; + } + + /// First half of `refreshClientStorageIfChanged`, given a fresh manifest. + /// Returns `None` when nothing changed. Storage targets that are not + /// client stores are marked as seen right away; client stores with a write + /// owed to disk are left alone. + pub fn begin_refresh(&mut self, next: &StorageManifest) -> Option { + let changed = diff_manifests(&self.manifest, next) + .into_iter() + .filter(|key| !is_client_store_id(key) || !self.has_pending_save(key)) + .collect::>(); + if changed.is_empty() { + return None; + } + let other_targets: Vec = changed + .iter() + .filter(|key| !is_client_store_id(key)) + .cloned() + .collect(); + self.record_manifest_entries(next, &other_targets); + let stores = changed + .iter() + .filter_map(|key| client_store_id(key)) + .map(|id| (id, self.revision(id))) + .collect(); + Some(RefreshPlan { + changed, + other_targets, + stores, + }) + } + + /// Second half: applies the stores of `snapshot` that saw no local write + /// since [`ClientStorage::begin_refresh`], and returns every id that + /// changed (other targets first). + pub fn finish_refresh( + &mut self, + plan: RefreshPlan, + snapshot: ClientStorageSnapshot, + ) -> Vec { + let applied: Vec = plan + .stores + .iter() + .filter(|(id, revision)| !self.has_pending_save(id) && self.revision(id) == *revision) + .map(|(id, _)| *id) + .collect(); + let mut stores = snapshot.stores; + for id in &applied { + let value = stores.remove(*id).unwrap_or(Value::Null); + self.values.insert(id, value); + self.bump(id); + } + let applied_keys: Vec = applied.iter().map(|id| id.to_string()).collect(); + self.record_manifest_entries(&snapshot.manifest, &applied_keys); + plan.other_targets.into_iter().chain(applied_keys).collect() + } + + /// `refreshClientStorageIfChanged`, blocking. + pub fn refresh_blocking(&mut self, backend: &dyn Backend) -> BackendResult> { + let next = backend.get_storage_manifest()?; + let Some(plan) = self.begin_refresh(&next) else { + return Ok(Vec::new()); + }; + storage_log( + backend, + "Detected external storage changes", + Some(&json!({ "changed": plan.changed })), + ); + if !plan.needs_snapshot() { + return Ok(plan.other_targets); + } + let snapshot = backend.load_client_storage_snapshot()?; + Ok(self.finish_refresh(plan, snapshot)) + } +} + +/// What a refresh found in the manifest, carried between the manifest read +/// and the snapshot read. +#[derive(Debug, Clone, PartialEq)] +pub struct RefreshPlan { + /// Every changed key worth reporting, stores and other targets. + pub changed: Vec, + /// Changed keys that are not client stores. + pub other_targets: Vec, + /// Changed client stores, with their revision before the snapshot read. + pub stores: Vec<(StoreId, u64)>, +} + +impl RefreshPlan { + /// False when only non-store targets changed: no snapshot is needed and + /// the result is [`RefreshPlan::other_targets`]. + pub fn needs_snapshot(&self) -> bool { + !self.stores.is_empty() + } +} + +/// `diffManifests`: keys whose fingerprint differs, a missing one reading as +/// `""`. Sorted by key (TS lists the previous manifest's keys first). +pub fn diff_manifests(previous: &StorageManifest, next: &StorageManifest) -> Vec { + let keys: BTreeSet<&String> = previous.stores.keys().chain(next.stores.keys()).collect(); + keys.into_iter() + .filter(|key| { + previous.stores.get(*key).map_or("", String::as_str) + != next.stores.get(*key).map_or("", String::as_str) + }) + .cloned() + .collect() +} diff --git a/crates/accshift-app/src/stores.rs b/crates/accshift-app/src/stores.rs new file mode 100644 index 00000000..ef10d552 --- /dev/null +++ b/crates/accshift-app/src/stores.rs @@ -0,0 +1,765 @@ +//! Every client store behind one value, plus the small stores of +//! `src/lib/shared/` (`accountCardColors`, `accountCardNotes`, +//! `folderCardColors`, `viewMode`, `cachedMapStore`) and +//! `src/lib/platforms/steam/defaultGameStore.ts`. +//! +//! [`ClientStores`] owns the [`ClientStorage`] and a typed copy of each store +//! the UI reads. A mutation updates the typed copy and writes its JSON into +//! the storage, which marks that store pending; the shell drains +//! [`ClientStores::take_due_saves`] into the job pool and reports each result +//! with [`ClientStores::finish_save`]. A typed copy is rebuilt (sanitized) +//! only when its store changes from outside: a snapshot or a refresh. A +//! local write keeps the value as written, like the TS caches. + +use crate::folders::{self, FolderInfo, FolderSection, FolderStore, ItemRef}; +use crate::i18n::Locale; +use crate::js; +use crate::personas::{self, Persona, PersonaInput, PersonaPatch}; +use crate::settings::{self, AppSettings}; +use crate::storage::{ + ClientStorage, PendingSave, RefreshPlan, StoreId, CLIENT_STORE_ACCOUNT_CARD_COLORS, + CLIENT_STORE_ACCOUNT_CARD_NOTES, CLIENT_STORE_ACCOUNT_DEFAULT_GAME, CLIENT_STORE_FOLDERS, + CLIENT_STORE_FOLDER_CARD_COLORS, CLIENT_STORE_PERSONAS, CLIENT_STORE_SETTINGS, + CLIENT_STORE_VIEW_MODE, +}; +use crate::types::ClientStorageSnapshot; +use crate::{Backend, BackendResult}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::time::Instant; + +/// The platforms shipped with the app, in display order: the platform +/// registry's chrome entries (`src/lib/platforms/registry.ts`), which also +/// cover every shipped descriptor. +pub const SHIPPED_PLATFORM_IDS: [&str; 9] = [ + "steam", + "riot", + "battle-net", + "ubisoft", + "roblox", + "epic", + "gog", + "jagex", + "discord", +]; + +/// The built-in theme ids (`BUILT_IN_DOCUMENTS` in `src/lib/theme/themes.ts`). +pub const BUILT_IN_THEME_IDS: [&str; 6] = [ + "dark", + "light", + "midnight", + "glass-dark", + "glass-light", + "liquid-glass", +]; + +/// What the sanitizers read from outside the stores: the platform list (the +/// shipped ones plus the user's descriptors), the theme list (built-ins plus +/// custom themes) and the OS's preferred languages for a fresh install. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoreContext { + pub platform_ids: Vec, + pub theme_ids: Vec, + /// Most preferred first, like `navigator.languages`. + pub preferred_languages: Vec, +} + +impl Default for StoreContext { + fn default() -> Self { + Self::shipped() + } +} + +impl StoreContext { + /// Shipped platforms and built-in themes, no preferred language. + pub fn shipped() -> Self { + Self { + platform_ids: SHIPPED_PLATFORM_IDS + .iter() + .map(|id| id.to_string()) + .collect(), + theme_ids: BUILT_IN_THEME_IDS.iter().map(|id| id.to_string()).collect(), + preferred_languages: Vec::new(), + } + } + + pub fn platform_ids(&self) -> impl Iterator { + self.platform_ids.iter().map(String::as_str) + } + + pub fn knows_platform(&self, id: &str) -> bool { + self.platform_ids.iter().any(|known| known == id) + } + + /// `getThemeDefinition(id).id`: the id when it names a theme, the default + /// theme otherwise. + pub fn resolve_theme_id<'a>(&'a self, id: &'a str) -> &'a str { + if !id.is_empty() && self.theme_ids.iter().any(|known| known == id) { + id + } else { + settings::DEFAULT_THEME_ID + } + } +} + +// ------------------------------------------------------------ cachedMapStore + +/// A string map store (`createCachedMapStore`) with its entry sanitizer. +#[derive(Debug, Clone)] +pub struct StringMapStore { + store_id: StoreId, + sanitize_entry: fn(&str, &str) -> Option, + map: BTreeMap, +} + +impl StringMapStore { + pub fn new(store_id: StoreId, sanitize_entry: fn(&str, &str) -> Option) -> Self { + Self { + store_id, + sanitize_entry, + map: BTreeMap::new(), + } + } + + pub fn store_id(&self) -> StoreId { + self.store_id + } + + /// `sanitizeMap`: string values under non-blank keys that pass the entry + /// sanitizer. + pub fn load(&mut self, value: Option<&Value>) { + self.map.clear(); + let Some(record) = js::as_record(value) else { + return; + }; + for (key, raw) in record { + if js::trim(key).is_empty() { + continue; + } + let Value::String(raw) = raw else { + continue; + }; + if let Some(safe) = (self.sanitize_entry)(key, raw) { + self.map.insert(key.clone(), safe); + } + } + } + + /// `get`: the entry, `""` when absent. + pub fn get(&self, id: &str) -> &str { + self.map.get(id).map_or("", String::as_str) + } + + pub fn entries(&self) -> &BTreeMap { + &self.map + } + + fn put(&mut self, id: &str, value: &str) { + match (self.sanitize_entry)(id, value) { + Some(safe) => { + self.map.insert(id.to_string(), safe); + } + None => { + self.map.remove(id); + } + } + } + + /// `set`: a value the sanitizer refuses removes the entry. + pub fn set(&mut self, id: &str, value: &str) { + self.put(id, value); + } + + /// `setMany`: the same value for every id. Returns false (and writes + /// nothing) for an empty list. + pub fn set_many(&mut self, ids: &[String], value: &str) -> bool { + if ids.is_empty() { + return false; + } + for id in ids { + self.put(id, value); + } + true + } + + /// `remove`. + pub fn remove(&mut self, id: &str) { + self.map.remove(id); + } + + pub fn to_value(&self) -> Value { + serde_json::to_value(&self.map).unwrap_or(Value::Null) + } +} + +/// One account card colour preset. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ColorPreset { + pub id: &'static str, + pub color: &'static str, +} + +/// `ACCOUNT_CARD_COLOR_PRESETS`. +pub const ACCOUNT_CARD_COLOR_PRESETS: [ColorPreset; 11] = [ + ColorPreset { + id: "none", + color: "", + }, + ColorPreset { + id: "blue", + color: "#3b82f6", + }, + ColorPreset { + id: "cyan", + color: "#06b6d4", + }, + ColorPreset { + id: "green", + color: "#10b981", + }, + ColorPreset { + id: "lime", + color: "#84cc16", + }, + ColorPreset { + id: "yellow", + color: "#f59e0b", + }, + ColorPreset { + id: "orange", + color: "#f97316", + }, + ColorPreset { + id: "red", + color: "#f43f5e", + }, + ColorPreset { + id: "pink", + color: "#ec4899", + }, + ColorPreset { + id: "violet", + color: "#8b5cf6", + }, + ColorPreset { + id: "gray", + color: "#71717a", + }, +]; + +/// `SAFE_COLOR_RE`: `#rgb`, `#rrggbb` or `#rrggbbaa`. +pub fn sanitize_card_color(_key: &str, color: &str) -> Option { + js::is_hex_color(color, &[3, 6, 8]).then(|| color.to_string()) +} + +/// `MAX_NOTE_LENGTH`, in UTF-16 units. +pub const MAX_NOTE_LENGTH: usize = 180; + +/// `sanitizeNote`: control characters become spaces, then trim and cut. +pub fn sanitize_note(_key: &str, value: &str) -> Option { + let spaced: String = value + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let clean = js::utf16_prefix(js::trim(&spaced), MAX_NOTE_LENGTH); + (!clean.is_empty()).then_some(clean) +} + +// ------------------------------------------------------------ viewMode + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ViewMode { + #[default] + Grid, + List, +} + +/// `getViewMode`: `"list"` is the list, anything else the grid. +pub fn view_mode_from_value(value: Option<&Value>) -> ViewMode { + if js::as_str(value) == Some("list") { + ViewMode::List + } else { + ViewMode::Grid + } +} + +impl ViewMode { + pub fn as_str(self) -> &'static str { + match self { + ViewMode::Grid => "grid", + ViewMode::List => "list", + } + } +} + +// ------------------------------------------------------------ defaultGameStore + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DefaultGame { + pub app_id: String, + pub name: String, +} + +/// The default game store's `sanitize`: entries with a numeric `appId`. +pub fn sanitize_default_games(value: Option<&Value>) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(record) = js::as_record(value) else { + return out; + }; + for (key, raw) in record { + if js::trim(key).is_empty() { + continue; + } + let Some(entry) = js::as_record(Some(raw)) else { + continue; + }; + let app_id = js::as_str(entry.get("appId")).map_or("", js::trim); + let name = js::as_str(entry.get("name")).unwrap_or_default(); + if !js::is_ascii_digits(app_id) { + continue; + } + out.insert( + key.clone(), + DefaultGame { + app_id: app_id.to_string(), + name: name.to_string(), + }, + ); + } + out +} + +// ------------------------------------------------------------ ClientStores + +/// Every client store the UI reads, typed, over the raw [`ClientStorage`]. +#[derive(Debug, Clone)] +pub struct ClientStores { + ctx: StoreContext, + storage: ClientStorage, + settings: AppSettings, + folders: FolderStore, + personas: Vec, + account_card_notes: StringMapStore, + account_card_colors: StringMapStore, + folder_card_colors: StringMapStore, + default_games: BTreeMap, + view_mode: ViewMode, + /// The storage revision each typed copy was built from or written at. + seen: BTreeMap, +} + +const TYPED_STORES: [StoreId; 8] = [ + CLIENT_STORE_SETTINGS, + CLIENT_STORE_FOLDERS, + CLIENT_STORE_PERSONAS, + CLIENT_STORE_ACCOUNT_CARD_NOTES, + CLIENT_STORE_ACCOUNT_CARD_COLORS, + CLIENT_STORE_ACCOUNT_DEFAULT_GAME, + CLIENT_STORE_FOLDER_CARD_COLORS, + CLIENT_STORE_VIEW_MODE, +]; + +impl ClientStores { + pub fn new(storage: ClientStorage, ctx: StoreContext) -> Self { + let settings = settings::sanitize_settings(&Value::Null, &ctx); + let mut stores = Self { + ctx, + storage, + settings, + folders: FolderStore::default(), + personas: Vec::new(), + account_card_notes: StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, sanitize_note), + account_card_colors: StringMapStore::new( + CLIENT_STORE_ACCOUNT_CARD_COLORS, + sanitize_card_color, + ), + folder_card_colors: StringMapStore::new( + CLIENT_STORE_FOLDER_CARD_COLORS, + sanitize_card_color, + ), + default_games: BTreeMap::new(), + view_mode: ViewMode::Grid, + seen: BTreeMap::new(), + }; + for id in TYPED_STORES { + stores.reload(id); + } + stores + } + + fn reload(&mut self, id: StoreId) { + let value = self.storage.get(id); + match id { + CLIENT_STORE_SETTINGS => { + self.settings = + settings::sanitize_settings(value.unwrap_or(&Value::Null), &self.ctx) + } + CLIENT_STORE_FOLDERS => self.folders = folders::load_folder_store(value), + CLIENT_STORE_PERSONAS => { + self.personas = personas::sanitize_personas( + value.unwrap_or(&Value::Array(Vec::new())), + &self.ctx, + ) + } + CLIENT_STORE_ACCOUNT_CARD_NOTES => self.account_card_notes.load(value), + CLIENT_STORE_ACCOUNT_CARD_COLORS => self.account_card_colors.load(value), + CLIENT_STORE_FOLDER_CARD_COLORS => self.folder_card_colors.load(value), + CLIENT_STORE_ACCOUNT_DEFAULT_GAME => self.default_games = sanitize_default_games(value), + CLIENT_STORE_VIEW_MODE => self.view_mode = view_mode_from_value(value), + _ => return, + } + self.seen.insert(id, self.storage.revision(id)); + } + + /// Rebuilds every typed copy whose store changed in the storage since it + /// was built. Returns the stores it rebuilt. + pub fn sync(&mut self) -> Vec { + let stale: Vec = TYPED_STORES + .into_iter() + .filter(|id| self.seen.get(id).copied() != Some(self.storage.revision(id))) + .collect(); + for id in &stale { + self.reload(id); + } + stale + } + + fn write(&mut self, id: StoreId, value: Value) { + self.storage.set(id, value); + self.seen.insert(id, self.storage.revision(id)); + } + + pub fn context(&self) -> &StoreContext { + &self.ctx + } + + /// Replaces the platform and theme lists. Like TS, nothing is + /// re-sanitized until the next write or reload. + pub fn set_context(&mut self, ctx: StoreContext) { + self.ctx = ctx; + } + + pub fn storage(&self) -> &ClientStorage { + &self.storage + } + + /// The raw storage, for the stores without a typed copy here (profile and + /// ban caches). Call [`ClientStores::sync`] after changing a typed store + /// through it. + pub fn storage_mut(&mut self) -> &mut ClientStorage { + &mut self.storage + } + + // -------------------------------------------------------- saves + + pub fn take_due_saves(&mut self, now: Instant) -> Vec { + self.storage.take_due_saves(now) + } + + pub fn take_all_saves(&mut self) -> Vec { + self.storage.take_all_saves() + } + + pub fn finish_save(&mut self, store_id: StoreId, result: &BackendResult>) { + self.storage.finish_save(store_id, result); + } + + pub fn next_save_due(&self) -> Option { + self.storage.next_save_due() + } + + // -------------------------------------------------------- snapshot and refresh + + pub fn apply_snapshot(&mut self, snapshot: ClientStorageSnapshot) -> Vec { + self.storage.apply_snapshot(snapshot); + self.sync() + } + + pub fn begin_refresh(&mut self, next: &crate::types::StorageManifest) -> Option { + self.storage.begin_refresh(next) + } + + /// Applies a refresh and rebuilds the typed stores it touched. Returns + /// every changed id, as `refreshClientStorageIfChanged`. + pub fn finish_refresh( + &mut self, + plan: RefreshPlan, + snapshot: ClientStorageSnapshot, + ) -> Vec { + let changed = self.storage.finish_refresh(plan, snapshot); + self.sync(); + changed + } + + /// `refreshClientStorageIfChanged`, blocking. + pub fn refresh_blocking(&mut self, backend: &dyn Backend) -> BackendResult> { + let changed = self.storage.refresh_blocking(backend); + self.sync(); + changed + } + + // -------------------------------------------------------- settings + + /// `peekSettings` / `getSettings`. + pub fn settings(&self) -> &AppSettings { + &self.settings + } + + /// `hasStoredSettings`: false only on a fresh install. + pub fn has_stored_settings(&self) -> bool { + self.storage.get(CLIENT_STORE_SETTINGS).is_some() + } + + /// `saveSettings`: sanitized, then written. (The TS boot-theme + /// `localStorage` hint has no counterpart here.) + pub fn save_settings(&mut self, next: &AppSettings) { + let sanitized = settings::sanitize_settings(&next.to_value(), &self.ctx); + let value = sanitized.to_value(); + self.settings = sanitized; + self.write(CLIENT_STORE_SETTINGS, value); + } + + /// `getCacheDuration`, in milliseconds. + pub fn cache_duration_ms(&self) -> u64 { + settings::cache_duration_ms(&self.settings) + } + + pub fn language(&self) -> Locale { + self.settings.language + } + + // -------------------------------------------------------- folders + + pub fn folders(&self) -> &FolderStore { + &self.folders + } + + fn write_folders(&mut self) { + self.folders.version = folders::CURRENT_VERSION; + let value = self.folders.to_value(); + self.write(CLIENT_STORE_FOLDERS, value); + } + + pub fn items_in_folder(&self, folder_id: Option<&str>, platform: &str) -> &[ItemRef] { + self.folders.items_in_folder(folder_id, platform) + } + + pub fn root_sections(&self, platform: &str) -> Vec { + self.folders.root_sections(platform) + } + + pub fn create_folder( + &mut self, + name: &str, + parent_id: Option<&str>, + platform: &str, + ) -> FolderInfo { + let folder = + self.folders + .create_folder(folders::generate_folder_id(), name, parent_id, platform); + self.write_folders(); + folder + } + + pub fn sync_accounts(&mut self, account_ids: &[String], platform: &str) { + if self.folders.sync_accounts(account_ids, platform) { + self.write_folders(); + } + } + + pub fn move_item( + &mut self, + item: &ItemRef, + from_folder_id: Option<&str>, + to_folder_id: Option<&str>, + platform: &str, + insert_index: Option, + ) { + if self + .folders + .move_item(item, from_folder_id, to_folder_id, platform, insert_index) + { + self.write_folders(); + } + } + + pub fn reorder_items(&mut self, folder_id: Option<&str>, platform: &str, items: Vec) { + self.folders.reorder_items(folder_id, platform, items); + self.write_folders(); + } + + pub fn delete_folder(&mut self, folder_id: &str) { + if self.folders.delete_folder(folder_id) { + self.write_folders(); + } + } + + pub fn rename_folder(&mut self, folder_id: &str, new_name: &str) { + if self.folders.rename_folder(folder_id, new_name) { + self.write_folders(); + } + } + + // -------------------------------------------------------- personas + + /// `getPersonas`. + pub fn personas(&self) -> &[Persona] { + &self.personas + } + + fn persist_personas(&mut self, list: Vec) { + let value = personas::personas_to_value(&list); + let sanitized = personas::sanitize_personas(&value, &self.ctx); + let value = personas::personas_to_value(&sanitized); + self.personas = sanitized; + self.write(CLIENT_STORE_PERSONAS, value); + } + + /// `createPersona`: returns the persona as given, with its new id. + pub fn create_persona(&mut self, input: PersonaInput) -> Persona { + let persona = Persona { + id: personas::generate_persona_id(), + name: input.name, + color: input.color, + image: input.image, + assignments: input.assignments, + }; + let mut list = self.personas.clone(); + list.push(persona.clone()); + self.persist_personas(list); + persona + } + + /// `updatePersona`. + pub fn update_persona(&mut self, id: &str, patch: &PersonaPatch) { + let list = self + .personas + .iter() + .cloned() + .map(|mut persona| { + if persona.id == id { + persona.apply(patch); + } + persona + }) + .collect(); + self.persist_personas(list); + } + + /// `deletePersona`. + pub fn delete_persona(&mut self, id: &str) { + let list = self + .personas + .iter() + .filter(|persona| persona.id != id) + .cloned() + .collect(); + self.persist_personas(list); + } + + // -------------------------------------------------------- string maps + + fn write_map(&mut self, which: StoreId) { + let value = match which { + CLIENT_STORE_ACCOUNT_CARD_NOTES => self.account_card_notes.to_value(), + CLIENT_STORE_ACCOUNT_CARD_COLORS => self.account_card_colors.to_value(), + _ => self.folder_card_colors.to_value(), + }; + self.write(which, value); + } + + pub fn account_card_note(&self, account_id: &str) -> &str { + self.account_card_notes.get(account_id) + } + + pub fn account_card_notes(&self) -> &StringMapStore { + &self.account_card_notes + } + + pub fn set_account_card_note(&mut self, account_id: &str, note: &str) { + self.account_card_notes.set(account_id, note); + self.write_map(CLIENT_STORE_ACCOUNT_CARD_NOTES); + } + + pub fn clear_account_card_note(&mut self, account_id: &str) { + self.account_card_notes.remove(account_id); + self.write_map(CLIENT_STORE_ACCOUNT_CARD_NOTES); + } + + pub fn account_card_color(&self, account_id: &str) -> &str { + self.account_card_colors.get(account_id) + } + + pub fn set_account_card_color(&mut self, account_id: &str, color: &str) { + self.account_card_colors.set(account_id, color); + self.write_map(CLIENT_STORE_ACCOUNT_CARD_COLORS); + } + + /// `setAccountCardColors`: one write for every id. + pub fn set_account_card_colors(&mut self, account_ids: &[String], color: &str) { + if self.account_card_colors.set_many(account_ids, color) { + self.write_map(CLIENT_STORE_ACCOUNT_CARD_COLORS); + } + } + + pub fn folder_card_color(&self, folder_id: &str) -> &str { + self.folder_card_colors.get(folder_id) + } + + pub fn set_folder_card_color(&mut self, folder_id: &str, color: &str) { + self.folder_card_colors.set(folder_id, color); + self.write_map(CLIENT_STORE_FOLDER_CARD_COLORS); + } + + // -------------------------------------------------------- default game + + /// `getDefaultGame`. + pub fn default_game(&self, steam_id: &str) -> Option<&DefaultGame> { + self.default_games.get(steam_id) + } + + fn write_default_games(&mut self) { + let value = serde_json::to_value(&self.default_games).unwrap_or(Value::Null); + self.write(CLIENT_STORE_ACCOUNT_DEFAULT_GAME, value); + } + + /// `setDefaultGame`: ignored unless the app id is numeric. + pub fn set_default_game(&mut self, steam_id: &str, game: &DefaultGame) { + let app_id = js::trim(&game.app_id); + if !js::is_ascii_digits(app_id) { + return; + } + self.default_games.insert( + steam_id.to_string(), + DefaultGame { + app_id: app_id.to_string(), + name: game.name.clone(), + }, + ); + self.write_default_games(); + } + + /// `clearDefaultGame`: no write when there is none. + pub fn clear_default_game(&mut self, steam_id: &str) { + if self.default_games.remove(steam_id).is_some() { + self.write_default_games(); + } + } + + // -------------------------------------------------------- view mode + + pub fn view_mode(&self) -> ViewMode { + self.view_mode + } + + pub fn set_view_mode(&mut self, mode: ViewMode) { + self.view_mode = mode; + self.write( + CLIENT_STORE_VIEW_MODE, + Value::String(mode.as_str().to_string()), + ); + } +} diff --git a/crates/accshift-app/src/streamer.rs b/crates/accshift-app/src/streamer.rs new file mode 100644 index 00000000..f5c92caa --- /dev/null +++ b/crates/accshift-app/src/streamer.rs @@ -0,0 +1,175 @@ +//! Streamer mode, ported from `src/lib/app/useStreamerMode.svelte.ts` and the +//! glue App.svelte keeps around it (lines 264-273, 719, 739). +//! +//! With `streamerMode: "auto"`, the app polls `detect_streaming_software` +//! and covers itself (the overlay is UI; what it hides is +//! [`crate::secure::ScreenCover`]) while streaming software runs. The +//! `setInterval` is a deadline checked each frame; the detection runs on +//! [`Jobs`]. + +use crate::error::BackendResult; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::settings::{AppSettings, StreamerMode}; +use crate::stores::ClientStores; +use std::time::{Duration, Instant}; + +/// `POLL_INTERVAL_MS` (line 15). +pub const POLL_INTERVAL: Duration = Duration::from_millis(4000); + +/// `HIDDEN_POLL_INTERVAL_MS` (line 19): a hidden window shows nothing a +/// stream could capture, so it checks far less often. +pub const HIDDEN_POLL_INTERVAL: Duration = Duration::from_millis(30_000); + +/// `createStreamerModeController`. +#[derive(Debug, Default)] +pub struct StreamerModeController { + /// `getSettings().streamerMode === "auto"`, read by [`Self::sync_settings`]. + enabled: bool, + streaming_detected: bool, + /// "Disable for now": hides the overlay until no streaming software runs. + dismissed_this_session: bool, + /// The `setInterval` is armed; its next tick. + next_tick: Option, + /// The detection in flight (`polling`). + polling: Option>>, + last_poll_at: Option, +} + +impl StreamerModeController { + pub fn new(settings: &AppSettings) -> Self { + let mut controller = Self::default(); + controller.sync_settings(settings); + controller + } + + /// Reads `streamerMode` from the shell's settings. Call when they change. + pub fn sync_settings(&mut self, settings: &AppSettings) { + self.enabled = settings.streamer_mode == StreamerMode::Auto; + } + + /// `enabled`. + pub fn enabled(&self) -> bool { + self.enabled + } + + pub fn streaming_detected(&self) -> bool { + self.streaming_detected + } + + /// `active` (line 36): the overlay is up. + pub fn active(&self) -> bool { + self.enabled && self.streaming_detected && !self.dismissed_this_session + } + + /// The interval is running. + pub fn is_running(&self) -> bool { + self.next_tick.is_some() + } + + /// A detection is in flight. + pub fn is_busy(&self) -> bool { + self.polling.is_some() + } + + /// When [`Self::tick`] next has work. + pub fn next_deadline(&self) -> Option { + self.next_tick + } + + /// `poll` (lines 38-58), up to the backend call. + fn poll(&mut self, jobs: &Jobs, now: Instant) { + if !self.enabled { + self.streaming_detected = false; + return; + } + if self.polling.is_some() { + return; + } + self.last_poll_at = Some(now); + self.polling = Some(jobs.run(|backend| backend.detect_streaming_software())); + } + + /// The end of `poll`: applies the answer. A failure keeps the old state, + /// as the TS `console.error` does. + fn apply(&mut self, jobs: &Jobs, result: BackendResult) { + let Ok(detected) = result else { + return; + }; + if detected && !self.streaming_detected { + drop(jobs.run(|backend| { + let _ = backend.telemetry_track_streamer_mode(); + })); + } + self.streaming_detected = detected; + if !detected { + self.dismissed_this_session = false; + } + } + + /// `start` (lines 60-67): polls now, then every [`POLL_INTERVAL`]. App.svelte + /// starts it once the first frame is out (`accshift:boot-ready`, line + /// 719), not during boot: the scan shares the backend with the snapshot. + pub fn start(&mut self, jobs: &Jobs, now: Instant) { + if self.next_tick.is_some() { + return; + } + self.poll(jobs, now); + self.next_tick = Some(now + POLL_INTERVAL); + } + + /// `stop` (lines 69-74). + pub fn stop(&mut self) { + self.next_tick = None; + } + + /// One frame: applies a finished detection, then runs the interval + /// callback (lines 63-66) when due. `hidden` is `isHidden()`: minimized + /// or page hidden (App.svelte line 266). True when `active` changed. + pub fn tick(&mut self, jobs: &Jobs, now: Instant, hidden: bool) -> bool { + let before = self.active(); + if let Some(pending) = self.polling.as_mut() { + match pending.poll() { + Poll::Waiting => {} + Poll::Ready(result) => { + self.polling = None; + self.apply(jobs, result); + } + Poll::Gone => self.polling = None, + } + } + if let Some(due) = self.next_tick { + if now >= due { + // One callback per frame, however many intervals a stalled + // frame skipped, as a throttled browser timer does. + let mut next = due + POLL_INTERVAL; + while next <= now { + next += POLL_INTERVAL; + } + self.next_tick = Some(next); + let recent = self + .last_poll_at + .is_some_and(|last| now.saturating_duration_since(last) < HIDDEN_POLL_INTERVAL); + if !(hidden && recent) { + self.poll(jobs, now); + } + } + } + before != self.active() + } + + /// `dismiss` (lines 76-78): "Disable for now". + pub fn dismiss(&mut self) { + self.dismissed_this_session = true; + } + + /// `disablePermanently` (lines 80-83) with App.svelte's `setStreamerMode` + /// (lines 267-272): saves `streamerMode: "off"` from the store's latest + /// settings. The shell refreshes its settings from the store after. + pub fn disable_permanently(&mut self, stores: &mut ClientStores) { + self.dismissed_this_session = true; + let mut latest = stores.settings().clone(); + latest.streamer_mode = StreamerMode::Off; + stores.save_settings(&latest); + self.sync_settings(stores.settings()); + } +} diff --git a/crates/accshift-app/src/text.rs b/crates/accshift-app/src/text.rs new file mode 100644 index 00000000..645fe150 --- /dev/null +++ b/crates/accshift-app/src/text.rs @@ -0,0 +1,117 @@ +//! A user-facing string as data: an i18n key plus its parameters, or text +//! that is shown as is (a folder name, a game name, the English ban tooltip). +//! +//! The platform model never resolves a key itself. It hands [`Text`] values to +//! the UI, which resolves them with the current locale when it draws, so a +//! language change needs no rebuild of the model. This is the TS +//! `callbacks.t(key, params)` call, moved from build time to draw time. + +use crate::i18n::{self, Locale, TranslationValue}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Text { + /// `t(key)` when `params` is `None`, `t(key, params)` otherwise. The + /// difference matters: `formatTemplate` leaves `{x}` placeholders alone + /// without parameters and blanks the unknown ones with them. + Key { + key: String, + params: Option>, + }, + /// Shown verbatim. + Raw(String), +} + +impl Text { + /// `t(key)`. + pub fn key(key: impl Into) -> Self { + Text::Key { + key: key.into(), + params: None, + } + } + + /// `t(key, params)`. + pub fn with, V: Into>( + key: impl Into, + params: impl IntoIterator, + ) -> Self { + Text::Key { + key: key.into(), + params: Some( + params + .into_iter() + .map(|(name, value)| (name.into(), value.into())) + .collect(), + ), + } + } + + /// `t(key, params)` with parameters already converted, `None` meaning + /// `undefined`. + pub fn with_optional( + key: impl Into, + params: Option>, + ) -> Self { + Text::Key { + key: key.into(), + params, + } + } + + /// A string shown as is. + pub fn raw(text: impl Into) -> Self { + Text::Raw(text.into()) + } + + /// The i18n key, `None` for raw text. + pub fn key_str(&self) -> Option<&str> { + match self { + Text::Key { key, .. } => Some(key), + Text::Raw(_) => None, + } + } + + /// The value of parameter `name`, if this is a key carrying it. + pub fn param(&self, name: &str) -> Option<&TranslationValue> { + match self { + Text::Key { + params: Some(params), + .. + } => params + .iter() + .find(|(param, _)| param == name) + .map(|(_, value)| value), + _ => None, + } + } + + /// The string to draw in `locale`. + pub fn resolve(&self, locale: Locale) -> String { + match self { + Text::Raw(text) => text.clone(), + Text::Key { key, params: None } => i18n::translate(locale, key), + Text::Key { + key, + params: Some(params), + } => { + let borrowed: Vec<(&str, TranslationValue)> = params + .iter() + .map(|(name, value)| (name.as_str(), value.clone())) + .collect(); + i18n::translate_with(locale, key, &borrowed) + } + } + } +} + +impl From<&str> for Text { + fn from(text: &str) -> Self { + Text::raw(text) + } +} + +impl From for Text { + fn from(text: String) -> Self { + Text::Raw(text) + } +} diff --git a/crates/accshift-app/src/time.rs b/crates/accshift-app/src/time.rs new file mode 100644 index 00000000..48494ce1 --- /dev/null +++ b/crates/accshift-app/src/time.rs @@ -0,0 +1,311 @@ +//! Timestamps the UI shows, ported from `src/lib/shared/time.ts`. +//! +//! Every account carries Unix SECONDS ([`unix_ms_to_seconds`] converts the +//! millisecond stamps most backends write). Formatting reads "now" from an +//! injected [`Clock`] instead of the system time, so the parity bench can pin +//! it on the mock's `NOW` like `Date.now` is pinned on the web side. +//! +//! The absolute formats reproduce what `Intl.DateTimeFormat` prints for the +//! seven shipped locales with `{ year: "numeric", month: "short", day: +//! "numeric" }` (plus `hour`/`minute: "2-digit"`), checked against Node's ICU +//! by `tests/platform_time.rs`. `Intl` renders in the machine's time zone with +//! its DST rules; so does this port, through a [`TimeZone`] the caller passes: +//! [`system_zone`] in the app (jiff maps the Windows zone id to IANA and reads +//! its embedded tzdb, no network and no installed database), a named or fixed +//! zone in tests. + +use crate::i18n::{self, Locale, DEFAULT_LOCALE}; +use jiff::tz::Offset; +use jiff::Timestamp; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +pub use jiff::tz::TimeZone; + +/// The machine's time zone, DST rules included, as WebView2 sees it. UTC when +/// the system zone cannot be read. jiff reads `TZ` first when it is set, +/// which WebView2 ignores. +pub fn system_zone() -> TimeZone { + TimeZone::try_system().unwrap_or(TimeZone::UTC) +} + +/// An IANA zone by name (`Europe/Paris`), from jiff's tzdb (embedded on +/// Windows, `/usr/share/zoneinfo` elsewhere). +pub fn named_zone(name: &str) -> Option { + TimeZone::get(name).ok() +} + +/// A zone that is always `utc_offset_minutes` ahead of UTC (UTC+2 is 120). +pub fn fixed_zone(utc_offset_minutes: i32) -> TimeZone { + Offset::from_seconds(utc_offset_minutes.saturating_mul(60)) + .map_or(TimeZone::UTC, TimeZone::fixed) +} + +/// Ceiling for a plausible Unix *seconds* timestamp: 1e11 seconds lands +/// thousands of years from now, while a millisecond timestamp of today is +/// around 1.7e12. A larger value is milliseconds handed to a seconds API and +/// renders as "unknown", so a unit regression stays visible. +pub const MAX_UNIX_SECONDS: f64 = 1e11; + +/// Past this age the relative wording stops carrying information, so the +/// absolute date is shown instead. +pub const ABSOLUTE_AFTER_DAYS: f64 = 30.0; + +const MINUTE_MS: f64 = 60.0 * 1000.0; +const HOUR_MS: f64 = 60.0 * MINUTE_MS; +const DAY_MS: f64 = 24.0 * HOUR_MS; + +/// Where "now" comes from. +#[derive(Debug, Clone)] +pub enum Clock { + /// The system clock (`Date.now()`). + System, + /// The mock clock: `start_ms` plus the time elapsed since `started`. The + /// parity bench pins `Date.now` on `NOW * 1000` and lets it run. + Running { start_ms: f64, started: Instant }, + /// A clock that never moves, for tests. + Fixed(f64), +} + +impl Clock { + pub fn system() -> Self { + Clock::System + } + + /// `NOW * 1000 + elapsed`, `now_s` being the mock's `NOW` in seconds. + pub fn mock(now_s: u64) -> Self { + Clock::Running { + start_ms: now_s as f64 * 1000.0, + started: Instant::now(), + } + } + + pub fn fixed(now_ms: f64) -> Self { + Clock::Fixed(now_ms) + } + + /// `Date.now()`: Unix milliseconds. + pub fn now_ms(&self) -> f64 { + match self { + Clock::System => SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0.0, |elapsed| elapsed.as_millis() as f64), + Clock::Running { start_ms, started } => start_ms + started.elapsed().as_millis() as f64, + Clock::Fixed(now_ms) => *now_ms, + } + } +} + +/// What a relative time needs besides the timestamp. +#[derive(Debug, Clone, PartialEq)] +pub struct TimeContext { + /// `Date.now()`. + pub now_ms: f64, + pub locale: Locale, + /// The zone absolute dates render in. + pub zone: TimeZone, +} + +impl TimeContext { + pub fn new(clock: &Clock, locale: Locale, zone: TimeZone) -> Self { + Self { + now_ms: clock.now_ms(), + locale, + zone, + } + } +} + +/// `isUnixSeconds`: a finite, positive number small enough to be seconds. +pub fn is_unix_seconds(timestamp: Option) -> bool { + timestamp.is_some_and(|value| value.is_finite() && value > 0.0 && value <= MAX_UNIX_SECONDS) +} + +/// `unixMsToSeconds`: Unix milliseconds to seconds, `None` for anything that +/// is not a positive finite number. +pub fn unix_ms_to_seconds(timestamp_ms: Option) -> Option { + let value = timestamp_ms?; + if !value.is_finite() || value <= 0.0 { + return None; + } + Some((value / 1000.0).floor()) +} + +/// A calendar moment in local time. +struct Civil { + year: i64, + month: u32, + day: u32, + hour: u32, + minute: u32, +} + +/// The local calendar moment of a Unix seconds stamp in `zone`, `None` +/// outside jiff's range (years -9999 to 9999). +fn civil(timestamp_s: f64, zone: &TimeZone) -> Option { + let at = zone.to_datetime(Timestamp::from_second(timestamp_s.floor() as i64).ok()?); + Some(Civil { + year: i64::from(at.year()), + month: at.month() as u32, + day: at.day() as u32, + hour: at.hour() as u32, + minute: at.minute() as u32, + }) +} + +const MONTHS_EN: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; +const MONTHS_ES: [&str; 12] = [ + "ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sept", "oct", "nov", "dic", +]; +const MONTHS_FR: [&str; 12] = [ + "janv.", + "f\u{e9}vr.", + "mars", + "avr.", + "mai", + "juin", + "juil.", + "ao\u{fb}t", + "sept.", + "oct.", + "nov.", + "d\u{e9}c.", +]; +const MONTHS_PT: [&str; 12] = [ + "jan.", "fev.", "mar.", "abr.", "mai.", "jun.", "jul.", "ago.", "set.", "out.", "nov.", "dez.", +]; +const MONTHS_RU: [&str; 12] = [ + "\u{44f}\u{43d}\u{432}.", + "\u{444}\u{435}\u{432}\u{440}.", + "\u{43c}\u{430}\u{440}.", + "\u{430}\u{43f}\u{440}.", + "\u{43c}\u{430}\u{44f}", + "\u{438}\u{44e}\u{43d}.", + "\u{438}\u{44e}\u{43b}.", + "\u{430}\u{432}\u{433}.", + "\u{441}\u{435}\u{43d}\u{442}.", + "\u{43e}\u{43a}\u{442}.", + "\u{43d}\u{43e}\u{44f}\u{431}.", + "\u{434}\u{435}\u{43a}.", +]; + +fn date_part(locale: Locale, at: &Civil) -> String { + let index = (at.month - 1) as usize; + let (day, year) = (at.day, at.year); + match locale { + Locale::En => format!("{} {day}, {year}", MONTHS_EN[index]), + Locale::Es => format!("{day} {} {year}", MONTHS_ES[index]), + Locale::Fr => format!("{day} {} {year}", MONTHS_FR[index]), + Locale::Pt | Locale::PtBr => format!("{day} de {} de {year}", MONTHS_PT[index]), + Locale::Ru => format!("{day} {} {year} \u{433}.", MONTHS_RU[index]), + Locale::Zh => format!("{year}\u{5e74}{}\u{6708}{day}\u{65e5}", at.month), + } +} + +fn time_part(locale: Locale, at: &Civil) -> String { + match locale { + Locale::En => { + let hour12 = match at.hour % 12 { + 0 => 12, + hour => hour, + }; + let period = if at.hour < 12 { "AM" } else { "PM" }; + format!("{hour12:02}:{:02} {period}", at.minute) + } + _ => format!("{:02}:{:02}", at.hour, at.minute), + } +} + +/// `formatAbsoluteDateFromUnixSeconds`: the localized calendar day, empty when +/// the value is not usable Unix seconds. +pub fn format_absolute_date(timestamp: Option, locale: Locale, zone: &TimeZone) -> String { + timestamp + .filter(|_| is_unix_seconds(timestamp)) + .and_then(|value| civil(value, zone)) + .map(|at| date_part(locale, &at)) + .unwrap_or_default() +} + +/// `formatAbsoluteDateTimeFromUnixSeconds`: day and time for the tooltip +/// behind a relative label, empty when the value is unusable. +pub fn format_absolute_date_time( + timestamp: Option, + locale: Locale, + zone: &TimeZone, +) -> String { + let Some(at) = timestamp + .filter(|_| is_unix_seconds(timestamp)) + .and_then(|value| civil(value, zone)) + else { + return String::new(); + }; + let separator = if locale == Locale::Zh { " " } else { ", " }; + format!( + "{}{separator}{}", + date_part(locale, &at), + time_part(locale, &at) + ) +} + +/// `formatRelativeTimeFromUnixSeconds`. `unknown_key` is what a missing or +/// unusable stamp reads as (`time.unknown` by default, Riot passes +/// `time.neverConnected`). +pub fn format_relative_time( + timestamp: Option, + ctx: &TimeContext, + unknown_key: &str, +) -> String { + let locale = ctx.locale; + let Some(value) = timestamp.filter(|_| is_unix_seconds(timestamp)) else { + return i18n::translate(locale, unknown_key); + }; + let delta_ms = (ctx.now_ms - value * 1000.0).max(0.0); + if delta_ms < MINUTE_MS { + return i18n::translate(locale, "time.justNow"); + } + if delta_ms < HOUR_MS { + let minutes = (delta_ms / MINUTE_MS).floor(); + let key = if minutes > 1.0 { + "time.minutesAgo" + } else { + "time.minuteAgo" + }; + return i18n::translate_with(locale, key, &[("count", minutes.into())]); + } + if delta_ms < DAY_MS { + let hours = (delta_ms / HOUR_MS).floor(); + let key = if hours > 1.0 { + "time.hoursAgo" + } else { + "time.hourAgo" + }; + return i18n::translate_with(locale, key, &[("count", hours.into())]); + } + let days = (delta_ms / DAY_MS).floor(); + if days > ABSOLUTE_AFTER_DAYS { + return format_absolute_date(timestamp, locale, &ctx.zone); + } + let key = if days > 1.0 { + "time.daysAgo" + } else { + "time.dayAgo" + }; + i18n::translate_with(locale, key, &[("count", days.into())]) +} + +/// `formatRelativeTimeCompact`: the relative label inside `time.compactWrapper`. +pub fn format_relative_time_compact( + timestamp: Option, + ctx: &TimeContext, + unknown_key: &str, +) -> String { + let base = format_relative_time(timestamp, ctx, unknown_key); + i18n::translate_with(ctx.locale, "time.compactWrapper", &[("value", base.into())]) +} + +/// The default `unknownKey` of the TS formatters. +pub const UNKNOWN_KEY: &str = "time.unknown"; + +/// The default locale of the TS formatters, for callers that have none. +pub const FALLBACK_LOCALE: Locale = DEFAULT_LOCALE; diff --git a/crates/accshift-app/src/types.rs b/crates/accshift-app/src/types.rs new file mode 100644 index 00000000..71a0e4f1 --- /dev/null +++ b/crates/accshift-app/src/types.rs @@ -0,0 +1,109 @@ +//! Payloads the backend answers with that `accshift-core` does not define, +//! because the Tauri app built them itself. Each one serializes to the JSON +//! its webview received from the matching command. +//! +//! Payloads `accshift-core` already defines are re-exported here so callers +//! have one place to import from. + +use serde::{Deserialize, Serialize}; + +pub use accshift_core::platforms::descriptor::library::DescriptorPreview; +pub use accshift_core::platforms::descriptor::plan::DryRunPlan; +pub use accshift_core::platforms::steam::accounts::{CopyableGame, SteamAccount}; +pub use accshift_core::platforms::steam::bans::BanInfo; +pub use accshift_core::platforms::steam::bulk_edit::{ + BulkEditFailure, BulkEditRequest, BulkEditResult, LaunchOptionEdit, +}; +pub use accshift_core::platforms::steam::cs2_bridge::{ + Cs2BridgeAccount, Cs2BridgeSettings, Cs2BridgeTestResult, +}; +pub use accshift_core::platforms::steam::profile::ProfileInfo; +pub use accshift_core::platforms::{ + RejectedDescriptor, SetupStatus, SkippedPlatform, UserPlatformReport, +}; +pub use accshift_core::storage::{ClientStorageSnapshot, StorageManifest}; +pub use accshift_core::themes::CustomTheme; + +/// `get_boot_payload`: everything the UI needs before the window shows. +/// Mirrors `BootPayload` in `src-tauri/src/commands/app.rs`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootPayload { + /// `migrated`, `none`, `error:` (and `skipped` from the mock). + pub migration: String, + pub runtime_os: String, + pub storage_snapshot: ClientStorageSnapshot, + pub custom_themes: Vec, + pub user_platforms: UserPlatformReport, +} + +/// `get_desktop_wallpaper`: the desktop picture behind the window, for the +/// glass themes. Mirrors `WallpaperSnapshot` in +/// `src-tauri/src/commands/backdrop.rs`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WallpaperSnapshot { + pub data_url: String, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +/// `pin_unlock`. Mirrors `PinUnlockResult` in `src-tauri/src/commands/pin.rs`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PinUnlockResult { + /// `unlocked`, `invalid`, `retry_later` or `not_configured`. + pub status: String, + /// The stored hash is the legacy unsalted form: the caller rewrites it. + pub legacy: bool, + /// For `invalid` and `retry_later`: how long the next attempt is refused. + pub retry_after_ms: u64, +} + +/// `telemetry_get_state`. Mirrors `TelemetryUiState` in +/// `src-tauri/src/commands/telemetry.rs`, snake_case on the wire. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TelemetryUiState { + pub mode_a_enabled: bool, + pub mode_b_enabled: bool, + pub install_id_set: bool, + pub forget_pending: bool, + pub onboarding_completed: bool, +} + +/// Arguments of `telemetry_track_settings_snapshot`, the `SettingsSnapshot` +/// of `src/lib/app/telemetryClient.ts`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingsSnapshot { + pub ui_language: String, + pub enabled_platforms: Vec, + pub personas_enabled: bool, + pub pin_enabled: bool, + pub cli_enabled: bool, + pub deep_links_enabled: bool, + pub streamer_mode: String, + pub animations: String, +} + +/// A Roblox account as `platform_get_accounts` and +/// `roblox_add_account_by_cookie` send it. Mirrors `RobloxAccount` in +/// `accshift_core::platforms::roblox`, which only exists on Windows. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RobloxAccount { + pub user_id: String, + pub username: String, + pub display_name: String, + pub last_login_at: Option, +} + +/// `roblox_get_profile_info`. Mirrors `RobloxProfileInfo` in +/// `accshift_core::platforms::roblox` (Windows only there). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RobloxProfileInfo { + pub avatar_url: Option, +} diff --git a/crates/accshift-app/tests/account_loader.rs b/crates/accshift-app/tests/account_loader.rs new file mode 100644 index 00000000..878abcba --- /dev/null +++ b/crates/accshift-app/tests/account_loader.rs @@ -0,0 +1,264 @@ +//! `src/lib/shared/useAccountLoader.test.ts`, ported. The TS deferred +//! promises become the begin and finish halves of each call, finished in the +//! order the TS test resolves them. + +use accshift_app::accounts::avatars::ProfileSource; +use accshift_app::accounts::loader::{AccountLoader, AddOutcome, LoadOptions, LoaderHooks}; +use accshift_app::error::BackendError; +use accshift_app::platforms::warnings::WarningPresentation; +use accshift_app::platforms::{ + AddAccountResult, AddFlowStatus, CachedPlatformProfile, PlatformAccount, StartupSnapshot, +}; +use accshift_app::text::Text; +use std::collections::BTreeMap; + +struct Hooks(&'static str); + +impl ProfileSource for Hooks { + fn cached_profile(&self, _account_id: &str) -> Option { + None + } + fn has_profile_info(&self) -> bool { + false + } +} + +impl LoaderHooks for Hooks { + fn platform_id(&self) -> &str { + self.0 + } + fn no_accounts_toast(&self) -> Option { + None + } + fn load_error_toast(&self, _message: &str) -> Option { + None + } + fn switch_error_toast(&self, _message: &str) -> Option { + None + } + fn cached_warning_states(&self) -> Option> { + None + } + fn has_warnings(&self) -> bool { + false + } +} + +fn account(id: &str, name: &str) -> PlatformAccount { + PlatformAccount { + id: id.into(), + display_name: name.into(), + username: id.into(), + last_login_at_sec: None, + } +} + +fn alice() -> PlatformAccount { + account("alice", "Alice") +} + +fn bob() -> PlatformAccount { + account("bob", "Bob") +} + +fn snapshot( + accounts: Vec, + current: &str, +) -> Result { + Ok(StartupSnapshot { + accounts, + current_account: current.into(), + }) +} + +// ------------------------------------------------------------ add flow + +#[test] +fn coalesces_duplicate_add_requests_while_setup_is_starting() { + let hooks = Hooks("steam"); + let mut loader = AccountLoader::new(Some("steam".into())); + + let first = loader.begin_add().expect("first add starts"); + assert!(loader.begin_add().is_none(), "the duplicate is dropped"); + assert!(loader.adding()); + + let outcome = loader.finish_add( + &first, + Ok(AddAccountResult { + setup_status: Some(AddFlowStatus { + setup_id: "setup-1".into(), + state: "waiting_for_login".into(), + account_id: None, + account_display_name: None, + error_message: None, + }), + }), + "Steam", + false, + &hooks, + ); + + assert!(matches!(outcome, AddOutcome::SetupStarted(status) if status.setup_id == "setup-1")); + assert!(!loader.adding()); +} + +// ------------------------------------------------------------ load and switch + +fn riot_loader() -> AccountLoader { + AccountLoader::new(Some("riot".into())) +} + +#[test] +fn clears_loading_when_a_switch_lands_while_a_load_is_in_flight() { + let hooks = Hooks("riot"); + let mut loader = riot_loader(); + + let load = loader.begin_load(&hooks, LoadOptions::default()); + assert!(loader.loading()); + let switch = loader.begin_switch(&bob()).expect("switch starts"); + loader.finish_switch(&switch, Ok(()), &hooks); + loader.finish_load( + &load, + snapshot(vec![alice(), bob()], "alice"), + &hooks, + None, + 0.0, + ); + + assert!(!loader.loading()); + assert_eq!(loader.accounts(), [alice(), bob()]); +} + +#[test] +fn keeps_the_switched_account_over_a_snapshot_read_before_the_switch() { + let hooks = Hooks("riot"); + let mut loader = riot_loader(); + + let load = loader.begin_load(&hooks, LoadOptions::default()); + let switch = loader.begin_switch(&bob()).unwrap(); + loader.finish_switch(&switch, Ok(()), &hooks); + loader.finish_load( + &load, + snapshot(vec![alice(), bob()], "alice"), + &hooks, + None, + 0.0, + ); + + assert_eq!(loader.current_account(), "bob"); +} + +#[test] +fn keeps_the_account_a_load_read_when_the_switch_is_refused() { + let hooks = Hooks("riot"); + let mut loader = riot_loader(); + + let load = loader.begin_load(&hooks, LoadOptions::default()); + let switch = loader.begin_switch(&bob()).unwrap(); + let outcome = loader.finish_switch( + &switch, + Err(BackendError::new("pin_locked: locked")), + &hooks, + ); + loader.finish_load( + &load, + snapshot(vec![alice(), bob()], "alice"), + &hooks, + None, + 0.0, + ); + + assert!(!outcome.succeeded); + assert!(outcome.toasts.is_empty(), "a PIN refusal shows no toast"); + assert_eq!(loader.current_account(), "alice"); +} + +#[test] +fn still_lets_a_newer_load_supersede_an_older_one() { + let hooks = Hooks("riot"); + let mut loader = riot_loader(); + + let older = loader.begin_load(&hooks, LoadOptions::default()); + let newer = loader.begin_load(&hooks, LoadOptions::default()); + loader.finish_load(&newer, snapshot(vec![bob()], "bob"), &hooks, None, 0.0); + let stale = loader.finish_load(&older, snapshot(vec![alice()], "alice"), &hooks, None, 0.0); + + assert!(!stale.applied); + assert_eq!(loader.accounts(), [bob()]); + assert_eq!(loader.current_account(), "bob"); + assert!(!loader.loading()); +} + +// ------------------------------------------------------------ platform change + +#[test] +fn names_the_platform_the_accounts_came_from_not_the_one_active_at_the_end() { + // The tab moved to another platform while Steam's load ran: the folder + // sync after the load must target Steam. + let steam = Hooks("steam"); + let riot = Hooks("riot"); + let mut loader = AccountLoader::new(Some("steam".into())); + + let load = loader.begin_load(&steam, LoadOptions::default()); + loader.set_adapter(Some("riot".into())); + let outcome = loader.finish_load(&load, snapshot(vec![alice()], "alice"), &riot, None, 0.0); + + assert_eq!(outcome.after_load.as_deref(), Some("steam")); +} + +// ------------------------------------------------------------ beyond the TS tests + +#[test] +fn a_failed_load_clears_the_list_and_keeps_the_error() { + let hooks = Hooks("riot"); + let mut loader = riot_loader(); + let first = loader.begin_load(&hooks, LoadOptions::default()); + loader.finish_load(&first, snapshot(vec![alice()], "alice"), &hooks, None, 0.0); + + let second = loader.begin_load(&hooks, LoadOptions::default()); + let outcome = loader.finish_load(&second, Err(BackendError::new("boom")), &hooks, None, 0.0); + + assert!(outcome.after_load.is_none()); + assert!(loader.accounts().is_empty()); + assert_eq!(loader.current_account(), ""); + assert_eq!(loader.error(), Some("boom")); +} + +#[test] +fn an_add_without_setup_reloads_silently_and_holds_adding_until_it_lands() { + let hooks = Hooks("riot"); + let mut loader = riot_loader(); + let add = loader.begin_add().unwrap(); + + let AddOutcome::Reload(load) = + loader.finish_add(&add, Ok(AddAccountResult::default()), "Riot", true, &hooks) + else { + panic!("expected a reload"); + }; + assert!(load.options.silent); + assert!(loader.adding()); + + loader.finish_load(&load, snapshot(vec![alice()], "alice"), &hooks, None, 0.0); + assert!(!loader.adding()); +} + +#[test] +fn a_missing_client_gets_its_own_add_toast() { + let hooks = Hooks("riot"); + let mut loader = riot_loader(); + let add = loader.begin_add().unwrap(); + let outcome = loader.finish_add( + &add, + Err(BackendError::new("Could not locate Riot Client executable")), + "Riot", + true, + &hooks, + ); + let AddOutcome::Failed(toast) = outcome else { + panic!("expected a failure toast"); + }; + assert_eq!( + toast.text, + Text::with("toast.addAccountClientMissing", [("platform", "Riot")]) + ); +} diff --git a/crates/accshift-app/tests/account_profiles.rs b/crates/accshift-app/tests/account_profiles.rs new file mode 100644 index 00000000..0ea61a91 --- /dev/null +++ b/crates/accshift-app/tests/account_profiles.rs @@ -0,0 +1,358 @@ +//! The profile caches (`profileCache.ts`: retries, negative entries, fetches +//! in flight, expiry) and the avatar image handoff. No TS test covers these +//! files; the cases follow the TS behaviour. + +use accshift_app::accounts::avatars::{ + avatar_source, fetch_avatar_image, fetch_avatar_image_cached, AvatarDiskCache, AvatarFetch, + AvatarHttp, AvatarImage, AvatarImages, AvatarSource, +}; +use accshift_app::accounts::profile_cache::{ + CacheConfig, CacheExpiry, ProfileCache, UrlPolicy, ROBLOX_PROFILE_CACHE, +}; +use accshift_app::error::BackendResult; +use accshift_app::storage::ClientStorage; +use accshift_app::types::RobloxProfileInfo; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier}; +use std::time::Duration; + +const URL: &str = "https://cdn.example/a.png"; + +fn config(attempts: u32, negative: bool) -> CacheConfig { + CacheConfig { + attempts, + retry_delay: Duration::ZERO, + cache_negative_results: negative, + ..ROBLOX_PROFILE_CACHE + } +} + +fn profile(url: Option<&str>) -> RobloxProfileInfo { + RobloxProfileInfo { + avatar_url: url.map(str::to_string), + } +} + +fn now() -> f64 { + 1_000.0 +} + +fn fresh() -> CacheExpiry { + CacheExpiry { + duration_ms: 60_000, + session_start_ms: 0.0, + now_ms: 1_000.0, + } +} + +#[test] +fn retries_until_a_usable_avatar_comes_back() { + let cache = ProfileCache::new(config(4, true), UrlPolicy::HttpOnly); + let calls = AtomicUsize::new(0); + let fetched = cache.fetch_profile( + "a", + &|_| { + let call = calls.fetch_add(1, Ordering::SeqCst); + Ok(Some(profile((call == 2).then_some(URL)))) + }, + &now, + ); + assert_eq!(calls.load(Ordering::SeqCst), 3); + assert_eq!(fetched.value, Some(profile(Some(URL)))); + assert_eq!(fetched.writes.len(), 1); + + let mut storage = ClientStorage::new(); + cache.apply_writes(&mut storage, fetched.writes); + let cached = cache.cached_profile(&storage, "a", &fresh()).unwrap(); + assert_eq!(cached.url.as_deref(), Some(URL)); + assert!(!cached.expired); +} + +#[test] +fn stores_a_negative_entry_once_every_attempt_lacks_an_avatar() { + let cache = ProfileCache::new(config(2, true), UrlPolicy::HttpOnly); + let fetched = cache.fetch_profile("a", &|_| Ok(Some(profile(None))), &now); + assert_eq!(fetched.writes.len(), 1); + assert_eq!(fetched.writes[0].entry.url, None); + + let mut storage = ClientStorage::new(); + cache.apply_writes(&mut storage, fetched.writes); + assert_eq!( + cache.cached_profile(&storage, "a", &fresh()).unwrap().url, + None + ); + + let without = ProfileCache::new(config(2, false), UrlPolicy::HttpOnly); + assert!(without + .fetch_profile("a", &|_| Ok(Some(profile(None))), &now) + .writes + .is_empty()); +} + +#[test] +fn shares_one_fetch_between_threads_asking_for_the_same_account() { + let cache = Arc::new(ProfileCache::new(config(1, false), UrlPolicy::HttpOnly)); + let calls = Arc::new(AtomicUsize::new(0)); + let gate = Arc::new(Barrier::new(2)); + let first = { + let (cache, calls, gate) = (cache.clone(), calls.clone(), gate.clone()); + std::thread::spawn(move || { + cache.fetch_profile( + "a", + &|_| { + calls.fetch_add(1, Ordering::SeqCst); + gate.wait(); + // Leave the second caller time to find this fetch. + std::thread::sleep(Duration::from_millis(50)); + Ok(Some(profile(Some(URL)))) + }, + &now, + ) + }) + }; + gate.wait(); + let second = cache.fetch_profile( + "a", + &|_| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(None) + }, + &now, + ); + let first = first.join().unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(first.value, Some(profile(Some(URL)))); + assert_eq!(second.value, Some(profile(Some(URL)))); + assert!(second.writes.is_empty(), "the waiter writes nothing"); + assert!(cache.in_flight().is_empty()); +} + +#[test] +fn batch_fetches_retry_only_the_ids_still_missing() { + let cache = ProfileCache::new(config(3, true), UrlPolicy::HttpOnly); + let asked = std::sync::Mutex::new(Vec::new()); + let batch = |ids: &[String]| -> BackendResult> { + asked.lock().unwrap().push(ids.to_vec()); + Ok(ids + .iter() + .map(|id| (id.clone(), profile((id == "a").then_some(URL)))) + .collect()) + }; + let ids = vec!["a".to_string(), "b".to_string(), "a".to_string()]; + let fetched = cache.fetch_profiles(&ids, Some(&batch), &|_| Ok(None), &now); + assert_eq!( + *asked.lock().unwrap(), + [ + vec!["a".to_string(), "b".to_string()], + vec!["b".to_string()], + vec!["b".to_string()] + ] + ); + assert_eq!(fetched.value["a"], Some(profile(Some(URL)))); + assert_eq!(fetched.value["b"], Some(profile(None))); + // One positive entry for a, one negative for b. + assert_eq!(fetched.writes.len(), 2); +} + +#[test] +fn entries_expire_by_duration_or_by_session_when_the_duration_is_zero() { + let cache = ProfileCache::::new(config(1, false), UrlPolicy::HttpOnly); + let mut storage = ClientStorage::new(); + cache.set_cached_profile(&mut storage, "a", URL, None, 1_000.0); + + let by_duration = |now_ms| CacheExpiry { + duration_ms: 500, + session_start_ms: 0.0, + now_ms, + }; + assert!( + !cache + .cached_profile(&storage, "a", &by_duration(1_500.0)) + .unwrap() + .expired + ); + assert!( + cache + .cached_profile(&storage, "a", &by_duration(1_501.0)) + .unwrap() + .expired + ); + + let by_session = |session_start_ms| CacheExpiry { + duration_ms: 0, + session_start_ms, + now_ms: 99_999.0, + }; + assert!( + !cache + .cached_profile(&storage, "a", &by_session(1_000.0)) + .unwrap() + .expired + ); + assert!( + cache + .cached_profile(&storage, "a", &by_session(1_001.0)) + .unwrap() + .expired + ); +} + +#[test] +fn the_url_policy_keeps_files_to_the_mock() { + let file = "crates/accshift-egui/parity/avatars/1.svg"; + let strict = ProfileCache::::new(config(1, false), UrlPolicy::HttpOnly); + let mut storage = ClientStorage::new(); + strict.set_cached_profile(&mut storage, "a", file, None, 0.0); + assert!(strict.cached_profile(&storage, "a", &fresh()).is_none()); + + let mock = ProfileCache::::new(config(1, false), UrlPolicy::HttpOrFile); + mock.set_cached_profile(&mut storage, "a", file, None, 0.0); + assert_eq!( + mock.cached_profile(&storage, "a", &fresh()) + .unwrap() + .url + .as_deref(), + Some(file) + ); +} + +// ------------------------------------------------------------ images + +struct FakeHttp(Result, String>); + +impl AvatarHttp for FakeHttp { + fn get(&self, _url: &str) -> Result, String> { + self.0.clone() + } +} + +#[test] +fn avatar_sources_follow_the_policy() { + assert_eq!( + avatar_source(URL, UrlPolicy::HttpOnly), + AvatarSource::Http(URL.into()) + ); + assert_eq!( + avatar_source("javascript:alert(1)", UrlPolicy::HttpOrFile), + AvatarSource::Unsupported + ); + assert_eq!( + avatar_source( + "crates/accshift-egui/parity/avatars/2.svg", + UrlPolicy::HttpOnly + ), + AvatarSource::Unsupported + ); + assert_eq!( + avatar_source("file:///C:/avatars/2.svg", UrlPolicy::HttpOrFile), + AvatarSource::File(PathBuf::from("/C:/avatars/2.svg")) + ); + // A persona cover: the bytes of a base64 data URL, under either policy. + assert_eq!( + avatar_source("data:image/webp;base64,UklGRg==", UrlPolicy::HttpOnly), + AvatarSource::Data(b"RIFF".to_vec()) + ); + assert_eq!( + avatar_source("data:image/svg+xml,", UrlPolicy::HttpOrFile), + AvatarSource::Unsupported + ); + assert_eq!( + avatar_source("data:text/html;base64,PGI+", UrlPolicy::HttpOrFile), + AvatarSource::Unsupported + ); +} + +#[test] +fn images_hand_over_files_at_once_and_download_urls_once() { + let mut images = AvatarImages::new(UrlPolicy::HttpOrFile); + let file = "crates/accshift-egui/parity/avatars/3.svg"; + assert_eq!( + images.request(file), + AvatarFetch::Ready(AvatarImage::File { + key: file.into(), + path: PathBuf::from(file) + }) + ); + + assert_eq!(images.request(URL), AvatarFetch::Start(URL.into())); + assert_eq!(images.request(URL), AvatarFetch::Waiting); + let image = fetch_avatar_image(URL, &FakeHttp(Ok(vec![1, 2, 3]))).unwrap(); + assert_eq!(image.key(), URL); + images.finish(URL, Ok(image.clone())); + assert_eq!(images.request(URL), AvatarFetch::Ready(image)); + + let broken = "https://cdn.example/broken.png"; + assert_eq!(images.request(broken), AvatarFetch::Start(broken.into())); + images.finish( + broken, + fetch_avatar_image(broken, &FakeHttp(Ok(Vec::new()))), + ); + assert_eq!(images.request(broken), AvatarFetch::Failed); + images.forget(broken); + assert_eq!(images.request(broken), AvatarFetch::Start(broken.into())); +} + +const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3]; + +#[test] +fn the_disk_cache_serves_what_it_stored_for_that_url_only() { + let dir = tempfile::tempdir().unwrap(); + let cache = AvatarDiskCache::new(dir.path().join("avatars")); + let steam = "https://avatars.steamstatic.com/0123abcd_full.jpg"; + let other = "https://cdn.example/a.jpg"; + assert_eq!(cache.read(steam), None); + cache.write(steam, JPEG).unwrap(); + cache.write(other, JPEG).unwrap(); + assert_eq!(cache.read(steam).as_deref(), Some(JPEG)); + // A host that may reuse its URLs serves while fresh. + assert_eq!(cache.read(other).as_deref(), Some(JPEG)); + assert_eq!( + cache.read("https://avatars.steamstatic.com/ffff_full.jpg"), + None + ); + // An HTML error page with a 200 is never stored. + assert!(cache.write(steam, b"").is_err()); + assert_eq!(cache.read(steam).as_deref(), Some(JPEG)); +} + +#[test] +fn the_disk_cache_drops_its_oldest_files_past_its_budget() { + let dir = tempfile::tempdir().unwrap(); + let cache = AvatarDiskCache::new(dir.path().to_path_buf()).with_max_files(2); + for i in 0..3 { + cache + .write( + &format!("https://avatars.steamstatic.com/{i}_full.jpg"), + JPEG, + ) + .unwrap(); + // Distinct modification times on coarse file systems. + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2); + assert_eq!( + cache.read("https://avatars.steamstatic.com/0_full.jpg"), + None + ); + assert!(cache + .read("https://avatars.steamstatic.com/2_full.jpg") + .is_some()); +} + +#[test] +fn a_cached_fetch_downloads_once_and_reads_the_disk_after() { + let dir = tempfile::tempdir().unwrap(); + let cache = AvatarDiskCache::new(dir.path().to_path_buf()); + let calls = AtomicUsize::new(0); + let http = || { + calls.fetch_add(1, Ordering::SeqCst); + Ok(Arc::new(FakeHttp(Ok(JPEG.to_vec())))) + }; + let first = fetch_avatar_image_cached(URL, http, Some(&cache)).unwrap(); + let http = || -> Result, String> { panic!("served from disk") }; + let second = fetch_avatar_image_cached(URL, http, Some(&cache)).unwrap(); + assert_eq!(first, second); + assert_eq!(calls.load(Ordering::SeqCst), 1); +} diff --git a/crates/accshift-app/tests/app_actions_dialogs.rs b/crates/accshift-app/tests/app_actions_dialogs.rs new file mode 100644 index 00000000..5ee98102 --- /dev/null +++ b/crates/accshift-app/tests/app_actions_dialogs.rs @@ -0,0 +1,631 @@ +//! The app dialogs against the mock backend's demo scenario: every input +//! and confirm dialog kind is opened, edited, validated, confirmed and +//! cancelled, plus `app/useAppDialogs.test.ts` (confirm replacement) and the +//! context menus. + +mod support; + +use accshift_app::accounts::ToastKind; +use accshift_app::actions::dialogs::{ + bulk_edit_result_toast, finish_rename_account, folder_path_label, rename_account_blocking, + AppDialogs, AppMenuCommand, AppMenuEntry, ConfirmConfig, ConfirmPurpose, ContextMenuTarget, + DialogContext, InputPurpose, MenuInput, +}; +use accshift_app::actions::{load_options, AppEffect}; +use accshift_app::folders::{FolderInfo, ItemRef}; +use accshift_app::menu::{MenuCommand, PlatformTask}; +use accshift_app::mock::MockBackend; +use accshift_app::platforms::{ + PlatformAccount, PlatformAdapter, PlatformBulkEditResult, PlatformRegistry, +}; +use accshift_app::storage; +use accshift_app::stores::{ClientStores, StoreContext, MAX_NOTE_LENGTH}; +use accshift_app::text::Text; +use accshift_app::BackendError; +use std::sync::Arc; + +const SMURFS: &str = "demo-folder-smurfs"; +const STEAM: &str = "steam"; + +struct Demo { + backend: MockBackend, + stores: ClientStores, + dialogs: AppDialogs, +} + +impl Demo { + fn load() -> Self { + let backend = support::mock("demo"); + let (storage, report) = storage::initialize(&backend, None, None, true); + assert!(report.load_error.is_none(), "{:?}", report.load_error); + Self { + backend, + stores: ClientStores::new(storage, StoreContext::shipped()), + dialogs: AppDialogs::new(), + } + } + + fn ctx<'a>( + &'a mut self, + tab: &'a str, + folder: Option<&'a str>, + ) -> (&'a mut AppDialogs, DialogContext<'a>) { + ( + &mut self.dialogs, + DialogContext { + stores: &mut self.stores, + active_tab: tab, + current_folder_id: folder, + }, + ) + } + + fn submit(&mut self, tab: &str, folder: Option<&str>) -> Vec { + let (dialogs, mut ctx) = self.ctx(tab, folder); + dialogs.submit_input(&mut ctx) + } + + fn confirm(&mut self) -> Vec { + let (dialogs, mut ctx) = self.ctx(STEAM, None); + dialogs.confirm_current(&mut ctx) + } + + fn activate(&mut self, tab: &str, command: &AppMenuCommand) -> Vec { + let (dialogs, mut ctx) = self.ctx(tab, None); + dialogs.activate(command, &mut ctx) + } + + fn folder(&self, id: &str) -> FolderInfo { + self.stores.folders().folder(id).expect(id).clone() + } + + fn folder_names(&self, tab: &str) -> Vec { + self.stores + .folders() + .list_folders(tab) + .into_iter() + .map(|folder| folder.name.clone()) + .collect() + } + + fn menu(&self, adapter: Option<&PlatformAdapter>, tab: &str) -> Vec { + self.dialogs.context_menu_items(&MenuInput { + stores: &self.stores, + adapter, + active_tab: tab, + active_tab_usable: true, + current_account_id: None, + }) + } +} + +fn find<'a>(entries: &'a [AppMenuEntry], id: &str) -> &'a AppMenuEntry { + entries + .iter() + .find(|entry| entry.id() == Some(id)) + .unwrap_or_else(|| panic!("no entry {id} in {:?}", ids(entries))) +} + +fn ids(entries: &[AppMenuEntry]) -> Vec<&str> { + entries + .iter() + .map(|entry| entry.id().unwrap_or("|")) + .collect() +} + +fn gog_account() -> PlatformAccount { + PlatformAccount { + id: "g1".into(), + display_name: "Gamer".into(), + username: "g1".into(), + last_login_at_sec: None, + } +} + +// Input dialog: new folder. + +#[test] +fn new_folder_dialog_opens_validates_confirms_and_cancels() { + let mut demo = Demo::load(); + demo.dialogs.open_background_context_menu(10.0, 20.0); + let menu = demo.menu(None, STEAM); + assert_eq!(ids(&menu), ["background.newFolder"]); + let steam = PlatformAdapter::shipped(STEAM).unwrap(); + assert_eq!( + ids(&demo.menu(Some(&steam), STEAM)), + ["background.refresh", "background.newFolder"] + ); + let command = find(&menu, "background.newFolder") + .command() + .unwrap() + .clone(); + assert!(demo.activate(STEAM, &command).is_empty()); + assert!(demo.dialogs.context_menu().is_none()); + + // Open: empty, not submittable. + let dialog = demo.dialogs.input_dialog().unwrap(); + assert_eq!(dialog.title, Text::key("dialog.newFolderTitle")); + assert_eq!(dialog.purpose, InputPurpose::NewFolder); + assert!(!dialog.can_submit()); + + // Validate: blank stays refused, the dialog stays up. + demo.dialogs.input_dialog_mut().unwrap().set_value(" "); + assert!(demo.submit(STEAM, None).is_empty()); + assert!(demo.dialogs.input_dialog().is_some()); + + // Edit and confirm: trimmed, created in the current folder. + demo.dialogs + .input_dialog_mut() + .unwrap() + .set_value(" Mains "); + assert_eq!( + demo.submit(STEAM, Some(SMURFS)), + vec![AppEffect::RefreshCurrentItems] + ); + assert!(demo.dialogs.input_dialog().is_none()); + let created = demo + .stores + .folders() + .list_folders(STEAM) + .into_iter() + .find(|folder| folder.name == "Mains") + .expect("created") + .clone(); + assert_eq!(created.parent_id.as_deref(), Some(SMURFS)); + assert_eq!( + folder_path_label(&demo.stores, &created.id), + format!("{} / Mains", demo.folder(SMURFS).name) + ); + + // Cancel: nothing written. + let before = demo.folder_names(STEAM); + demo.dialogs.open_new_folder_dialog(); + demo.dialogs.input_dialog_mut().unwrap().set_value("Nope"); + demo.dialogs.close_input_dialog(); + assert!(demo.dialogs.input_dialog().is_none()); + assert_eq!(demo.folder_names(STEAM), before); +} + +// Input dialog: rename folder. + +#[test] +fn rename_folder_dialog_opens_with_the_name_and_renames_on_confirm() { + let mut demo = Demo::load(); + let folder = demo.folder(SMURFS); + demo.dialogs + .open_folder_context_menu(5.0, 5.0, folder.clone()); + let menu = demo.menu(None, STEAM); + assert_eq!( + ids(&menu), + ["folder.rename", "folder.color", "folder.delete"] + ); + let rename = find(&menu, "folder.rename").command().unwrap().clone(); + demo.activate(STEAM, &rename); + + let dialog = demo.dialogs.input_dialog().unwrap(); + assert_eq!(dialog.value(), folder.name); + assert_eq!( + dialog.purpose, + InputPurpose::RenameFolder { + folder_id: SMURFS.into() + } + ); + demo.dialogs.input_dialog_mut().unwrap().set_value(""); + assert!(demo.submit(STEAM, None).is_empty()); + + demo.dialogs.input_dialog_mut().unwrap().set_value(" Alts "); + assert_eq!( + demo.submit(STEAM, None), + vec![AppEffect::RefreshCurrentItems] + ); + assert_eq!(demo.folder(SMURFS).name, "Alts"); + + // Cancel keeps the name. + demo.dialogs.open_rename_folder_dialog(&demo.folder(SMURFS)); + demo.dialogs.input_dialog_mut().unwrap().set_value("Other"); + demo.dialogs.close_input_dialog(); + assert_eq!(demo.folder(SMURFS).name, "Alts"); +} + +// Input dialog: card note. + +#[test] +fn card_note_dialog_counts_clamps_sets_and_clears() { + let mut demo = Demo::load(); + let command = AppMenuCommand::Platform(MenuCommand::EditNote { + account_id: "a1".into(), + initial_note: String::new(), + }); + demo.activate(STEAM, &command); + let dialog = demo.dialogs.input_dialog().unwrap(); + assert!(dialog.allow_empty); + assert_eq!(dialog.maxlength, Some(MAX_NOTE_LENGTH)); + assert!(dialog.can_submit()); + assert_eq!(dialog.char_count(), None); + + // Typing past the limit is refused; the counter shows near it. + let long = "x".repeat(MAX_NOTE_LENGTH + 20); + demo.dialogs.input_dialog_mut().unwrap().set_value(long); + let dialog = demo.dialogs.input_dialog().unwrap(); + assert_eq!(dialog.value().len(), MAX_NOTE_LENGTH); + assert_eq!( + dialog.char_count(), + Some((MAX_NOTE_LENGTH, MAX_NOTE_LENGTH)) + ); + + demo.dialogs + .input_dialog_mut() + .unwrap() + .set_value("main account"); + assert_eq!(demo.submit(STEAM, None), vec![AppEffect::CardNotesChanged]); + assert_eq!(demo.stores.account_card_note("a1"), "main account"); + + // A blank note clears it. + demo.activate( + STEAM, + &AppMenuCommand::Platform(MenuCommand::EditNote { + account_id: "a1".into(), + initial_note: "main account".into(), + }), + ); + assert_eq!(demo.dialogs.input_dialog().unwrap().value(), "main account"); + demo.dialogs.input_dialog_mut().unwrap().set_value(" "); + assert_eq!(demo.submit(STEAM, None), vec![AppEffect::CardNotesChanged]); + assert_eq!(demo.stores.account_card_note("a1"), ""); + + // Cancel writes nothing. + demo.activate(STEAM, &command); + demo.dialogs.input_dialog_mut().unwrap().set_value("draft"); + demo.dialogs.close_input_dialog(); + assert_eq!(demo.stores.account_card_note("a1"), ""); +} + +// Input dialog: a platform menu's rename, run on the mock. + +#[test] +fn platform_input_dialog_runs_the_adapter_task_on_confirm() { + let mut demo = Demo::load(); + let gog = PlatformAdapter::shipped("gog").unwrap(); + demo.dialogs + .open_account_context_menu(1.0, 1.0, gog_account()); + let menu = demo.menu(Some(&gog), "gog"); + let rename = find(&menu, "platform.rename.g1").command().unwrap().clone(); + demo.activate("gog", &rename); + let dialog = demo.dialogs.input_dialog().unwrap(); + assert!(matches!(dialog.purpose, InputPurpose::PlatformInput(_))); + assert_eq!(dialog.value(), "Gamer"); + + demo.dialogs + .input_dialog_mut() + .unwrap() + .set_value("Renamed"); + let effects = demo.submit("gog", None); + let task = PlatformTask::SetAccountLabel { + platform_id: "gog".into(), + account_id: "g1".into(), + label: "Renamed".into(), + }; + assert_eq!(effects, vec![AppEffect::RunPlatformTask(task.clone())]); + let settings = demo.stores.settings().clone(); + let outcome = gog.run_task(&task, &demo.backend, &settings); + assert!(!outcome.effects.is_empty()); + + // Cancel runs nothing. + demo.activate("gog", &rename); + demo.dialogs.close_input_dialog(); + assert!(demo.dialogs.input_dialog().is_none()); +} + +// Input dialog: F2 rename and the rename after an add. + +#[test] +fn rename_account_dialogs_label_through_the_adapter() { + let mut demo = Demo::load(); + let gog = Arc::new(PlatformAdapter::shipped("gog").unwrap()); + let steam = PlatformAdapter::shipped(STEAM).unwrap(); + // Steam cannot label accounts: no dialog. + demo.dialogs + .open_rename_account_dialog(Some(&steam), &gog_account()); + assert!(demo.dialogs.input_dialog().is_none()); + + demo.dialogs + .open_rename_account_dialog(Some(&gog), &gog_account()); + assert!(demo.dialogs.input_dialog().unwrap().can_submit()); + demo.dialogs.input_dialog_mut().unwrap().set_value("Main"); + let effects = demo.submit("gog", None); + assert_eq!( + effects, + vec![AppEffect::RenameAccount { + platform_id: "gog".into(), + account_id: "g1".into(), + label: "Main".into() + }] + ); + let result = rename_account_blocking(&demo.backend, &gog, "g1", "Main"); + assert!(result.is_ok(), "{result:?}"); + assert_eq!( + finish_rename_account(result), + vec![AppEffect::LoadAccounts(load_options( + true, false, false, false + ))] + ); + let failed = finish_rename_account(Err(BackendError::new("nope"))); + let [AppEffect::Toast(toast)] = failed.as_slice() else { + panic!("{failed:?}"); + }; + assert_eq!( + toast.text, + Text::with("toast.renameAccountFailed", [("error", "nope")]) + ); + assert_eq!(toast.kind, ToastKind::Error); + + // After an add: prefilled with the detected name, blank keeps it. + let registry = PlatformRegistry::shipped(); + demo.dialogs + .prompt_rename_new_account(®istry, STEAM, "s1", Some("x")); + assert!(demo.dialogs.input_dialog().is_none()); + demo.dialogs + .prompt_rename_new_account(®istry, "gog", "g2", Some(" Detected ")); + assert_eq!(demo.dialogs.input_dialog().unwrap().value(), "Detected"); + demo.dialogs.input_dialog_mut().unwrap().set_value(""); + assert!(demo.submit("gog", None).is_empty()); + assert!(demo.dialogs.input_dialog().is_none()); + + demo.dialogs + .prompt_rename_new_account(®istry, "gog", "g2", None); + demo.dialogs.close_input_dialog(); + assert!(demo.dialogs.input_dialog().is_none()); +} + +// Confirm dialog: delete folder. + +#[test] +fn delete_folder_confirm_deletes_on_confirm_and_keeps_on_cancel() { + let mut demo = Demo::load(); + let folder = demo.folder(SMURFS); + demo.activate(STEAM, &AppMenuCommand::DeleteFolder(folder.clone())); + let dialog = demo.dialogs.confirm_dialog().unwrap(); + assert_eq!( + dialog.title, + Text::with("dialog.deleteFolderTitle", [("name", folder.name.as_str())]) + ); + assert_eq!( + dialog.confirm_label(), + Text::key("context.menu.deleteFolder") + ); + + // Cancel: the folder stays. + assert!(demo.dialogs.close_confirm_dialog().is_empty()); + assert!(demo.stores.folders().folder(SMURFS).is_some()); + + demo.activate(STEAM, &AppMenuCommand::DeleteFolder(folder)); + assert_eq!(demo.confirm(), vec![AppEffect::RefreshCurrentItems]); + assert!(demo.stores.folders().folder(SMURFS).is_none()); + assert!(demo.dialogs.confirm_dialog().is_none()); +} + +// Confirm dialog: a platform menu's confirm. + +#[test] +fn platform_confirm_runs_its_task_on_confirm_only() { + let mut demo = Demo::load(); + let gog = PlatformAdapter::shipped("gog").unwrap(); + demo.dialogs + .open_account_context_menu(1.0, 1.0, gog_account()); + let menu = demo.menu(Some(&gog), "gog"); + let forget = find(&menu, "gog.forget.g1").command().unwrap().clone(); + demo.activate("gog", &forget); + let dialog = demo.dialogs.confirm_dialog().unwrap().clone(); + let ConfirmPurpose::PlatformTask(task) = dialog.purpose else { + panic!("{:?}", dialog.purpose); + }; + assert!(demo.dialogs.close_confirm_dialog().is_empty()); + + demo.activate("gog", &forget); + assert_eq!(demo.confirm(), vec![AppEffect::RunPlatformTask(task)]); +} + +// requestConfirm (useAppDialogs.test.ts). + +#[test] +fn request_confirm_settles_false_when_another_confirm_replaces_it() { + let mut demo = Demo::load(); + let mut effects = Vec::new(); + let ticket = demo.dialogs.request_confirm( + ConfirmConfig { + title: Text::raw("switch"), + message: Text::raw("sure?"), + confirm_label: None, + confirm_color: None, + }, + &mut effects, + ); + assert!(effects.is_empty()); + assert_eq!( + demo.dialogs.confirm_dialog().unwrap().confirm_label(), + Text::key("common.confirm") + ); + let replaced = demo.activate(STEAM, &AppMenuCommand::DeleteFolder(demo.folder(SMURFS))); + assert_eq!( + replaced, + vec![AppEffect::ConfirmSettled { + ticket, + allowed: false + }] + ); +} + +#[test] +fn confirming_the_replacing_dialog_runs_it_and_never_accepts_the_old_request() { + let mut demo = Demo::load(); + let mut effects = Vec::new(); + demo.dialogs.request_confirm( + ConfirmConfig { + title: Text::raw("switch"), + message: Text::raw("sure?"), + confirm_label: None, + confirm_color: None, + }, + &mut effects, + ); + let mut settled = demo.activate(STEAM, &AppMenuCommand::DeleteFolder(demo.folder(SMURFS))); + settled.extend(demo.confirm()); + assert!(demo.stores.folders().folder(SMURFS).is_none()); + let answers: Vec = settled + .iter() + .filter_map(|effect| match effect { + AppEffect::ConfirmSettled { allowed, .. } => Some(*allowed), + _ => None, + }) + .collect(); + assert_eq!(answers, vec![false]); +} + +#[test] +fn request_confirm_resolves_true_on_its_own_dialog_and_false_on_cancel() { + let mut demo = Demo::load(); + let config = ConfirmConfig { + title: Text::raw("switch"), + message: Text::raw("sure?"), + confirm_label: Some(Text::key("personas.switchConfirmAction")), + confirm_color: None, + }; + let mut effects = Vec::new(); + let ticket = demo.dialogs.request_confirm(config.clone(), &mut effects); + assert_eq!(demo.dialogs.pending_confirm(), Some(ticket)); + assert_eq!( + demo.confirm(), + vec![AppEffect::ConfirmSettled { + ticket, + allowed: true + }] + ); + let second = demo.dialogs.request_confirm(config, &mut effects); + assert_ne!(second, ticket); + assert_eq!( + demo.dialogs.close_confirm_dialog(), + vec![AppEffect::ConfirmSettled { + ticket: second, + allowed: false + }] + ); + assert_eq!(demo.dialogs.pending_confirm(), None); +} + +// Context menus. + +#[test] +fn context_menu_commands_write_the_stores() { + let mut demo = Demo::load(); + let folder = demo.folder(SMURFS); + demo.dialogs.open_folder_context_menu(0.0, 0.0, folder); + let menu = demo.menu(None, STEAM); + let AppMenuEntry::Swatches { swatches, .. } = find(&menu, "folder.color") else { + panic!("swatches"); + }; + let swatch = swatches.last().unwrap().clone(); + assert_eq!( + demo.activate(STEAM, &swatch.command), + vec![AppEffect::CardColorsChanged] + ); + assert_eq!(demo.stores.folder_card_color(SMURFS), swatch.color); + + let account = demo + .stores + .folders() + .list_folders(STEAM) + .iter() + .find(|f| f.id == SMURFS) + .map(|f| f.id.clone()) + .unwrap(); + let mv = AppMenuCommand::Platform(MenuCommand::MoveToFolder { + account_id: "a-moved".into(), + folder_id: Some(account.clone()), + }); + assert_eq!( + demo.activate(STEAM, &mv), + vec![AppEffect::RefreshCurrentItems] + ); + assert_eq!( + demo.stores + .folders() + .find_item_folder_id(&ItemRef::account("a-moved"), STEAM), + Some(SMURFS) + ); + + let color = AppMenuCommand::Platform(MenuCommand::SetCardColor { + account_id: "a1".into(), + color: swatch.color.clone(), + }); + assert_eq!( + demo.activate(STEAM, &color), + vec![AppEffect::CardColorsChanged] + ); + assert_eq!(demo.stores.account_card_color("a1"), swatch.color); + + let copy = AppMenuCommand::Platform(MenuCommand::Copy { + value: "76561198000000001".into(), + label: Text::key("platform.steamId"), + }); + assert_eq!( + demo.activate(STEAM, ©), + vec![AppEffect::CopyToClipboard { + text: "76561198000000001".into(), + label: Text::key("platform.steamId") + }] + ); + assert_eq!( + demo.activate(STEAM, &AppMenuCommand::RefreshAccounts), + vec![AppEffect::LoadAccounts(load_options( + false, true, false, true + ))] + ); + + demo.dialogs.open_custom_context_menu( + 0.0, + 0.0, + vec![AppMenuEntry::item( + "persona.edit", + Text::key("personas.edit"), + AppMenuCommand::Custom("persona.edit".into()), + )], + ); + assert!(matches!( + demo.dialogs.context_menu().unwrap().target, + ContextMenuTarget::Custom(_) + )); + let menu = demo.menu(None, STEAM); + let custom = find(&menu, "persona.edit").command().unwrap().clone(); + assert_eq!( + demo.activate(STEAM, &custom), + vec![AppEffect::CustomMenu("persona.edit".into())] + ); +} + +#[test] +fn bulk_edit_result_picks_success_partial_or_failure() { + let ok = bulk_edit_result_toast(&PlatformBulkEditResult { + succeeded: 3, + failed: vec![], + }); + assert_eq!( + ok.text, + Text::with("bulkEdit.toastSuccess", [("count", 3.0)]) + ); + let partial = bulk_edit_result_toast(&PlatformBulkEditResult { + succeeded: 2, + failed: vec!["x".into()], + }); + assert_eq!( + partial.text, + Text::with( + "bulkEdit.toastPartial", + [("succeeded", 2.0), ("failed", 1.0)] + ) + ); + let failed = bulk_edit_result_toast(&PlatformBulkEditResult { + succeeded: 0, + failed: vec!["x".into()], + }); + assert_eq!(failed.kind, ToastKind::Error); +} diff --git a/crates/accshift-app/tests/app_actions_keyboard.rs b/crates/accshift-app/tests/app_actions_keyboard.rs new file mode 100644 index 00000000..627108a8 --- /dev/null +++ b/crates/accshift-app/tests/app_actions_keyboard.rs @@ -0,0 +1,875 @@ +//! The keyboard model: `shared/keyboard/controller.test.ts`, +//! `app/keyboardBindings.test.ts` and `app/tabCycle.test.ts`, then every +//! binding of the app table through the resolver in every scope. + +use accshift_app::actions::keyboard::{ + app_bindings, current_key_scope, parse_combo, pick_cycled_tab, Binding, Direction, KeyAction, + KeyCommand, KeyEvent, KeyHost, KeyOutcome, KeyResult, KeyScope, KeyboardController, Scopes, + CARD_NAV_SCOPES, LAYER_ORDER, +}; +use accshift_app::actions::shell::{key_scope, ScopeFlags}; +use accshift_app::folders::ItemRef; +use std::collections::BTreeSet; + +/// A host that records every call, the TS `setup()` deps. +struct Host { + folder: bool, + active_tab: String, + enabled: Vec, + unavailable: BTreeSet, + input_dialog: bool, + search_focused: bool, + focused: Option, + owns_key: bool, + moves: bool, + bulk_allowed: bool, + drag: bool, + personas_escape: Vec, + activate: bool, + rename: bool, + context_menu: bool, + actions: Vec, + activated: usize, + renamed: usize, + context_menus: usize, + moved: Vec, +} + +impl Default for Host { + fn default() -> Self { + Self { + folder: false, + active_tab: "steam".into(), + enabled: vec!["steam".into(), "riot".into(), "epic".into()], + unavailable: BTreeSet::new(), + input_dialog: false, + search_focused: false, + focused: None, + owns_key: true, + moves: true, + bulk_allowed: true, + drag: false, + personas_escape: Vec::new(), + activate: true, + rename: true, + context_menu: true, + actions: Vec::new(), + activated: 0, + renamed: 0, + context_menus: 0, + moved: Vec::new(), + } + } +} + +impl KeyHost for Host { + fn in_folder(&self) -> bool { + self.folder + } + fn enabled_platform_ids(&self) -> Vec { + self.enabled.clone() + } + fn is_platform_unavailable(&self, platform_id: &str) -> bool { + self.unavailable.contains(platform_id) + } + fn active_tab(&self) -> String { + self.active_tab.clone() + } + fn has_input_dialog(&self) -> bool { + self.input_dialog + } + fn search_input_focused(&self) -> bool { + self.search_focused + } + fn focused_item(&self) -> Option { + self.focused.clone() + } + fn card_focus_owns_activation_key(&self, _event: &KeyEvent) -> bool { + self.owns_key + } + fn is_bulk_edit_toggle_allowed(&self) -> bool { + self.bulk_allowed + } + fn cancel_drag_from_escape(&mut self) -> bool { + self.drag + } + fn personas_panel_escape(&mut self) -> bool { + if self.personas_escape.is_empty() { + false + } else { + self.personas_escape.remove(0) + } + } + fn card_focus_move(&mut self, direction: Direction) -> bool { + self.moved.push(direction); + self.moves + } + fn activate_focused_card(&mut self) -> bool { + self.activated += 1; + self.activate + } + fn rename_focused_card(&mut self) -> bool { + self.renamed += 1; + self.rename + } + fn open_focused_card_context_menu(&mut self) -> bool { + self.context_menus += 1; + self.context_menu + } + fn act(&mut self, action: KeyAction) { + match &action { + KeyAction::ClearCardFocus => self.focused = None, + KeyAction::BlurSearch => self.search_focused = false, + _ => {} + } + self.actions.push(action); + } +} + +impl Host { + fn count(&self, action: &KeyAction) -> usize { + self.actions.iter().filter(|a| *a == action).count() + } + fn last_tab(&self) -> Option<&str> { + self.actions.iter().rev().find_map(|a| match a { + KeyAction::ChangeTab(tab) => Some(tab.as_str()), + _ => None, + }) + } +} + +fn press(keyboard: &KeyboardController, host: &mut Host, scope: KeyScope, event: KeyEvent) -> bool { + keyboard.handle(&event, scope, false, host).is_handled() +} + +fn key(k: &str) -> KeyEvent { + KeyEvent::new(k) +} + +fn binding(combo: &str, scopes: Scopes, command: KeyCommand) -> Binding { + Binding { + combo: combo.into(), + scopes, + allow_in_input: false, + skip_in_input: false, + prevent_default: true, + command, + } +} + +// controller.test.ts + +#[test] +fn controller_handles_and_consumes_a_binding_that_takes_the_key() { + let keyboard = KeyboardController::new(vec![binding( + "escape", + Scopes::Only(vec![KeyScope::App]), + KeyCommand::Swallow, + )]); + let outcome = keyboard.handle(&key("Escape"), KeyScope::App, false, &mut Host::default()); + assert_eq!( + outcome, + KeyOutcome::Handled { + index: 0, + consume: true + } + ); +} + +#[test] +fn controller_stops_at_the_first_binding_that_takes_the_key() { + let keyboard = KeyboardController::new(vec![ + binding( + "escape", + Scopes::Only(vec![KeyScope::App]), + KeyCommand::ClosePalette, + ), + binding( + "escape", + Scopes::Only(vec![KeyScope::App]), + KeyCommand::AddAccount, + ), + ]); + let mut host = Host::default(); + assert!(press(&keyboard, &mut host, KeyScope::App, key("Escape"))); + assert_eq!(host.actions, vec![KeyAction::SetPaletteOpen(false)]); +} + +#[test] +fn controller_falls_through_on_pass_and_leaves_the_event_alone() { + let keyboard = KeyboardController::new(vec![ + binding( + "escape", + Scopes::Only(vec![KeyScope::App]), + KeyCommand::Pass, + ), + binding( + "escape", + Scopes::Only(vec![KeyScope::App]), + KeyCommand::Pass, + ), + ]); + let outcome = keyboard.handle(&key("Escape"), KeyScope::App, false, &mut Host::default()); + assert_eq!(outcome, KeyOutcome::Unhandled); +} + +#[test] +fn controller_runs_modifier_combos_in_a_text_field_unless_the_binding_opts_out() { + let mut select_all = binding( + "mod+a", + Scopes::Only(vec![KeyScope::BulkEdit]), + KeyCommand::BulkSelectAll, + ); + select_all.skip_in_input = true; + let keyboard = KeyboardController::new(vec![ + select_all, + binding("mod+plus", Scopes::All, KeyCommand::ZoomIn), + ]); + let mut host = Host::default(); + assert!(!press( + &keyboard, + &mut host, + KeyScope::BulkEdit, + key("a").ctrl().in_input() + )); + assert!(host.actions.is_empty()); + assert!(press( + &keyboard, + &mut host, + KeyScope::BulkEdit, + key("+").ctrl().code("Equal").in_input() + )); + assert_eq!(host.actions, vec![KeyAction::ZoomIn]); +} + +#[test] +fn controller_runs_skip_in_input_bindings_outside_a_field() { + let mut select_all = binding( + "mod+a", + Scopes::Only(vec![KeyScope::BulkEdit]), + KeyCommand::BulkSelectAll, + ); + select_all.skip_in_input = true; + let keyboard = KeyboardController::new(vec![select_all]); + let mut host = Host::default(); + assert!(press( + &keyboard, + &mut host, + KeyScope::BulkEdit, + key("a").ctrl() + )); + assert_eq!(host.actions, vec![KeyAction::BulkEditSelectAll]); +} + +#[test] +fn controller_keeps_single_keys_out_of_text_fields_unless_allowed() { + let keyboard = KeyboardController::new(vec![binding("n", Scopes::All, KeyCommand::AddAccount)]); + let mut host = Host::default(); + assert!(!press( + &keyboard, + &mut host, + KeyScope::App, + key("n").in_input() + )); + assert!(press(&keyboard, &mut host, KeyScope::App, key("N"))); +} + +#[test] +fn controller_reads_mod_as_cmd_on_macos_and_refuses_the_stray_modifier() { + let keyboard = KeyboardController::new(vec![ + binding("mod+k", Scopes::All, KeyCommand::ClosePalette), + binding("escape", Scopes::All, KeyCommand::Swallow), + ]); + let mut host = Host::default(); + assert!(keyboard + .handle(&key("k").meta(), KeyScope::App, true, &mut host) + .is_handled()); + assert!(!keyboard + .handle(&key("k").ctrl(), KeyScope::App, true, &mut host) + .is_handled()); + assert!(!keyboard + .handle(&key("Escape").ctrl(), KeyScope::App, true, &mut host) + .is_handled()); + assert!(!keyboard + .handle(&key("Escape").meta(), KeyScope::App, false, &mut host) + .is_handled()); +} + +// tabCycle.test.ts + +fn tabs() -> Vec { + vec!["steam".into(), "riot".into(), "epic".into()] +} + +#[test] +fn tab_cycle_steps_forward_and_backward_with_wrap_around() { + let usable = tabs(); + assert_eq!( + pick_cycled_tab(&usable, "steam", 1).as_deref(), + Some("riot") + ); + assert_eq!( + pick_cycled_tab(&usable, "epic", 1).as_deref(), + Some("steam") + ); + assert_eq!( + pick_cycled_tab(&usable, "riot", -1).as_deref(), + Some("steam") + ); + assert_eq!( + pick_cycled_tab(&usable, "steam", -1).as_deref(), + Some("epic") + ); +} + +#[test] +fn tab_cycle_enters_from_the_matching_end_off_the_list() { + let usable = tabs(); + assert_eq!( + pick_cycled_tab(&usable, "roblox", 1).as_deref(), + Some("steam") + ); + assert_eq!( + pick_cycled_tab(&usable, "roblox", -1).as_deref(), + Some("epic") + ); +} + +#[test] +fn tab_cycle_returns_none_with_fewer_than_two_tabs() { + assert_eq!(pick_cycled_tab(&["steam".into()], "steam", 1), None); + assert_eq!(pick_cycled_tab(&[], "steam", -1), None); +} + +// keyboardBindings.test.ts + +#[test] +fn bindings_swallow_the_webview_built_ins_in_every_scope() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + for scope in [ + KeyScope::Locked, + KeyScope::Dialog, + KeyScope::Settings, + KeyScope::App, + ] { + assert!(press(&keyboard, &mut host, scope, key("w").ctrl())); + assert!(press(&keyboard, &mut host, scope, key("p").ctrl())); + assert!(press(&keyboard, &mut host, scope, key("F3"))); + assert!(press(&keyboard, &mut host, scope, key("F5"))); + assert!(press(&keyboard, &mut host, scope, key("f").ctrl())); + assert!(press(&keyboard, &mut host, scope, key("ArrowRight").alt())); + } +} + +#[test] +fn bindings_open_the_palette_over_a_context_menu_and_close_it_from_inside() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + assert!(press( + &keyboard, + &mut host, + KeyScope::ContextMenu, + key("k").ctrl() + )); + assert_eq!( + host.actions, + vec![KeyAction::CloseContextMenu, KeyAction::SetPaletteOpen(true)] + ); + press(&keyboard, &mut host, KeyScope::Palette, key("k").ctrl()); + assert_eq!(host.actions.last(), Some(&KeyAction::SetPaletteOpen(false))); +} + +#[test] +fn bindings_cancel_a_drag_before_any_other_escape_layer() { + let keyboard = KeyboardController::default(); + let mut host = Host { + drag: true, + ..Host::default() + }; + assert!(press( + &keyboard, + &mut host, + KeyScope::Palette, + key("Escape") + )); + assert!(host.actions.is_empty()); +} + +#[test] +fn bindings_close_exactly_one_escape_layer_and_pass_where_the_owner_handles_it() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + assert!(press( + &keyboard, + &mut host, + KeyScope::Palette, + key("Escape") + )); + assert_eq!(host.actions.last(), Some(&KeyAction::SetPaletteOpen(false))); + + press(&keyboard, &mut host, KeyScope::Dialog, key("Escape")); + assert_eq!(host.count(&KeyAction::CloseConfirmDialog), 1); + host.input_dialog = true; + press(&keyboard, &mut host, KeyScope::Dialog, key("Escape")); + assert_eq!(host.count(&KeyAction::CloseInputDialog), 1); + assert_eq!(host.count(&KeyAction::CloseConfirmDialog), 1); + + for scope in [ + KeyScope::ContextMenu, + KeyScope::BulkEdit, + KeyScope::Settings, + KeyScope::Onboarding, + KeyScope::Locked, + ] { + assert!( + !press(&keyboard, &mut host, scope, key("Escape")), + "{}", + scope.as_str() + ); + } +} + +#[test] +fn bindings_let_the_persona_wizard_take_escape_before_closing_the_panel() { + let keyboard = KeyboardController::default(); + let mut host = Host { + personas_escape: vec![true], + ..Host::default() + }; + assert!(press( + &keyboard, + &mut host, + KeyScope::Personas, + key("Escape") + )); + assert_eq!(host.count(&KeyAction::ClosePersonas), 0); + assert!(press( + &keyboard, + &mut host, + KeyScope::Personas, + key("Escape") + )); + assert_eq!(host.count(&KeyAction::ClosePersonas), 1); +} + +#[test] +fn bindings_clear_the_search_then_the_card_focus_then_pass_escape() { + let keyboard = KeyboardController::default(); + let mut host = Host { + search_focused: true, + ..Host::default() + }; + assert!(press( + &keyboard, + &mut host, + KeyScope::App, + key("Escape").in_input() + )); + assert_eq!( + host.actions, + vec![KeyAction::ClearSearchQuery, KeyAction::BlurSearch] + ); + + host.focused = Some(ItemRef::account("a1")); + assert!(press(&keyboard, &mut host, KeyScope::App, key("Escape"))); + assert_eq!(host.focused, None); + + assert!(!press(&keyboard, &mut host, KeyScope::App, key("Escape"))); +} + +#[test] +fn bindings_route_mod_f_to_the_grid_search_or_the_settings_search() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + press(&keyboard, &mut host, KeyScope::App, key("f").ctrl()); + assert_eq!(host.actions, vec![KeyAction::FocusSearch]); + press(&keyboard, &mut host, KeyScope::Settings, key("f").ctrl()); + assert_eq!(host.actions.last(), Some(&KeyAction::FocusSettingsSearch)); +} + +#[test] +fn bindings_map_the_app_shortcuts_to_their_actions() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let app = KeyScope::App; + press(&keyboard, &mut host, app, key("n").ctrl()); + assert_eq!(host.count(&KeyAction::AddAccount), 1); + press(&keyboard, &mut host, app, key("N").ctrl().shift()); + assert_eq!(host.count(&KeyAction::NewFolder), 1); + press(&keyboard, &mut host, app, key("r").ctrl()); + press(&keyboard, &mut host, app, key("F5")); + assert_eq!(host.count(&KeyAction::Refresh), 2); + press(&keyboard, &mut host, app, key("e").ctrl()); + assert_eq!(host.count(&KeyAction::ToggleBulkEdit), 1); + press(&keyboard, &mut host, app, key(",").ctrl()); + assert_eq!(host.count(&KeyAction::ClosePersonas), 1); + assert_eq!(host.count(&KeyAction::ToggleSettingsPanel), 1); + press(&keyboard, &mut host, app, key("P").ctrl().shift()); + assert_eq!(host.count(&KeyAction::OpenPersonas), 1); + press(&keyboard, &mut host, app, key("L").ctrl().shift()); + assert_eq!(host.count(&KeyAction::ToggleViewMode), 1); + press(&keyboard, &mut host, app, key("=").ctrl().code("Equal")); + press( + &keyboard, + &mut host, + app, + key("+").ctrl().shift().code("Equal"), + ); + assert_eq!(host.count(&KeyAction::ZoomIn), 2); + press(&keyboard, &mut host, app, key("-").ctrl().code("Minus")); + assert_eq!(host.count(&KeyAction::ZoomOut), 1); + press(&keyboard, &mut host, app, key("0").ctrl().code("Digit0")); + assert_eq!(host.count(&KeyAction::ResetZoom), 1); + + // Mod+E is taken but does nothing while bulk edit is unavailable. + host.bulk_allowed = false; + assert!(press(&keyboard, &mut host, app, key("e").ctrl())); + assert_eq!(host.count(&KeyAction::ToggleBulkEdit), 1); + + // F5 refreshes only in the app scope; elsewhere it is only swallowed. + assert!(press(&keyboard, &mut host, KeyScope::Settings, key("F5"))); + assert_eq!(host.count(&KeyAction::Refresh), 2); +} + +#[test] +fn bindings_switch_tabs_by_digit_and_cycle_past_unavailable_platforms() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let app = KeyScope::App; + press(&keyboard, &mut host, app, key("2").ctrl().code("Digit2")); + assert_eq!(host.last_tab(), Some("riot")); + assert_eq!(host.count(&KeyAction::ClosePersonas), 1); + + host.unavailable.insert("riot".into()); + assert!(press( + &keyboard, + &mut host, + app, + key("2").ctrl().code("Digit2") + )); + assert!(press( + &keyboard, + &mut host, + app, + key("9").ctrl().code("Digit9") + )); + let tab_changes = |host: &Host| { + host.actions + .iter() + .filter(|a| matches!(a, KeyAction::ChangeTab(_))) + .count() + }; + assert_eq!(tab_changes(&host), 1); + + press(&keyboard, &mut host, app, key("Tab").ctrl()); + assert_eq!(host.last_tab(), Some("epic")); + press(&keyboard, &mut host, app, key("Tab").ctrl().shift()); + assert_eq!(host.last_tab(), Some("epic")); +} + +#[test] +fn bindings_go_up_a_folder_on_backspace_and_alt_left_and_pass_at_the_root() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let app = KeyScope::App; + assert!(!press(&keyboard, &mut host, app, key("Backspace"))); + assert!(press(&keyboard, &mut host, app, key("ArrowLeft").alt())); + assert_eq!(host.count(&KeyAction::NavigateBack), 0); + + host.folder = true; + assert!(press(&keyboard, &mut host, app, key("Backspace"))); + assert!(press(&keyboard, &mut host, app, key("ArrowLeft").alt())); + assert_eq!(host.count(&KeyAction::NavigateBack), 2); + + assert!(!press( + &keyboard, + &mut host, + KeyScope::Settings, + key("ArrowLeft").alt() + )); +} + +#[test] +fn bindings_move_and_activate_the_card_focus_passing_when_nothing_takes_the_key() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let app = KeyScope::App; + assert_eq!(CARD_NAV_SCOPES, [KeyScope::App, KeyScope::BulkEdit]); + assert!(press(&keyboard, &mut host, app, key("ArrowDown"))); + host.moves = false; + assert!(!press(&keyboard, &mut host, app, key("ArrowDown"))); + + assert!(press(&keyboard, &mut host, app, key("Enter"))); + assert_eq!(host.activated, 1); + host.owns_key = false; + assert!(!press(&keyboard, &mut host, app, key("Enter"))); + assert_eq!(host.activated, 1); + + assert!(press(&keyboard, &mut host, app, key("F2"))); + assert!(press(&keyboard, &mut host, app, key("Delete"))); + assert!(press(&keyboard, &mut host, app, key("F10").shift())); + assert!(press(&keyboard, &mut host, app, key("ContextMenu"))); + assert_eq!(host.context_menus, 3); + host.rename = false; + assert!(!press(&keyboard, &mut host, app, key("F2"))); +} + +#[test] +fn bindings_toggle_the_focused_account_with_space_in_bulk_edit_only() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let bulk = KeyScope::BulkEdit; + assert!(!press(&keyboard, &mut host, bulk, key(" "))); + host.focused = Some(ItemRef::folder("f1")); + assert!(!press(&keyboard, &mut host, bulk, key(" "))); + host.focused = Some(ItemRef::account("a1")); + assert!(press(&keyboard, &mut host, bulk, key(" "))); + assert_eq!( + host.actions, + vec![KeyAction::ToggleBulkEditAccount("a1".into())] + ); + assert!(!press(&keyboard, &mut host, KeyScope::App, key(" "))); +} + +#[test] +fn bindings_select_all_in_bulk_edit_but_not_from_a_text_field() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let bulk = KeyScope::BulkEdit; + press(&keyboard, &mut host, bulk, key("a").ctrl()); + press(&keyboard, &mut host, bulk, key("d").ctrl()); + assert_eq!(host.count(&KeyAction::BulkEditSelectAll), 1); + assert_eq!(host.count(&KeyAction::BulkEditDeselectAll), 1); + assert!(!press( + &keyboard, + &mut host, + bulk, + key("a").ctrl().in_input() + )); + assert_eq!(host.count(&KeyAction::BulkEditSelectAll), 1); +} + +#[test] +fn key_scope_picks_the_topmost_layer_and_stops_reading_there() { + fn resolve(active: &[KeyScope]) -> (KeyScope, Vec) { + let mut read = Vec::new(); + let scope = current_key_scope(|scope| { + read.push(scope); + active.contains(&scope) + }); + (scope, read) + } + assert_eq!(resolve(&[]).0, KeyScope::App); + assert_eq!( + resolve(&[KeyScope::Personas, KeyScope::Settings]).0, + KeyScope::Settings + ); + assert_eq!( + resolve(&[KeyScope::BulkEdit, KeyScope::ContextMenu]).0, + KeyScope::ContextMenu + ); + assert_eq!( + resolve(&[KeyScope::Dialog, KeyScope::Onboarding]).0, + KeyScope::Onboarding + ); + assert_eq!( + resolve(&[KeyScope::Locked, KeyScope::Palette]).0, + KeyScope::Locked + ); + let (scope, read) = resolve(&[KeyScope::Dialog]); + assert_eq!(scope, KeyScope::Dialog); + assert_eq!( + read, + vec![ + KeyScope::Locked, + KeyScope::Palette, + KeyScope::Onboarding, + KeyScope::Dialog + ] + ); + assert_eq!(LAYER_ORDER.len(), 8); +} + +#[test] +fn shell_key_scope_reads_the_app_state_like_app_svelte() { + assert_eq!(key_scope(&ScopeFlags::default()), KeyScope::App); + for locked in [ + ScopeFlags { + pin_locked: true, + ..ScopeFlags::default() + }, + ScopeFlags { + streamer_active: true, + palette_open: true, + ..ScopeFlags::default() + }, + ScopeFlags { + render_suspended: true, + has_dialog: true, + ..ScopeFlags::default() + }, + ] { + assert_eq!(key_scope(&locked), KeyScope::Locked); + } + assert_eq!( + key_scope(&ScopeFlags { + bulk_edit_mode: true, + settings_open: true, + personas_open: true, + ..ScopeFlags::default() + }), + KeyScope::BulkEdit + ); +} + +// Every binding of the table, in every scope, through the resolver. + +/// A key event that matches `combo` exactly. +fn event_for(combo: &str) -> KeyEvent { + let parsed = parse_combo(combo); + let mut event = match parsed.key.as_str() { + "plus" => KeyEvent::new("=").code("Equal"), + "minus" => KeyEvent::new("-").code("Minus"), + "space" => KeyEvent::new(" "), + digit if digit.starts_with("digit") => { + let n = &digit["digit".len()..]; + KeyEvent::new(n).code(format!("Digit{n}")) + } + other => KeyEvent::new(other), + }; + event.ctrl = parsed.mod_key; + event.shift = parsed.shift; + event.alt = parsed.alt; + event +} + +/// What a command does on a host that says yes to everything. +fn expected_actions(command: KeyCommand) -> Option> { + use KeyAction as A; + use KeyCommand as C; + Some(match command { + C::Swallow => vec![], + C::Pass => return None, + C::OpenPalette => vec![A::CloseContextMenu, A::SetPaletteOpen(true)], + C::ClosePalette => vec![A::SetPaletteOpen(false)], + C::EscapeCancelDrag => vec![], + C::EscapeCloseDialog => vec![A::CloseInputDialog], + C::EscapePersonas => vec![A::ClosePersonas], + C::EscapeApp => vec![A::ClearSearchQuery, A::BlurSearch], + C::FocusSearch => vec![A::FocusSearch], + C::FocusSettingsSearch => vec![A::FocusSettingsSearch], + C::AddAccount => vec![A::AddAccount], + C::NewFolder => vec![A::NewFolder], + C::Refresh => vec![A::Refresh], + C::ToggleBulkEdit => vec![A::ToggleBulkEdit], + C::ToggleSettings => vec![A::ClosePersonas, A::ToggleSettingsPanel], + C::OpenPersonas => vec![A::OpenPersonas], + C::ToggleViewMode => vec![A::ToggleViewMode], + C::CycleTab(1) => vec![A::ClosePersonas, A::ChangeTab("riot".into())], + C::CycleTab(_) => vec![A::ClosePersonas, A::ChangeTab("epic".into())], + C::TabByIndex(index) => match ["steam", "riot", "epic"].get(index) { + Some(id) => vec![A::ClosePersonas, A::ChangeTab((*id).into())], + None => vec![], + }, + C::ZoomIn => vec![A::ZoomIn], + C::ZoomOut => vec![A::ZoomOut], + C::ResetZoom => vec![A::ResetZoom], + C::AltBack | C::BackspaceBack => vec![A::NavigateBack], + C::CardMove(_) | C::CardActivate | C::RenameFocused | C::ContextMenuFocused => vec![], + C::BulkToggleFocused => vec![A::ToggleBulkEditAccount("a1".into())], + C::BulkSelectAll => vec![A::BulkEditSelectAll], + C::BulkDeselectAll => vec![A::BulkEditDeselectAll], + }) +} + +fn yes_host(drag: bool) -> Host { + Host { + folder: true, + input_dialog: true, + search_focused: true, + focused: Some(ItemRef::account("a1")), + drag, + ..Host::default() + } +} + +#[test] +fn every_binding_resolves_in_every_scope() { + let table = app_bindings(); + let keyboard = KeyboardController::new(table.clone()); + let parsed: Vec<_> = table.iter().map(|b| parse_combo(&b.combo)).collect(); + let mut reached = BTreeSet::new(); + let mut checked = 0; + for drag in [false, true] { + for (index, binding) in table.iter().enumerate() { + let event = event_for(&binding.combo); + for scope in KeyScope::ALL { + let mut host = yes_host(drag); + let outcome = keyboard.handle(&event, scope, false, &mut host); + checked += 1; + // The first binding of the table that is in scope, matches + // the event and does not pass is the one that runs. + let expected = table.iter().enumerate().position(|(j, other)| { + other.scopes.contains(scope) && parsed[j] == parsed[index] && { + let mut probe = yes_host(drag); + accshift_app::actions::keyboard::run_command( + other.command, + &event, + &mut probe, + ) == KeyResult::Handled + } + }); + match (outcome, expected) { + (KeyOutcome::Unhandled, None) => {} + ( + KeyOutcome::Handled { + index: got, + consume, + }, + Some(want), + ) => { + assert_eq!(got, want, "{} in {}", binding.combo, scope.as_str()); + assert!(consume); + if got == index { + reached.insert(index); + let want_actions = expected_actions(binding.command); + assert_eq!( + Some(host.actions.clone()), + want_actions, + "{} in {}", + binding.combo, + scope.as_str() + ); + } + } + (outcome, expected) => panic!( + "{} in {}: got {outcome:?}, expected {expected:?}", + binding.combo, + scope.as_str() + ), + } + // A binding never fires outside its scopes. + if let KeyOutcome::Handled { index: got, .. } = outcome { + assert!(table[got].scopes.contains(scope)); + } + } + } + } + assert_eq!(checked, table.len() * KeyScope::ALL.len() * 2); + // Every binding that can take a key takes one somewhere. + for (index, binding) in table.iter().enumerate() { + if binding.command == KeyCommand::Pass { + continue; + } + assert!( + reached.contains(&index), + "binding {index} ({}) is never reached", + binding.combo + ); + } + assert!(table + .iter() + .filter(|b| b.scopes == Scopes::All) + .all(|b| b.prevent_default)); +} diff --git a/crates/accshift-app/tests/app_actions_personas.rs b/crates/accshift-app/tests/app_actions_personas.rs new file mode 100644 index 00000000..2df397b1 --- /dev/null +++ b/crates/accshift-app/tests/app_actions_personas.rs @@ -0,0 +1,396 @@ +//! Personas: `app/usePersonas.test.ts` and `app/usePersonaSwitch.test.ts`, +//! run on the mock backend through a scripted wrapper. + +mod support; + +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::actions::dialogs::AppDialogs; +use accshift_app::actions::personas::{ + persona_platforms, switch_steps, switch_to_persona_blocking, PersonaController, + PersonaPlatform, PersonaSwitch, PersonaSwitchFailure, PersonaSwitchResult, +}; +use accshift_app::actions::AppEffect; +use accshift_app::jobs::Jobs; +use accshift_app::personas::{Persona, PersonaAssignment, PersonaInput, PersonaPatch}; +use accshift_app::platforms::{PlatformAccount, PlatformRegistry, RuntimeOs}; +use accshift_app::storage; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::text::Text; +use accshift_app::Backend; +use serde_json::Value; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use support::scripted::Scripted; + +fn persona(assignments: &[(&str, &str)]) -> Persona { + Persona { + id: "persona-1".into(), + name: "Main".into(), + color: String::new(), + image: None, + assignments: assignments + .iter() + .map(|(platform_id, account_id)| PersonaAssignment { + platform_id: (*platform_id).into(), + account_id: (*account_id).into(), + }) + .collect(), + } +} + +fn steam_accounts(backend: &dyn Backend, registry: &PlatformRegistry) -> Vec { + registry + .adapter("steam") + .unwrap() + .load_accounts(backend) + .unwrap() +} + +fn stores_of(backend: &dyn Backend) -> ClientStores { + let (storage, _) = storage::initialize(backend, None, None, true); + ClientStores::new(storage, StoreContext::shipped()) +} + +// usePersonas.test.ts + +#[test] +fn switches_with_the_adapters_resolved_account() { + // The dev scenario's Steam accounts have login names; demo's are blank. + let backend = support::scripted("dev"); + let registry = PlatformRegistry::shipped(); + let account = steam_accounts(&backend, ®istry) + .into_iter() + .find(|account| !account.username.is_empty() && account.username != account.id) + .expect("a Steam account with a login name"); + let settings = stores_of(&backend).settings().clone(); + let steps = switch_steps(®istry, &persona(&[("steam", &account.id)])); + backend.clear_log(); + + let result = switch_to_persona_blocking(&backend, &steps, &settings); + + assert_eq!( + result, + Some(PersonaSwitchResult { + succeeded: vec!["steam".into()], + failed: vec![] + }) + ); + // Steam switches by login name, never by the steam id. + let calls = backend.calls("platform_switch_account"); + assert_eq!(calls.len(), 1); + assert!( + calls[0].contains(&format!("{:?}", account.username)), + "{calls:?}" + ); +} + +#[test] +fn fails_the_assignment_instead_of_switching_an_unresolved_id() { + let backend = support::scripted("demo"); + let registry = PlatformRegistry::shipped(); + let settings = stores_of(&backend).settings().clone(); + let steps = switch_steps(®istry, &persona(&[("steam", "steam-id")])); + backend.clear_log(); + + let result = switch_to_persona_blocking(&backend, &steps, &settings).unwrap(); + + assert!(backend.calls("platform_switch_account").is_empty()); + assert!(result.succeeded.is_empty()); + assert_eq!(result.failed[0].platform_id, "steam"); + assert!(result.failed[0].error.contains("steam/steam-id")); +} + +#[test] +fn reports_the_switch_in_flight_until_every_platform_is_done() { + let backend = Arc::new(support::scripted("demo")); + let registry = PlatformRegistry::shipped(); + let account = steam_accounts(backend.as_ref(), ®istry).remove(0); + let settings = stores_of(backend.as_ref()).settings().clone(); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let mut controller = PersonaController::new(&stores_of(backend.as_ref())); + let target = persona(&[("steam", &account.id)]); + + assert!(controller.start_switch(&jobs, ®istry, &settings, target.clone(), false)); + assert!(controller.switching()); + assert_eq!(controller.switching_persona_id(), Some("persona-1")); + // A second start while one runs is refused. + assert!(!controller.start_switch(&jobs, ®istry, &settings, target, false)); + + let deadline = Instant::now() + Duration::from_secs(10); + let (done, result) = loop { + if let Some(done) = controller.poll_switch() { + break done; + } + assert!(Instant::now() < deadline, "switch never finished"); + std::thread::sleep(Duration::from_millis(5)); + }; + assert_eq!(done.id, "persona-1"); + assert_eq!(result.unwrap().succeeded, vec!["steam".to_string()]); + assert!(!controller.switching()); + assert_eq!(controller.poll_switch(), None); +} + +#[test] +fn stops_quietly_when_the_backend_is_locked_by_the_pin() { + let backend = support::scripted("demo"); + let registry = PlatformRegistry::shipped(); + let account = steam_accounts(&backend, ®istry).remove(0); + let settings = stores_of(&backend).settings().clone(); + backend.push( + "platform_switch_account", + Err("pin_locked: Accshift is locked. Enter the PIN to switch accounts."), + ); + let steps = switch_steps( + ®istry, + &persona(&[("steam", &account.id), ("riot", "riot-1")]), + ); + backend.clear_log(); + + assert_eq!( + switch_to_persona_blocking(&backend, &steps, &settings), + None + ); + assert_eq!(backend.calls("platform_switch_account").len(), 1); +} + +#[test] +fn a_pin_lock_after_a_landed_platform_reports_and_stops() { + let backend = support::scripted("demo"); + let registry = PlatformRegistry::shipped(); + let account = steam_accounts(&backend, ®istry).remove(0); + let settings = stores_of(&backend).settings().clone(); + let steps = switch_steps( + ®istry, + &persona(&[ + ("steam", &account.id), + ("riot", "riot-1"), + ("riot", "riot-2"), + ]), + ); + backend.push("platform_switch_account", Ok(Value::Null)); + backend.push("platform_switch_account", Err("pin_locked: locked")); + + let result = switch_to_persona_blocking(&backend, &steps, &settings).unwrap(); + assert_eq!(result.succeeded, vec!["steam".to_string()]); + assert_eq!( + result.failed, + vec![PersonaSwitchFailure { + platform_id: "riot".into(), + error: "pin_locked: locked".into() + }] + ); +} + +#[test] +fn refuses_to_start_while_an_account_switch_is_running() { + let backend = Arc::new(support::scripted("demo")); + let registry = PlatformRegistry::shipped(); + let settings = stores_of(backend.as_ref()).settings().clone(); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let mut controller = PersonaController::new(&stores_of(backend.as_ref())); + backend.clear_log(); + + assert!(!controller.start_switch(&jobs, ®istry, &settings, persona(&[]), true)); + assert!(!controller.switching()); + assert_eq!(controller.poll_switch(), None); +} + +#[test] +fn controller_keeps_its_list_in_step_with_the_store() { + let backend = support::mock("demo"); + let mut stores = stores_of(&backend); + let mut controller = PersonaController::new(&stores); + let before = controller.personas().len(); + let created = controller.create( + &mut stores, + PersonaInput { + name: "Work".into(), + color: String::new(), + image: None, + assignments: vec![], + }, + ); + assert_eq!(controller.personas().len(), before + 1); + controller.update( + &mut stores, + &created.id, + &PersonaPatch { + name: Some("Office".into()), + ..PersonaPatch::default() + }, + ); + assert!(controller.personas().iter().any(|p| p.name == "Office")); + controller.remove(&mut stores, &created.id); + assert_eq!(controller.personas().len(), before); +} + +// usePersonaSwitch.test.ts + +#[test] +fn offers_the_implemented_enabled_platforms_of_this_os_as_slots() { + let backend = support::mock("demo"); + let registry = PlatformRegistry::shipped(); + let mut settings = stores_of(&backend).settings().clone(); + settings.enabled_platforms = registry.defs().iter().map(|def| def.id.clone()).collect(); + let all = persona_platforms(®istry, RuntimeOs::Windows, &settings); + let expected: Vec = registry + .defs() + .iter() + .filter(|def| def.implemented && def.supported_os.contains(&RuntimeOs::Windows)) + .map(|def| PersonaPlatform { + id: def.id.clone(), + name: def.name.clone(), + accent: def.accent.clone(), + }) + .collect(); + assert_eq!(all, expected); + assert!(all.iter().any(|platform| platform.id == "steam")); + + settings.enabled_platforms = vec!["riot".into(), "steam".into()]; + let ids: Vec = persona_platforms(®istry, RuntimeOs::Windows, &settings) + .into_iter() + .map(|platform| platform.id) + .collect(); + // Registry order, not settings order. + assert_eq!(ids, ["steam", "riot"]); +} + +#[test] +fn opens_the_panel_after_closing_settings_and_bulk_edit() { + let mut switch = PersonaSwitch::new(); + assert_eq!( + switch.open_personas(true, true), + vec![AppEffect::CloseSettingsPanel, AppEffect::CloseBulkEdit] + ); + assert!(switch.show_personas); + switch.sync_enabled(false); + assert!(!switch.show_personas); + assert!(switch.open_personas(false, false).is_empty()); + assert!(!switch.show_personas); +} + +#[test] +fn does_nothing_without_a_confirmation() { + let mut switch = PersonaSwitch::new(); + let mut dialogs = AppDialogs::new(); + let target = persona(&[("steam", "s1"), ("riot", "r1")]); + assert!(switch + .handle_switch_persona(&mut dialogs, &target, false, false) + .is_empty()); + let dialog = dialogs.confirm_dialog().unwrap(); + assert_eq!( + dialog.message, + Text::with( + "personas.switchConfirmMessage", + [ + ("name", accshift_app::i18n::TranslationValue::from("Main")), + ("count", accshift_app::i18n::TranslationValue::from(2.0)) + ] + ) + ); + let settled = dialogs.close_confirm_dialog(); + let [AppEffect::ConfirmSettled { ticket, allowed }] = settled.as_slice() else { + panic!("{settled:?}"); + }; + assert_eq!(switch.confirm_settled(*ticket, *allowed), None); + + // Busy: no dialog at all. + let mut dialogs = AppDialogs::new(); + switch.handle_switch_persona(&mut dialogs, &target, true, false); + switch.handle_switch_persona(&mut dialogs, &target, false, true); + assert!(dialogs.confirm_dialog().is_none()); + + // Accepted: the persona comes back for the controller. + let mut dialogs = AppDialogs::new(); + switch.handle_switch_persona(&mut dialogs, &target, false, false); + let ticket = dialogs.pending_confirm().unwrap(); + assert_eq!(switch.confirm_settled(ticket, true), Some(target)); +} + +#[test] +fn toasts_success_total_failure_and_a_partial_switch_by_platform_name() { + let platforms = vec![ + PersonaPlatform { + id: "steam".into(), + name: "Steam".into(), + accent: "#1".into(), + }, + PersonaPlatform { + id: "riot".into(), + name: "Riot".into(), + accent: "#2".into(), + }, + ]; + let target = persona(&[("steam", "s1"), ("riot", "r1")]); + let fail = |id: &str| PersonaSwitchFailure { + platform_id: id.into(), + error: "x".into(), + }; + + let ok = PersonaSwitch::finish_switch( + &target, + Some(&PersonaSwitchResult { + succeeded: vec!["steam".into(), "riot".into()], + failed: vec![], + }), + &platforms, + ); + assert_eq!( + ok, + vec![ + AppEffect::TrackPersonaSwitch { + platforms: 2, + succeeded: 2 + }, + AppEffect::Toast(Toast::new( + Text::with("personas.switched", [("name", "Main")]), + ToastKind::Success + )) + ] + ); + + let failed = PersonaSwitch::finish_switch( + &target, + Some(&PersonaSwitchResult { + succeeded: vec![], + failed: vec![fail("steam"), fail("riot")], + }), + &platforms, + ); + assert_eq!( + failed[1], + AppEffect::Toast(Toast::new( + Text::with("personas.switchFailed", [("name", "Main")]), + ToastKind::Error + )) + ); + + let partial = PersonaSwitch::finish_switch( + &target, + Some(&PersonaSwitchResult { + succeeded: vec!["steam".into()], + failed: vec![fail("riot"), fail("gog")], + }), + &platforms, + ); + assert_eq!( + partial[1], + AppEffect::Toast(Toast::new( + Text::with( + "personas.switchPartial", + [("name", "Main"), ("failed", "Riot, gog")] + ), + ToastKind::Default + )) + ); + + assert!(PersonaSwitch::finish_switch(&target, None, &platforms).is_empty()); +} + +#[test] +fn telemetry_counter_reaches_the_backend() { + let backend = support::scripted("demo"); + let _ = backend.telemetry_track_persona_switch(2, 1); + assert_eq!(backend.calls("telemetry_track_persona_switch"), ["(2, 1)"]); + let _ = Scripted::new(support::mock("demo")); +} diff --git a/crates/accshift-app/tests/app_actions_toasts_palette.rs b/crates/accshift-app/tests/app_actions_toasts_palette.rs new file mode 100644 index 00000000..fecb53e3 --- /dev/null +++ b/crates/accshift-app/tests/app_actions_toasts_palette.rs @@ -0,0 +1,310 @@ +//! Toasts and the command palette: `features/notifications/store.test.ts`, +//! `features/commandPalette/registry.test.ts`, plus the queue timers and the +//! palette filtering of `CommandPalette.svelte`. + +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::actions::palette::{ + fold_text, fuzzy_score, fuzzy_score_all, palette_commands, run_command, CommandPalette, + FoldCache, PaletteAction, PaletteRow, PaletteSection, RegistryInput, EMPTY_QUERY_ACCOUNT_LIMIT, +}; +use accshift_app::actions::toasts::{ + resolve_toast_duration, AddToastOptions, DurationRequest, ToastAction, ToastQueue, ToastType, + DEFAULT_DURATION_MS, MAX_TOASTS, +}; +use accshift_app::i18n::Locale; +use accshift_app::platforms::PlatformAccount; +use accshift_app::stores::ViewMode; +use accshift_app::text::Text; +use std::collections::BTreeSet; +use std::time::{Duration, Instant}; + +// store.test.ts + +#[test] +fn toast_error_never_gets_a_timer() { + assert_eq!( + resolve_toast_duration(ToastType::Error, DurationRequest::Default), + None + ); +} + +#[test] +fn toast_error_ignores_an_explicit_duration() { + assert_eq!( + resolve_toast_duration(ToastType::Error, DurationRequest::Ms(1500)), + None + ); +} + +#[test] +fn toast_other_types_keep_the_default_timer() { + assert_eq!( + resolve_toast_duration(ToastType::Info, DurationRequest::Default), + Some(3000) + ); + assert_eq!( + resolve_toast_duration(ToastType::Success, DurationRequest::Default), + Some(DEFAULT_DURATION_MS) + ); +} + +#[test] +fn toast_honours_an_explicit_duration_on_a_non_error_toast() { + assert_eq!( + resolve_toast_duration(ToastType::Info, DurationRequest::Ms(1000)), + Some(1000) + ); + assert_eq!( + resolve_toast_duration(ToastType::Info, DurationRequest::Sticky), + None + ); +} + +#[test] +fn toast_store_keeps_an_error_toast_without_duration() { + let mut queue = ToastQueue::new(); + let id = queue.add( + Text::raw("boom"), + AddToastOptions::kind(ToastType::Error), + Instant::now(), + ); + assert_eq!(queue.get(id).unwrap().duration_ms, None); +} + +#[test] +fn toast_store_drops_the_timer_when_a_message_is_raised_again_as_an_error() { + let mut queue = ToastQueue::new(); + let now = Instant::now(); + queue.add(Text::raw("same text"), AddToastOptions::default(), now); + let id = queue.add( + Text::raw("same text"), + AddToastOptions::kind(ToastType::Error), + now, + ); + assert_eq!(queue.toasts().len(), 1); + let toast = queue.get(id).unwrap(); + assert_eq!(toast.kind, ToastType::Error); + assert_eq!(toast.duration_ms, None); + assert_eq!(toast.reset_key, 1); +} + +#[test] +fn toast_queue_keeps_the_five_newest() { + let mut queue = ToastQueue::new(); + let now = Instant::now(); + for n in 0..7 { + queue.add(Text::raw(format!("t{n}")), AddToastOptions::default(), now); + } + assert_eq!(queue.toasts().len(), MAX_TOASTS); + assert_eq!(queue.toasts()[0].text, Text::raw("t2")); + assert_eq!(queue.toasts()[4].text, Text::raw("t6")); +} + +#[test] +fn toast_queue_dedupes_on_the_key_and_params_not_the_resolved_text() { + let mut queue = ToastQueue::new(); + let now = Instant::now(); + let a = queue.add( + Text::with("toast.copied", [("label", "a")]), + AddToastOptions::default(), + now, + ); + let b = queue.add( + Text::with("toast.copied", [("label", "b")]), + AddToastOptions::default(), + now, + ); + assert_ne!(a, b); + let again = queue.push( + Toast::new( + Text::with("toast.copied", [("label", "a")]), + ToastKind::Success, + ), + now, + ); + assert_eq!(again, a); + assert_eq!(queue.get(a).unwrap().kind, ToastType::Success); +} + +#[test] +fn toast_countdown_expires_pauses_on_hover_and_resumes() { + let mut queue = ToastQueue::new(); + let start = Instant::now(); + let id = queue.add(Text::raw("x"), AddToastOptions::default(), start); + assert_eq!( + queue.next_deadline(), + Some(start + Duration::from_millis(3000)) + ); + assert!(queue.tick(start + Duration::from_millis(2999)).is_empty()); + + // Hovered at 1 s: 2 s left, frozen. + queue.set_hovered(id, true, start + Duration::from_millis(1000)); + assert_eq!(queue.next_deadline(), None); + assert!(queue.tick(start + Duration::from_secs(60)).is_empty()); + + // Left at 10 s: runs out at 12 s. + let leave = start + Duration::from_secs(10); + assert!(!queue.set_hovered(id, false, leave)); + assert_eq!(queue.next_deadline(), Some(leave + Duration::from_secs(2))); + assert_eq!(queue.tick(leave + Duration::from_secs(2)), vec![id]); + assert!(queue.toasts().is_empty()); +} + +#[test] +fn toast_leaving_with_no_time_left_dismisses_it() { + let mut queue = ToastQueue::new(); + let start = Instant::now(); + let id = queue.add(Text::raw("x"), AddToastOptions::default(), start); + queue.set_hovered(id, true, start + Duration::from_secs(5)); + assert!(queue.set_hovered(id, false, start + Duration::from_secs(6))); + assert!(queue.get(id).is_none()); +} + +#[test] +fn toast_action_runs_once_and_closes_the_toast() { + let mut queue = ToastQueue::new(); + let id = queue.add( + Text::raw("update ready"), + AddToastOptions { + kind: ToastType::Info, + duration: DurationRequest::Sticky, + action: Some(ToastAction { + label: Text::key("update.restart"), + id: "restart".into(), + }), + }, + Instant::now(), + ); + assert_eq!(queue.next_deadline(), None); + assert_eq!(queue.activate_action(id).as_deref(), Some("restart")); + assert!(queue.toasts().is_empty()); + assert_eq!(queue.activate_action(id), None); +} + +// registry.test.ts and the palette view + +fn account(id: &str, name: &str, username: &str) -> PlatformAccount { + PlatformAccount { + id: id.into(), + display_name: name.into(), + username: username.into(), + last_login_at_sec: None, + } +} + +fn input<'a>( + accounts: &'a [PlatformAccount], + current: Option<&'a str>, + unavailable: &'a BTreeSet, +) -> RegistryInput<'a> { + RegistryInput { + accounts, + current_account_id: current, + enabled_platforms: &[], + unavailable_platform_ids: unavailable, + active_tab: "steam", + active_tab_usable: true, + current_folders: &[], + current_folder_id: None, + bulk_edit_available: false, + personas_enabled: false, + update_ready: false, + view_mode: ViewMode::Grid, + is_mac: false, + } +} + +#[test] +fn registry_marks_the_current_account_active_once_through_the_badge_alone() { + let accounts = [ + account("a", "main", "main_login"), + account("b", "alt", "alt_login"), + ]; + let unavailable = BTreeSet::new(); + let all = palette_commands(&input(&accounts, Some("a"), &unavailable)); + let commands: Vec<_> = all + .iter() + .filter(|command| command.section == PaletteSection::Accounts) + .collect(); + let by_id = |id: &str| commands.iter().find(|c| c.id == id).expect(id); + assert!(by_id("account:a").active); + assert!(!by_id("account:b").active); + assert!(commands.iter().all(|command| command.hint.is_none())); + let add = all + .iter() + .find(|command| command.id == "action:add-account") + .unwrap(); + assert!(add.hint.as_deref().unwrap().ends_with("+N")); +} + +#[test] +fn fuzzy_scores_subsequences_and_prefers_tight_word_start_matches() { + assert_eq!(fuzzy_score("", "anything"), Some(0)); + assert_eq!(fuzzy_score("xyz", "steam"), None); + let tight = fuzzy_score("ref", "Refresh").unwrap(); + let loose = fuzzy_score("ref", "rare effect").unwrap(); + assert!(tight > loose, "{tight} vs {loose}"); + assert_eq!(fold_text("Élodie"), "elodie"); + assert!(fuzzy_score("elo", "Élodie").is_some()); +} + +#[test] +fn fuzzy_keyword_hits_count_two_less_than_title_hits() { + let mut cache = FoldCache::default(); + let title = fuzzy_score_all(&mut cache, "zoom", "zoom", &[]).unwrap(); + let keyword = fuzzy_score_all(&mut cache, "zoom", "reset", &["zoom".into()]).unwrap(); + assert_eq!(keyword, title - 2); + assert_eq!( + fuzzy_score_all(&mut cache, "qq", "reset", &["zoom".into()]), + None + ); +} + +#[test] +fn palette_idle_shows_six_accounts_then_the_other_commands() { + let accounts: Vec<_> = (0..9) + .map(|n| account(&format!("a{n}"), &format!("acc {n}"), "")) + .collect(); + let unavailable = BTreeSet::new(); + let commands = palette_commands(&input(&accounts, None, &unavailable)); + let mut palette = CommandPalette::new(); + let filtered = palette.filtered(&commands, Locale::En); + let shown_accounts = filtered + .iter() + .filter(|&&i| commands[i].section == PaletteSection::Accounts) + .count(); + assert_eq!(shown_accounts, EMPTY_QUERY_ACCOUNT_LIMIT); + assert_eq!( + filtered.len(), + commands.len() - (accounts.len() - EMPTY_QUERY_ACCOUNT_LIMIT) + ); + let rows = palette.rows(&commands, Locale::En); + assert_eq!(rows[0], PaletteRow::Header(PaletteSection::Accounts)); +} + +#[test] +fn palette_query_ranks_matches_moves_and_runs_the_highlighted_command() { + let accounts = [ + account("a", "main", "main_login"), + account("b", "alt", "alt_login"), + ]; + let unavailable = BTreeSet::new(); + let commands = palette_commands(&input(&accounts, Some("a"), &unavailable)); + let mut palette = CommandPalette::new(); + palette.set_query("alt"); + let flat = palette.flat_commands(&commands, Locale::En); + assert_eq!(commands[flat[0]].id, "account:b"); + assert_eq!( + palette.enter(&commands, Locale::En), + Some(PaletteAction::SwitchToAccount(accounts[1].clone())) + ); + + palette.move_active(-1, flat.len()); + assert_eq!(palette.active_index(), flat.len() - 1); + palette.set_query("al"); + assert_eq!(palette.active_index(), 0); + + let mut disabled = commands[flat[0]].clone(); + disabled.disabled = true; + assert_eq!(run_command(&disabled), None); +} diff --git a/crates/accshift-app/tests/app_actions_workspace.rs b/crates/accshift-app/tests/app_actions_workspace.rs new file mode 100644 index 00000000..a4450659 --- /dev/null +++ b/crates/accshift-app/tests/app_actions_workspace.rs @@ -0,0 +1,860 @@ +//! Navigation, card actions, extension content, the fallback avatar, the +//! onboarding tour and the App.svelte glue: `app/useAppNavigation.test.ts`, +//! `app/useExtensionContent.test.ts`, `shared/avatarFallback.test.ts`, plus +//! the card and tour paths on the mock backend's demo scenario. + +mod support; + +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::actions::avatar_fallback::{ + avatar_gradient, avatar_initials, avatar_seed, GradientCache, +}; +use accshift_app::actions::card_actions::{self, CardContext, FocusedCard}; +use accshift_app::actions::dialogs::{AppDialogs, ContextMenuTarget, InputPurpose}; +use accshift_app::actions::extension::{ + note_section, warning_section, ExtensionContent, ExtensionInputs, +}; +use accshift_app::actions::keyboard::KeyAction; +use accshift_app::actions::navigation::{ + AppHistoryEntry, AppNavigation, HistoryStack, NavContext, SettingsPanel, SettingsTabBar, +}; +use accshift_app::actions::onboarding::{mock_accounts, mock_items, OnboardingTour, MOCK_IDS}; +use accshift_app::actions::palette::PaletteAction; +use accshift_app::actions::personas::PersonaSwitch; +use accshift_app::actions::shell::{self, ShellActions, TitleBarInput}; +use accshift_app::actions::{load_options, AppEffect}; +use accshift_app::display::FolderNavigation; +use accshift_app::folders::ItemRef; +use accshift_app::i18n::Locale; +use accshift_app::jobs::Jobs; +use accshift_app::platforms::card::{CardSection, ChipTone}; +use accshift_app::platforms::warnings::{WarningChip, WarningPresentation, WarningTone}; +use accshift_app::platforms::{PlatformAccount, PlatformAdapter, PlatformRegistry, RuntimeOs}; +use accshift_app::settings::AppSettings; +use accshift_app::storage; +use accshift_app::stores::{ClientStores, StoreContext, ViewMode}; +use accshift_app::text::Text; +use accshift_app::Backend; +use serde_json::json; +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const SMURFS: &str = "demo-folder-smurfs"; + +fn demo_stores() -> ClientStores { + let backend = support::mock("demo"); + let (storage, _) = storage::initialize(&backend, None, None, true); + ClientStores::new(storage, StoreContext::shipped()) +} + +// ------------------------------------------------------------ navigation + +struct Nav { + registry: PlatformRegistry, + settings: AppSettings, + navigation: FolderNavigation, + panel: SettingsPanel, + stores: ClientStores, + app: AppNavigation, + active_tab: String, + add_flow: Option, +} + +impl Nav { + fn new(tab: &str, show_settings: bool, enabled: &[&str]) -> Self { + let stores = demo_stores(); + let mut settings = stores.settings().clone(); + settings.enabled_platforms = enabled.iter().map(|id| id.to_string()).collect(); + Self { + registry: PlatformRegistry::shipped(), + settings, + navigation: FolderNavigation::new(), + panel: SettingsPanel { show_settings }, + stores, + app: AppNavigation::new(tab), + active_tab: tab.into(), + add_flow: None, + } + } + + fn run( + &mut self, + step: impl FnOnce(&mut AppNavigation, &mut NavContext<'_>) -> Vec, + ) -> Vec { + let mut ctx = NavContext { + active_tab: &self.active_tab, + runtime_os: RuntimeOs::Windows, + registry: &self.registry, + settings: &self.settings, + add_flow_platform: self.add_flow.as_deref(), + navigation: &mut self.navigation, + settings_panel: &mut self.panel, + stores: &self.stores, + }; + let effects = step(&mut self.app, &mut ctx); + for effect in &effects { + if let AppEffect::SetActiveTab(tab) = effect { + self.active_tab = tab.clone(); + } + } + effects + } +} + +/// The TS test's `calls`: clear, tab and load, in order. +fn calls(effects: &[AppEffect]) -> Vec { + effects + .iter() + .filter_map(|effect| match effect { + AppEffect::ClearForPlatformChange => Some("clear".to_string()), + AppEffect::SetActiveTab(tab) => Some(format!("tab:{tab}")), + AppEffect::LoadAccounts(_) => Some("load".to_string()), + _ => None, + }) + .collect() +} + +#[test] +fn tab_change_no_ops_the_re_click_without_wiping_or_reloading() { + let mut nav = Nav::new("steam", false, &["steam"]); + let effects = nav.run(|app, ctx| app.handle_tab_change("steam", ctx)); + assert!(effects.is_empty()); + assert_eq!(nav.app.history.len(), 1); +} + +#[test] +fn tab_change_still_exits_settings_on_the_current_tab() { + let mut nav = Nav::new("steam", true, &["steam"]); + let effects = nav.run(|app, ctx| app.handle_tab_change("steam", ctx)); + assert!(!nav.panel.show_settings); + assert!(calls(&effects).is_empty()); + assert!(effects.contains(&AppEffect::SettingsClosed)); +} + +#[test] +fn tab_change_switches_normally_on_a_different_tab() { + let mut nav = Nav::new("steam", false, &["steam", "riot"]); + nav.navigation.set_current_folder_id(Some(SMURFS.into())); + let effects = nav.run(|app, ctx| app.handle_tab_change("riot", ctx)); + assert_eq!(calls(&effects), ["clear", "tab:riot", "load"]); + assert_eq!(effects[0], AppEffect::AddFlowCancel); + assert!(effects.contains(&AppEffect::ResetVisiblePrimeState)); + assert_eq!(nav.navigation.current_folder_id(), None); + assert_eq!( + nav.app.history.state(), + &AppHistoryEntry::new("riot", None, false) + ); + // An unusable tab is refused outright. + assert!(nav + .run(|app, ctx| app.handle_tab_change("nope", ctx)) + .is_empty()); +} + +#[test] +fn platforms_changed_clears_the_old_load_before_moving_off_a_disabled_tab() { + let mut nav = Nav::new("steam", false, &["riot"]); + let effects = nav.run(|app, ctx| app.handle_platforms_changed(ctx, || "riot".into())); + assert_eq!(calls(&effects), ["clear", "tab:riot", "load"]); + assert!(effects.contains(&AppEffect::ResetVisiblePrimeState)); + assert_eq!(nav.app.history.state().tab, "riot"); +} + +#[test] +fn platforms_changed_keeps_the_grid_when_the_tab_stays_enabled() { + let mut nav = Nav::new("steam", false, &["steam", "riot"]); + let effects = nav.run(|app, ctx| app.handle_platforms_changed(ctx, || unreachable!())); + assert_eq!(calls(&effects), ["load"]); + assert!(effects.contains(&AppEffect::LoadAccounts(load_options( + false, false, false, false + )))); +} + +#[test] +fn settings_toggle_pushes_history_and_back_closes_it() { + let mut nav = Nav::new("steam", false, &["steam"]); + let effects = nav.run(|app, ctx| app.toggle_settings_panel(ctx)); + assert_eq!( + effects, + [AppEffect::AddFlowCancel, AppEffect::CloseBulkEdit] + ); + assert!(nav.panel.show_settings); + assert!(nav.app.history.state().show_settings); + + // Toggling again goes back through history (popstate). + let effects = nav.run(|app, ctx| app.toggle_settings_panel(ctx)); + assert!(!nav.panel.show_settings); + assert_eq!(nav.app.history.index(), 0); + assert_eq!( + effects, + [ + AppEffect::RefreshSettings, + AppEffect::SettingsClosed, + AppEffect::RefreshCurrentItems, + AppEffect::PrepareVisibleAccounts, + AppEffect::QueueGridPadding + ] + ); +} + +#[test] +fn folders_walk_in_and_back_out_through_history() { + let mut nav = Nav::new("steam", false, &["steam"]); + let effects = nav.run(|app, ctx| app.navigate_to(Some(SMURFS.into()), true, ctx)); + assert_eq!( + effects[0], + AppEffect::AddFlowCancelIfConflicting { + platform_id: "steam".into(), + account_id: None + } + ); + assert_eq!(nav.navigation.current_folder_id(), Some(SMURFS)); + assert_eq!(nav.app.history.len(), 2); + + // The mouse back button pops it. + nav.navigation.set_search_query("abc"); + nav.run(|app, ctx| app.go_back(ctx)); + assert_eq!(nav.navigation.current_folder_id(), None); + assert_eq!(nav.navigation.search_query(), ""); + nav.run(|app, ctx| app.go_forward(ctx)); + assert_eq!(nav.navigation.current_folder_id(), Some(SMURFS)); + + // Alt+Left replaces the entry with the parent. + let effects = nav.run(|app, ctx| app.navigate_to_parent_folder(ctx)); + assert!(effects.contains(&AppEffect::QueueGridPadding)); + assert_eq!(nav.navigation.current_folder_id(), None); + assert_eq!(nav.app.history.state().folder_id, None); + assert!(nav + .run(|app, ctx| app.navigate_to_parent_folder(ctx)) + .is_empty()); + + // Without history tracking nothing is pushed. + let len = nav.app.history.len(); + nav.run(|app, ctx| app.navigate_to(Some(SMURFS.into()), false, ctx)); + assert_eq!(nav.app.history.len(), len); +} + +#[test] +fn pop_state_cancels_a_conflicting_add_flow_and_reloads_a_new_tab() { + let mut nav = Nav::new("steam", false, &["steam", "riot"]); + nav.add_flow = Some("steam".into()); + let entry = AppHistoryEntry::new("riot", None, false); + let effects = nav.run(|app, ctx| app.handle_pop_state(&entry, ctx)); + assert_eq!(effects[0], AppEffect::AddFlowCancel); + assert_eq!(calls(&effects), ["clear", "tab:riot", "load"]); +} + +#[test] +fn history_stack_truncates_forward_entries_on_push() { + let mut history = HistoryStack::new(AppHistoryEntry::new("steam", None, false)); + history.push(AppHistoryEntry::new("steam", Some("a".into()), false)); + history.push(AppHistoryEntry::new("steam", Some("b".into()), false)); + assert_eq!(history.back().unwrap().folder_id.as_deref(), Some("a")); + history.push(AppHistoryEntry::new("riot", None, false)); + assert_eq!(history.len(), 3); + assert_eq!(history.forward(), None); + history.back(); + history.back(); + assert_eq!(history.back(), None); +} + +#[test] +fn settings_tab_bar_falls_back_to_a_visible_tab() { + let mut bar = SettingsTabBar::new(); + assert_eq!(bar.active_tab(), "general"); + bar.select("steam"); + assert!(bar.take_scroll_request()); + assert!(!bar.take_scroll_request()); + let visible = vec!["general".to_string(), "riot".to_string()]; + assert_eq!( + bar.ensure_active_visible(&visible).as_deref(), + Some("general") + ); + assert_eq!(bar.ensure_active_visible(&visible), None); + // Like the TS, an empty list re-selects `general` every time. + assert_eq!(bar.ensure_active_visible(&[]).as_deref(), Some("general")); + bar.select("x"); + assert_eq!(bar.ensure_active_visible(&[]).as_deref(), Some("general")); + let mut panel = SettingsPanel::default(); + panel.open(); + assert_eq!( + panel.close(), + [AppEffect::RefreshSettings, AppEffect::SettingsClosed] + ); +} + +// ---------------------------------------------------------- card actions + +fn account(id: &str, name: &str) -> PlatformAccount { + PlatformAccount { + id: id.into(), + display_name: name.into(), + username: id.into(), + last_login_at_sec: None, + } +} + +fn cards<'a>( + rendered: &'a [PlatformAccount], + adapter: Option<&'a PlatformAdapter>, +) -> CardContext<'a> { + CardContext { + active_tab: "steam", + is_searching: false, + adapter, + bulk_edit_mode: false, + pending_setup_account_id: None, + rendered_accounts: rendered, + } +} + +#[test] +fn card_clicks_switch_toggle_or_wait_for_the_add_flow() { + let rendered = [account("a1", "Main")]; + let mut ctx = cards(&rendered, None); + let cancel = AppEffect::AddFlowCancelIfConflicting { + platform_id: "steam".into(), + account_id: Some("a1".into()), + }; + assert_eq!( + card_actions::account_activate(&ctx, &rendered[0]), + std::slice::from_ref(&cancel) + ); + assert_eq!( + card_actions::account_switch(&ctx, &rendered[0]), + [cancel, AppEffect::SwitchAccount(rendered[0].clone())] + ); + assert_eq!( + card_actions::workspace_mouse_down(&ctx), + [AppEffect::DragGridMouseDown] + ); + ctx.is_searching = true; + assert!(card_actions::workspace_mouse_down(&ctx).is_empty()); + + ctx.pending_setup_account_id = Some("a1"); + assert!(card_actions::account_switch(&ctx, &rendered[0]).is_empty()); + let mut dialogs = AppDialogs::new(); + assert!( + card_actions::account_context_menu(&mut dialogs, &ctx, &rendered[0], 0.0, 0.0).is_empty() + ); + assert!(dialogs.context_menu().is_none()); + + ctx.bulk_edit_mode = true; + assert!(card_actions::account_activate(&ctx, &rendered[0]).is_empty()); + assert_eq!( + card_actions::account_switch(&ctx, &rendered[0]), + [AppEffect::BulkEditToggleAccount("a1".into())] + ); + assert_eq!( + card_actions::account_context_menu(&mut dialogs, &ctx, &rendered[0], 0.0, 0.0), + [AppEffect::BulkEditToggleAccount("a1".into())] + ); + assert_eq!( + card_actions::workspace_mouse_down(&ctx), + [AppEffect::BulkEditPaintStart] + ); +} + +#[test] +fn focused_card_keys_open_rename_or_menu() { + let stores = demo_stores(); + let rendered = [account("a1", "Main")]; + let gog = PlatformAdapter::shipped("gog").unwrap(); + let ctx = cards(&rendered, None); + let mut dialogs = AppDialogs::new(); + + assert_eq!(card_actions::activate_focused_card(&ctx, None), None); + let folder = ItemRef::folder(SMURFS); + assert_eq!( + card_actions::activate_focused_card(&ctx, Some(&folder)), + Some(vec![AppEffect::NavigateToFolder(Some(SMURFS.into()))]) + ); + let item = ItemRef::account("a1"); + let effects = card_actions::activate_focused_card(&ctx, Some(&item)).unwrap(); + assert_eq!( + effects.last(), + Some(&AppEffect::SwitchAccount(rendered[0].clone())) + ); + assert_eq!( + card_actions::activate_focused_card(&ctx, Some(&ItemRef::account("gone"))), + None + ); + + // The menu opens at the card's centre, not when it is off screen. + let off_screen = FocusedCard { + item: &item, + center: None, + }; + assert!(card_actions::open_focused_card_context_menu( + &mut dialogs, + &ctx, + &stores, + Some(off_screen) + ) + .is_none()); + let shown = FocusedCard { + item: &folder, + center: Some((40.0, 60.0)), + }; + assert!( + card_actions::open_focused_card_context_menu(&mut dialogs, &ctx, &stores, Some(shown)) + .is_some() + ); + let menu = dialogs.context_menu().unwrap(); + assert_eq!((menu.x, menu.y), (40.0, 60.0)); + assert!(matches!(menu.target, ContextMenuTarget::Folder(_))); + + // F2: folders always, accounts only where labels exist. + assert!(card_actions::rename_focused_card( + &mut dialogs, + &ctx, + &stores, + Some(&folder) + )); + assert!(matches!( + dialogs.input_dialog().unwrap().purpose, + InputPurpose::RenameFolder { .. } + )); + dialogs.close_input_dialog(); + assert!(!card_actions::rename_focused_card( + &mut dialogs, + &ctx, + &stores, + Some(&item) + )); + let ctx = cards(&rendered, Some(&gog)); + assert!(card_actions::rename_focused_card( + &mut dialogs, + &ctx, + &stores, + Some(&item) + )); + assert!(matches!( + dialogs.input_dialog().unwrap().purpose, + InputPurpose::RenameAccount { .. } + )); + + let mut dialogs = AppDialogs::new(); + card_actions::background_context_menu(&mut dialogs, &ctx, 1.0, 2.0); + assert!(matches!( + dialogs.context_menu().unwrap().target, + ContextMenuTarget::Background + )); +} + +#[test] +fn bulk_bar_copies_urls_and_paints_card_colours() { + let mut stores = demo_stores(); + assert!(card_actions::copy_bulk_edit_urls(&[]).is_empty()); + let urls = vec!["https://a".to_string(), "https://b".to_string()]; + assert_eq!( + card_actions::copy_bulk_edit_urls(&urls), + [AppEffect::CopyBulkUrls(urls.clone())] + ); + assert_eq!( + card_actions::bulk_urls_copied_toast(2, true), + Toast::new( + Text::with("bulkEdit.urlsCopied", [("count", 2.0)]), + ToastKind::Success + ) + ); + assert_eq!( + card_actions::bulk_urls_copied_toast(2, false).kind, + ToastKind::Error + ); + + let ids = vec!["a1".to_string(), "a2".to_string()]; + assert!(card_actions::apply_bulk_edit_card_color(&mut stores, &[], "#fff").is_empty()); + let color = accshift_app::stores::ACCOUNT_CARD_COLOR_PRESETS[0].color; + let effects = card_actions::apply_bulk_edit_card_color(&mut stores, &ids, color); + assert_eq!(effects[0], AppEffect::CardColorsChanged); + assert_eq!(stores.account_card_color("a2"), color); + let effects = card_actions::apply_bulk_edit_card_color(&mut stores, &ids, ""); + assert_eq!( + effects[1], + AppEffect::Toast(Toast::new( + Text::with("bulkEdit.colorCleared", [("count", 2.0)]), + ToastKind::Success + )) + ); + assert_eq!(stores.account_card_color("a1"), ""); +} + +// ---------------------------------------------------- extension content + +struct ExtState { + warnings: BTreeMap, + notes: HashMap, + setup: Option>, + inline: bool, +} + +fn content( + ext: &mut ExtensionContent, + state: &ExtState, + ids: &[String], +) -> Arc>>>> { + let setup = |id: &str| (id == "a").then(|| state.setup.clone()).flatten(); + let note = |id: &str| state.notes.get(id).cloned().unwrap_or_default(); + let extra = |_: &str| Vec::new(); + ext.content_by_id(&ExtensionInputs { + locale: Locale::En, + warning_states: &state.warnings, + visible_ids: ids, + setup_content: &setup, + account_note: ¬e, + card_note_version: 0, + show_card_notes_inline: state.inline, + extra_sections: &extra, + extra_sections_version: 0, + }) +} + +fn warning(chips: &[&str], tooltip: &str) -> WarningPresentation { + WarningPresentation { + tooltip: Text::raw(tooltip), + card_outline_tone: None, + list_has_red: false, + list_has_orange: false, + chips: chips + .iter() + .map(|text| WarningChip { + tone: WarningTone::Red, + text: Text::raw(*text), + }) + .collect(), + } +} + +#[test] +fn extension_keeps_the_other_cards_content_when_one_account_changes() { + let ids = vec!["a".to_string(), "b".to_string()]; + let mut state = ExtState { + warnings: BTreeMap::new(), + notes: HashMap::from([("a".into(), "first".into()), ("b".into(), "second".into())]), + setup: None, + inline: false, + }; + let mut ext = ExtensionContent::new(); + let before = content(&mut ext, &state, &ids); + let same = content(&mut ext, &state, &ids); + assert!(Arc::ptr_eq(&before, &same)); + + state.notes.insert("b".into(), "changed".into()); + let after = content(&mut ext, &state, &ids); + let a_before = before["a"].as_ref().unwrap(); + let a_after = after["a"].as_ref().unwrap(); + assert!(Arc::ptr_eq(a_before, a_after)); + assert!(!Arc::ptr_eq( + before["b"].as_ref().unwrap(), + after["b"].as_ref().unwrap() + )); + assert_eq!(after["b"].as_ref().unwrap()[0].lines, ["changed"]); +} + +#[test] +fn extension_sees_a_chip_text_change_with_the_same_chip_count() { + let ids = vec!["a".to_string()]; + let mut state = ExtState { + warnings: BTreeMap::from([("a".into(), warning(&["VAC"], ""))]), + notes: HashMap::new(), + setup: None, + inline: false, + }; + let mut ext = ExtensionContent::new(); + let before = content(&mut ext, &state, &ids)["a"].clone().unwrap(); + state.warnings.insert("a".into(), warning(&["Trade"], "")); + let after = content(&mut ext, &state, &ids)["a"].clone().unwrap(); + assert!(!Arc::ptr_eq(&before, &after)); +} + +#[test] +fn extension_sections_follow_setup_warnings_notes_and_inline_mode() { + let ids = vec!["a".to_string(), "b".to_string()]; + let mut state = ExtState { + warnings: BTreeMap::from([("b".into(), warning(&[], "line one\n line two \n"))]), + notes: HashMap::from([("b".into(), " my note ".into())]), + setup: Some(vec![CardSection { + title: Some(Text::raw("setup")), + ..CardSection::default() + }]), + inline: false, + }; + let mut ext = ExtensionContent::new(); + let map = content(&mut ext, &state, &ids); + assert_eq!( + map["a"].as_ref().unwrap()[0].title, + Some(Text::raw("setup")) + ); + let b = map["b"].as_ref().unwrap(); + assert_eq!(b[0].title, Some(Text::key("card.extensionWarnings"))); + assert_eq!(b[0].text, Some(Text::raw("line one \u{2022} line two"))); + assert_eq!(b[1].lines, ["my note"]); + + state.inline = true; + state.setup = None; + let map = content(&mut ext, &state, &ids); + assert!(map["a"].is_none()); + assert_eq!(map["b"].as_ref().unwrap().len(), 1); + + let chips = warning_section(Some(&warning(&["VAC"], "tip")), Locale::En).unwrap(); + assert_eq!(chips.text, None); + assert!(chips.lines.is_empty()); + assert_eq!(chips.chips[0].tone, ChipTone::Red); + assert_eq!(note_section("x", true), None); + assert_eq!(note_section(" ", false), None); +} + +// ---------------------------------------------------------------- avatar + +#[test] +fn avatar_initials_follow_avatar_fallback_ts() { + assert_eq!(avatar_initials("John Doe"), "JD"); + assert_eq!(avatar_initials("Alice"), "A"); + assert_eq!(avatar_initials(""), "?"); + assert_eq!(avatar_initials(" "), "?"); + assert_eq!(avatar_initials("john doe"), "JD"); + assert_eq!(avatar_initials("John Doe"), "JD"); +} + +#[test] +fn avatar_seed_follows_avatar_fallback_ts() { + let seed = avatar_seed("Alice", "alice_user", "123"); + assert!(seed.contains("Alice") && seed.contains("123")); + assert!(avatar_seed("", "bob_user", "456").contains("bob_user")); + assert_eq!(avatar_seed("X", "Y", "Z"), avatar_seed("X", "Y", "Z")); + assert_ne!( + avatar_seed("Same", "same", "111"), + avatar_seed("Same", "same", "222") + ); + assert_eq!(avatar_seed("Ab", "", "1"), "Ab::1::bA"); + assert_eq!(avatar_seed(" ", "", "1"), "unknown::1::nwonknu"); +} + +#[test] +fn avatar_gradient_follows_avatar_fallback_ts() { + let css = avatar_gradient("test-seed").css(); + assert!(css.contains("background-color:hsl(")); + assert!(css.contains("background-image:linear-gradient(")); + assert_eq!(avatar_gradient("x"), avatar_gradient("x")); + assert_ne!(avatar_gradient("a").css(), avatar_gradient("b").css()); + assert_eq!( + avatar_gradient("a").css(), + avatar_gradient(" A ").css(), + "trimmed and lowercased first" + ); + let mut cache = GradientCache::default(); + assert_eq!(cache.get("x"), avatar_gradient("x")); + assert_eq!(cache.len(), 1); +} + +// ------------------------------------------------------------ onboarding + +#[test] +fn onboarding_tour_swaps_to_steam_and_puts_the_tab_back() { + let accounts = mock_accounts(); + assert_eq!(accounts.len(), 3); + assert_eq!(accounts[1].account.id, MOCK_IDS[1]); + assert_eq!(accounts[1].account.username, "account_2"); + assert_eq!( + accounts[1].display_name, + Text::with("onboarding.features.mockAccount", [("number", 2.0)]) + ); + assert_eq!(mock_items()[2], ItemRef::account("__tour_mock_3")); + + let mut tour = OnboardingTour::new(); + tour.open_onboarding(); + assert_eq!( + tour.set_mock_active(true, "riot"), + [AppEffect::SetActiveTab("steam".into())] + ); + assert!(tour.mock_active()); + assert_eq!( + tour.close("steam"), + [AppEffect::SetActiveTab("riot".into())] + ); + assert!(!tour.open() && !tour.mock_active()); + + // Already on Steam: no tab change either way. + assert!(tour.set_mock_active(true, "steam").is_empty()); + assert!(tour.set_mock_active(false, "steam").is_empty()); +} + +#[test] +fn onboarding_opens_on_a_first_launch_only() { + let backend = Arc::new(support::scripted("demo")); + backend.push( + "telemetry_get_state", + Ok(json!({ + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": false + })), + ); + backend.push("telemetry_get_state", Err("no telemetry")); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let poll = |tour: &mut OnboardingTour| { + let deadline = Instant::now() + Duration::from_secs(5); + let opened = RefCell::new(false); + while Instant::now() < deadline { + if tour.poll() { + *opened.borrow_mut() = true; + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + opened.into_inner() + }; + + let mut tour = OnboardingTour::new(); + tour.start_open_if_never_completed(&jobs); + assert!(poll(&mut tour)); + assert!(tour.open()); + + let mut failed = OnboardingTour::new(); + failed.start_open_if_never_completed(&jobs); + std::thread::sleep(Duration::from_millis(50)); + assert!(!poll(&mut failed)); + assert!(!failed.open()); +} + +// -------------------------------------------------------------- shell + +#[test] +fn shell_glue_matches_app_svelte() { + assert_eq!( + shell::copy_result_toast("Steam ID", true), + Toast::new( + Text::with("toast.copied", [("label", "Steam ID")]), + ToastKind::Success + ) + ); + assert_eq!(shell::copy_result_toast("x", false).kind, ToastKind::Error); + assert_eq!(shell::color_label("red"), Text::key("color.red")); + + let mut stores = demo_stores(); + assert_eq!( + shell::view_mode_change(&mut stores, ViewMode::Grid), + [ + AppEffect::ViewModeChanged(ViewMode::Grid), + AppEffect::QueueGridPadding + ] + ); + assert_eq!( + shell::view_mode_change(&mut stores, ViewMode::List), + [AppEffect::ViewModeChanged(ViewMode::List)] + ); + assert_eq!(stores.view_mode(), ViewMode::List); + assert_eq!(shell::toggled_view_mode(ViewMode::List), ViewMode::Grid); + + assert_eq!(shell::apply_update(true, false), []); + assert_eq!(shell::apply_update(false, true), []); + assert_eq!(shell::apply_update(false, false), [AppEffect::ApplyUpdate]); + assert_eq!(shell::panel_key(true, true, "steam"), "__settings__"); + assert_eq!(shell::panel_key(false, true, "steam"), "__personas__"); + assert_eq!(shell::panel_key(false, false, "steam"), "steam"); + + let registry = PlatformRegistry::shipped(); + let steam = registry.definition("steam"); + assert!(shell::is_bulk_edit_toggle_allowed(steam, true, false)); + assert!(!shell::is_bulk_edit_toggle_allowed(steam, true, true)); + assert!(!shell::is_bulk_edit_toggle_allowed(None, true, false)); + assert_eq!(shell::active_platform_name(steam, "steam"), "Steam"); + assert_eq!(shell::active_platform_name(None, "x"), "x"); + assert_eq!(shell::last_login_unknown_key(None), "time.unknown"); + assert_eq!( + shell::detected_platform_defs(registry.defs(), &["riot".into(), "steam".into()]), + [ + ("steam".to_string(), "Steam".to_string()), + ("riot".to_string(), "Riot Games".to_string()) + ] + ); + assert!(shell::cs2_sections_for_tab("riot", None).is_empty()); + assert!(shell::cs2_badge_for_tab("riot", None).is_none()); + + let flags = shell::title_bar_flags(&TitleBarInput { + active_tab_usable: true, + bulk_edit_capability: true, + persona_switching: true, + ..TitleBarInput::default() + }); + assert!(flags.can_refresh && flags.can_add_account && flags.show_bulk_edit); + assert!(flags.update_cta_disabled); + let flags = shell::title_bar_flags(&TitleBarInput { + active_tab_usable: true, + personas_open: true, + ..TitleBarInput::default() + }); + assert!(!flags.can_refresh && !flags.can_add_account && !flags.show_bulk_edit); +} + +#[test] +fn shell_routes_palette_and_key_actions() { + let mut stores = demo_stores(); + let mut dialogs = AppDialogs::new(); + let mut personas = PersonaSwitch::new(); + personas.show_personas = true; + let mut shell = ShellActions { + dialogs: &mut dialogs, + persona_switch: &mut personas, + stores: &mut stores, + active_tab: "steam", + view_mode: ViewMode::Grid, + personas_enabled: true, + settings_open: false, + }; + let main = account("a1", "Main"); + assert_eq!( + shell.run_palette_action(&PaletteAction::SwitchToAccount(main.clone())), + [ + AppEffect::AddFlowCancelIfConflicting { + platform_id: "steam".into(), + account_id: Some("a1".into()) + }, + AppEffect::SwitchAccount(main) + ] + ); + assert_eq!( + shell.run_palette_action(&PaletteAction::ChangeTab("riot".into())), + [AppEffect::ChangeTab("riot".into())] + ); + assert!(!shell.persona_switch.show_personas); + assert_eq!( + shell.run_palette_action(&PaletteAction::NavigateToParent), + shell::navigate_back("steam") + ); + assert_eq!( + shell.run_palette_action(&PaletteAction::ToggleViewMode), + [AppEffect::ViewModeChanged(ViewMode::List)] + ); + assert_eq!( + shell.run_palette_action(&PaletteAction::OpenPersonas), + [AppEffect::CloseBulkEdit] + ); + assert!(shell + .run_palette_action(&PaletteAction::NewFolder) + .is_empty()); + assert!(shell.dialogs.input_dialog().is_some()); + + assert_eq!(shell.run_key_action(&KeyAction::FocusSearch), None); + assert_eq!( + shell.run_key_action(&KeyAction::CloseInputDialog), + Some(vec![]) + ); + assert!(shell.dialogs.input_dialog().is_none()); + assert_eq!( + shell.run_key_action(&KeyAction::NavigateBack), + Some(shell::navigate_back("steam")) + ); + assert_eq!( + shell.run_key_action(&KeyAction::ToggleBulkEditAccount("a1".into())), + Some(vec![AppEffect::BulkEditToggleAccount("a1".into())]) + ); + assert_eq!( + shell::title_bar_open_settings(shell.persona_switch), + [AppEffect::ToggleSettingsPanel] + ); +} diff --git a/crates/accshift-app/tests/bulk_edit.rs b/crates/accshift-app/tests/bulk_edit.rs new file mode 100644 index 00000000..3e44b2eb --- /dev/null +++ b/crates/accshift-app/tests/bulk_edit.rs @@ -0,0 +1,318 @@ +//! `useBulkEdit.test.ts`, the logic of `BulkEditBar.svelte`, and one bulk +//! edit on the demo accounts with a failure, against MockBackend. + +mod support; + +use accshift_app::backend::Backend; +use accshift_app::bulk_edit::{ + titlebar_button_visible, toggle_allowed, BarStep, BulkEdit, BulkEditBar, TriState, +}; +use accshift_app::jobs::Jobs; +use accshift_app::platforms::PlatformRegistry; +use accshift_app::text::Text; +use serde_json::json; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +fn ids(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() +} + +fn open_steam(bulk: &mut BulkEdit, registry: &PlatformRegistry) { + bulk.toggle(registry.definition("steam")); + assert!(bulk.is_open()); +} + +/// Polls until the bar has no job in flight; the apply result if one ended. +fn settle(bulk: &mut BulkEdit) -> Option { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(result) = bulk.poll() { + return Some(result); + } + if !bulk.is_busy() { + return None; + } + assert!(Instant::now() < deadline, "bulk edit job never finished"); + std::thread::sleep(Duration::from_millis(2)); + } +} + +// ------------------------------------------------------------ selection + +#[test] +fn select_all_inside_a_folder_keeps_what_was_picked_elsewhere() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + open_steam(&mut bulk, ®istry); + bulk.select_all(&ids(&["root-1", "root-2"])); + assert_eq!(bulk.selected_ids(), ids(&["root-1", "root-2"])); + // Navigating into a folder only changes what is on screen. + bulk.select_all(&ids(&["folder-1"])); + assert_eq!(bulk.selected_ids(), ids(&["root-1", "root-2", "folder-1"])); +} + +#[test] +fn a_manual_pick_in_another_folder_is_kept() { + let mut bulk = BulkEdit::new(); + bulk.toggle_account("root-1"); + bulk.toggle_account("folder-1"); + assert_eq!(bulk.selected_ids(), ids(&["root-1", "folder-1"])); +} + +#[test] +fn deselect_all_is_the_reset_across_folders() { + let mut bulk = BulkEdit::new(); + bulk.select_all(&ids(&["root-1"])); + bulk.toggle_account("folder-1"); + bulk.deselect_all(); + assert!(bulk.selected_ids().is_empty()); +} + +// ------------------------------------------------------------ opening + +#[test] +fn opens_only_on_a_platform_with_the_capability() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + bulk.toggle(registry.definition("riot")); + assert!(!bulk.is_open()); + bulk.toggle(None); + assert!(!bulk.is_open()); + bulk.toggle(registry.definition("steam")); + assert_eq!(bulk.platform_id(), Some("steam")); + assert_eq!(bulk.bar().map(BulkEditBar::step), Some(BarStep::Select)); +} + +#[test] +fn a_second_toggle_closes_and_a_third_opens_again() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + let steam = registry.definition("steam"); + bulk.toggle(steam); + bulk.toggle_account("a"); + bulk.toggle(steam); + assert!(!bulk.is_open()); + assert!(bulk.selected_ids().is_empty()); + bulk.toggle(steam); + assert!(bulk.is_open()); +} + +#[test] +fn a_tab_change_closes_the_mode() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + open_steam(&mut bulk, ®istry); + bulk.toggle_account("a"); + bulk.close(); + assert!(!bulk.is_open()); + assert!(bulk.bar().is_none()); + assert!(!bulk.is_selected("a")); +} + +#[test] +fn the_toggle_follows_the_tab_and_the_panels() { + let registry = PlatformRegistry::shipped(); + let steam = registry.definition("steam"); + assert!(toggle_allowed(steam, true, false)); + assert!(!toggle_allowed(steam, false, false)); + assert!(!toggle_allowed(steam, true, true)); + assert!(!toggle_allowed(registry.definition("riot"), true, false)); + assert!(titlebar_button_visible(steam, true, false, false)); + assert!(!titlebar_button_visible(steam, true, false, true)); +} + +// ------------------------------------------------------------ paint + +#[test] +fn painting_selects_every_card_crossed_and_eats_the_click() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + open_steam(&mut bulk, ®istry); + assert!(bulk.paint_mouse_down(true, Some("a"), (10.0, 10.0))); + // Under the threshold: still a click. + bulk.paint_mouse_move((12.0, 11.0), Some("a")); + assert!(!bulk.is_selected("a")); + bulk.paint_mouse_move((40.0, 10.0), Some("b")); + bulk.paint_mouse_move((80.0, 10.0), Some("c")); + bulk.paint_mouse_up(); + assert_eq!(bulk.selected_ids(), ids(&["a", "b", "c"])); + assert!(bulk.paint_capture_click()); + assert!(!bulk.paint_capture_click()); + + // Starting on a selected card deselects. + assert!(bulk.paint_mouse_down(true, Some("b"), (0.0, 0.0))); + bulk.paint_mouse_move((0.0, 20.0), Some("c")); + bulk.paint_mouse_up(); + bulk.end_frame(); + assert!(!bulk.paint_capture_click()); + assert_eq!(bulk.selected_ids(), ids(&["a"])); +} + +#[test] +fn painting_needs_the_mode_a_card_and_the_primary_button() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + assert!(!bulk.paint_mouse_down(true, Some("a"), (0.0, 0.0))); + open_steam(&mut bulk, ®istry); + assert!(!bulk.paint_mouse_down(false, Some("a"), (0.0, 0.0))); + assert!(!bulk.paint_mouse_down(true, None, (0.0, 0.0))); + // A plain click: no swallow. + assert!(bulk.paint_mouse_down(true, Some("a"), (0.0, 0.0))); + bulk.paint_mouse_up(); + assert!(!bulk.paint_capture_click()); +} + +// ------------------------------------------------------------ bar + +#[test] +fn the_bar_steps_cycles_and_escapes() { + let mut bar = BulkEditBar::new(); + assert_eq!(bar.news_popup, TriState::Unchanged); + bar.news_popup = bar.news_popup.cycle(); + assert_eq!(bar.news_popup, TriState::Enabled); + assert_eq!(bar.news_popup.label(), Text::key("common.enabled")); + bar.news_popup = bar.news_popup.cycle(); + assert_eq!(bar.news_popup.value(), Some(false)); + bar.news_popup = bar.news_popup.cycle(); + assert!(!bar.news_popup.is_changed()); + + bar.toggle_color_menu(); + bar.pointer_down(true); + assert!(bar.color_menu_open()); + bar.pointer_down(false); + assert!(!bar.color_menu_open()); + bar.toggle_color_menu(); + assert_eq!(bar.apply_color("#ff0000"), "#ff0000"); + assert!(!bar.color_menu_open()); + + // Escape: the color menu, then the settings step, then the mode. + bar.toggle_color_menu(); + let backend: Arc = Arc::new(support::mock("demo")); + let jobs = Jobs::new(backend, 1, || {}); + bar.go_to_settings(&jobs, None); + assert!(!bar.escape()); + assert!(!bar.color_menu_open()); + assert_eq!(bar.step(), BarStep::Settings); + assert!(!bar.escape()); + assert_eq!(bar.step(), BarStep::Select); + assert!(bar.escape()); + assert_eq!( + BulkEditBar::selected_label(3), + Text::with("bulkEdit.selected", [("count", 3u64)]) + ); + assert!(!BulkEditBar::selection_actions_enabled(0)); + assert_eq!( + BulkEditBar::copy_urls(&ids(&["76561198100000001"])), + vec!["https://steamcommunity.com/profiles/76561198100000001"] + ); +} + +#[test] +fn launch_options_keep_one_row_per_game() { + let mut bar = BulkEditBar::new(); + bar.selected_game_id = "730".into(); + bar.launch_option_input = " ".into(); + assert!(!bar.can_add_launch_option()); + bar.launch_option_input = " -novid ".into(); + bar.add_launch_option(); + assert_eq!(bar.launch_option_edits()[0].value, "-novid"); + assert!(bar.selected_game_id.is_empty() && bar.launch_option_input.is_empty()); + bar.selected_game_id = "730".into(); + bar.launch_option_input = "-high".into(); + bar.add_launch_option(); + assert_eq!(bar.launch_option_edits().len(), 1); + assert_eq!(bar.launch_option_edits()[0].value, "-high"); + bar.remove_launch_option("730"); + assert!(bar.launch_option_edits().is_empty()); +} + +// ------------------------------------------------------------ demo apply + +#[test] +fn a_bulk_edit_on_the_demo_accounts_reports_its_failure() { + let backend = Arc::new(support::scripted("demo")); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let registry = PlatformRegistry::shipped(); + let steam = registry.adapter("steam").expect("steam adapter"); + let accounts = steam.to_accounts(&backend.inner.platform_get_accounts("steam").unwrap()); + let visible: Vec = accounts.iter().map(|account| account.id.clone()).collect(); + assert!(visible.len() >= 2, "the demo has Steam accounts"); + + let mut bulk = BulkEdit::new(); + open_steam(&mut bulk, ®istry); + bulk.select_all(&visible); + + // Next: the games of the first account fill the picker. + backend.push( + "steam_get_account_games", + Ok(json!([{ "app_id": "730", "name": "Counter-Strike 2" }])), + ); + bulk.go_to_settings(&jobs); + assert!(bulk.bar().unwrap().games_loading()); + assert_eq!(settle(&mut bulk).map(|_| ()), None); + let bar = bulk.bar_mut().unwrap(); + assert_eq!(bar.game_name("730"), "Counter-Strike 2"); + assert_eq!(bar.game_name("440"), "440"); + assert_eq!( + backend.calls("steam_get_account_games"), + vec![format!("({:?},)", visible[0])] + ); + + bar.news_popup = TriState::Disabled; + bar.do_not_disturb = TriState::Enabled; + bar.selected_game_id = "730".into(); + bar.launch_option_input = "-novid".into(); + bar.add_launch_option(); + + let failed_id = visible[1].clone(); + backend.push( + "steam_bulk_edit", + Ok(json!({ + "succeeded": visible.len() - 1, + "failed": [{ "steamId": failed_id, "error": "localconfig.vdf is locked" }], + })), + ); + bulk.apply(&jobs); + assert!(bulk.bar().unwrap().applying()); + assert_eq!(bulk.bar().unwrap().apply_label(), Text::raw("...")); + let result = settle(&mut bulk).expect("a result"); + assert_eq!(result.succeeded as usize, visible.len() - 1); + assert_eq!(result.failed.len(), 1); + assert_eq!(result.failed[0].steam_id, failed_id); + // The finished apply closes the mode. + assert!(!bulk.is_open()); + + let sent = backend.calls("steam_bulk_edit"); + assert_eq!(sent.len(), 1); + assert!(sent[0].contains("news_popup: Some(false)")); + assert!(sent[0].contains("do_not_disturb: Some(true)")); + assert!(sent[0].contains(r#"app_id: "730", value: "-novid""#)); + for id in &visible { + assert!(sent[0].contains(id.as_str())); + } + // steamApi.bulkEdit logged both ends. + let logs = backend.calls("log_app_event").join("\n"); + assert!(logs.contains("Bulk edit started") && logs.contains("Bulk edit completed")); +} + +#[test] +fn a_backend_error_fails_every_account() { + let backend = Arc::new(support::scripted("demo")); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + open_steam(&mut bulk, ®istry); + // Nothing selected: nothing sent. + bulk.apply(&jobs); + assert!(!bulk.is_busy()); + bulk.toggle_account("76561198100000001"); + // The plain mock has no bulk edit: its refusal is the error. + bulk.apply(&jobs); + let result = settle(&mut bulk).expect("a result"); + assert_eq!(result.succeeded, 0); + assert_eq!(result.failed[0].steam_id, "all"); + assert!(result.failed[0].error.contains("not available")); + let logs = backend.calls("log_app_event").join("\n"); + assert!(logs.contains("Bulk edit failed")); +} diff --git a/crates/accshift-app/tests/client_storage.rs b/crates/accshift-app/tests/client_storage.rs new file mode 100644 index 00000000..46a31436 --- /dev/null +++ b/crates/accshift-app/tests/client_storage.rs @@ -0,0 +1,359 @@ +//! The TS tests of the ported client storage layer +//! (`src/lib/storage/clientStorage*.test.ts`), as Rust tests. The debounce +//! timers and in-flight promises of TS become explicit steps: a write is +//! pending until `take_*_saves` hands it out, in flight until `finish_save`. + +use accshift_app::mock::MockBackend; +use accshift_app::storage::{ + initialize, ClientStorage, LegacyLocalStorage, CLIENT_STORE_FOLDERS, CLIENT_STORE_SETTINGS, + SAVE_DEBOUNCE, +}; +use accshift_app::types::{ClientStorageSnapshot, StorageManifest}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +fn manifest(entries: &[(&str, &str)]) -> StorageManifest { + StorageManifest { + schema_version: 1, + stores: entries + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + } +} + +fn snapshot(entries: &[(&str, &str)], stores: Value) -> ClientStorageSnapshot { + ClientStorageSnapshot { + manifest: manifest(entries), + stores: serde_json::from_value(stores).unwrap(), + } +} + +/// Every store `{}`, so nothing looks missing (the TS boot payload). +fn booted() -> ClientStorage { + let stores: BTreeMap = accshift_app::storage::CLIENT_STORE_IDS + .iter() + .map(|id| (id.to_string(), json!({}))) + .collect(); + ClientStorage::from_snapshot(ClientStorageSnapshot { + manifest: manifest(&[]), + stores, + }) +} + +fn later() -> Instant { + Instant::now() + SAVE_DEBOUNCE + Duration::from_millis(1) +} + +// ------------------------------------------------------------ flushPendingSaves + +#[test] +fn debounces_a_save_instead_of_persisting_immediately() { + let mut storage = booted(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "a": 1 })); + assert_eq!(storage.get(CLIENT_STORE_FOLDERS), Some(&json!({ "a": 1 }))); + assert!(storage.take_due_saves(Instant::now()).is_empty()); + assert!(storage.has_pending_save(CLIENT_STORE_FOLDERS)); +} + +#[test] +fn flush_persists_pending_stores_immediately_and_cancels_the_timer() { + let mut storage = booted(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["x"] })); + let saves = storage.take_all_saves(); + assert_eq!(saves.len(), 1); + assert_eq!(saves[0].store_id, CLIENT_STORE_FOLDERS); + assert_eq!(saves[0].value, json!({ "folders": ["x"] })); + storage.finish_save(CLIENT_STORE_FOLDERS, &Ok(None)); + // The debounce is gone: past it, nothing more is due. + assert!(storage + .take_due_saves(later() + Duration::from_secs(1)) + .is_empty()); +} + +#[test] +fn flush_is_a_no_op_when_nothing_is_pending() { + let mut storage = booted(); + assert!(storage.take_all_saves().is_empty()); + assert!(!storage.has_unsaved()); +} + +#[test] +fn flush_waits_for_a_save_already_started_by_the_timer() { + let mut storage = booted(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["slow"] })); + let started = storage.take_due_saves(later()); + assert_eq!(started.len(), 1); + // Flushing hands out nothing new while that save is in flight, and the + // storage still owes it to disk. + assert!(storage.take_all_saves().is_empty()); + assert!(storage.has_unsaved()); + storage.finish_save(CLIENT_STORE_FOLDERS, &Ok(None)); + assert!(!storage.has_unsaved()); +} + +#[test] +fn marks_its_own_write_as_seen_so_the_next_focus_finds_no_change() { + let mut storage = booted(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["x"] })); + for save in storage.take_all_saves() { + storage.finish_save(save.store_id, &Ok(Some("file:42:4242".to_string()))); + } + let next = manifest(&[(CLIENT_STORE_FOLDERS, "file:42:4242")]); + assert_eq!(storage.begin_refresh(&next), None); +} + +// ------------------------------------------------------------ external refresh + +/// Both stores recorded as seen, like the TS `beforeEach`. +fn seen() -> ClientStorage { + let mut storage = booted(); + storage.replace_manifest(manifest(&[ + (CLIENT_STORE_FOLDERS, "seen"), + (CLIENT_STORE_SETTINGS, "seen"), + ])); + storage +} + +fn refresh( + storage: &mut ClientStorage, + next: &[(&str, &str)], + snap: ClientStorageSnapshot, +) -> Vec { + match storage.begin_refresh(&manifest(next)) { + None => Vec::new(), + Some(plan) if !plan.needs_snapshot() => plan.other_targets, + Some(plan) => storage.finish_refresh(plan, snap), + } +} + +#[test] +fn keeps_an_edit_still_in_the_save_debounce_and_saves_it_afterwards() { + let mut storage = seen(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["local"] })); + let external = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "seen"), + ]; + refresh( + &mut storage, + &external, + snapshot( + &external, + json!({ CLIENT_STORE_FOLDERS: { "folders": ["external"] } }), + ), + ); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "folders": ["local"] })) + ); + let saves = storage.take_due_saves(later()); + assert_eq!(saves.len(), 1); + assert_eq!(saves[0].value, json!({ "folders": ["local"] })); +} + +#[test] +fn a_snapshot_already_loading_does_not_replace_a_save_that_finished() { + let mut storage = seen(); + let external = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "seen"), + ]; + let plan = storage.begin_refresh(&manifest(&external)).expect("plan"); + // While the snapshot is in flight, the user edits and the save lands. + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["local"] })); + let saves = storage.take_due_saves(later()); + assert_eq!(saves[0].value, json!({ "folders": ["local"] })); + storage.finish_save(CLIENT_STORE_FOLDERS, &Ok(Some("local:1".to_string()))); + storage.finish_refresh( + plan, + snapshot( + &external, + json!({ CLIENT_STORE_FOLDERS: { "folders": ["stale"] } }), + ), + ); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "folders": ["local"] })) + ); +} + +#[test] +fn keeps_an_edit_whose_save_is_still_in_flight() { + let mut storage = seen(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["local"] })); + assert_eq!(storage.take_due_saves(later()).len(), 1); + let external = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "seen"), + ]; + refresh( + &mut storage, + &external, + snapshot( + &external, + json!({ CLIENT_STORE_FOLDERS: { "folders": ["external"] } }), + ), + ); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "folders": ["local"] })) + ); + storage.finish_save(CLIENT_STORE_FOLDERS, &Ok(Some("local:slow".to_string()))); +} + +#[test] +fn still_applies_external_changes_to_stores_with_no_pending_save() { + let mut storage = seen(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["local"] })); + let external = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "external"), + ]; + let changed = refresh( + &mut storage, + &external, + snapshot( + &external, + json!({ + CLIENT_STORE_FOLDERS: { "folders": ["external"] }, + CLIENT_STORE_SETTINGS: { "language": "fr" }, + }), + ), + ); + assert_eq!(changed, [CLIENT_STORE_SETTINGS]); + assert_eq!( + storage.get(CLIENT_STORE_SETTINGS), + Some(&json!({ "language": "fr" })) + ); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "folders": ["local"] })) + ); +} + +#[test] +fn does_not_mark_a_store_as_seen_when_it_changed_after_the_manifest_read() { + let mut storage = seen(); + let first = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "seen"), + ]; + let later_manifest = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "later"), + ]; + let stores = json!({ + CLIENT_STORE_FOLDERS: { "folders": ["external"] }, + CLIENT_STORE_SETTINGS: { "language": "de" }, + }); + let changed = refresh( + &mut storage, + &first, + snapshot(&later_manifest, stores.clone()), + ); + assert_eq!(changed, [CLIENT_STORE_FOLDERS]); + let changed = refresh( + &mut storage, + &later_manifest, + snapshot(&later_manifest, stores), + ); + assert_eq!(changed, [CLIENT_STORE_SETTINGS]); + assert_eq!( + storage.get(CLIENT_STORE_SETTINGS), + Some(&json!({ "language": "de" })) + ); +} + +// ------------------------------------------------------------ onClientStoreChange + +#[test] +fn a_write_changes_its_own_store_revision_only() { + // TS tells listeners; here a reader compares revisions. + let mut storage = booted(); + let settings = storage.revision(CLIENT_STORE_SETTINGS); + storage.set(CLIENT_STORE_FOLDERS, json!({ "a": 1 })); + assert_eq!(storage.revision(CLIENT_STORE_SETTINGS), settings); + storage.set(CLIENT_STORE_SETTINGS, json!({ "uiScalePercent": 110 })); + assert_eq!(storage.revision(CLIENT_STORE_SETTINGS), settings + 1); +} + +// ------------------------------------------------------------ legacy migration + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap() + .to_path_buf() +} + +fn mock() -> MockBackend { + MockBackend::load(&MockBackend::data_dir(&repo_root()), "empty", None).unwrap() +} + +fn legacy() -> LegacyLocalStorage { + BTreeMap::from([( + "accshift_folders".to_string(), + json!({ "folders": [{ "id": "real", "name": "Real folder" }] }).to_string(), + )]) +} + +/// No store at all: every store is a migration candidate. +fn empty_snapshot() -> ClientStorageSnapshot { + snapshot(&[], json!({})) +} + +#[test] +fn migrates_legacy_keys_against_the_real_backend() { + let (storage, report) = initialize(&mock(), Some(empty_snapshot()), Some(&legacy()), false); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "folders": [{ "id": "real", "name": "Real folder" }] })) + ); + assert_eq!(report.migrated, [CLIENT_STORE_FOLDERS]); + assert!(!storage.has_unsaved()); +} + +#[test] +fn leaves_the_store_files_alone_when_the_snapshot_cannot_be_read() { + let backend = mock(); + assert!(backend.fail( + "load_client_storage_snapshot", + Some("Could not parse JSON folders.json") + )); + let (storage, report) = initialize(&backend, None, Some(&legacy()), false); + assert!(report.load_error.is_some()); + assert!(report.migrated.is_empty()); + assert_eq!(storage.get(CLIENT_STORE_FOLDERS), None); + assert!(!storage.has_unsaved()); +} + +#[test] +fn never_reads_legacy_keys_under_the_mock_backend() { + let (storage, report) = initialize(&mock(), Some(empty_snapshot()), Some(&legacy()), true); + assert!(report.migrated.is_empty()); + assert_eq!(storage.get(CLIENT_STORE_FOLDERS), None); + assert!(!storage.has_unsaved()); +} + +#[test] +fn refresh_blocking_reads_through_the_backend() { + // The empty scenario's manifest has no entry, so a storage that recorded + // one sees it as removed and reloads that store from the snapshot. + let backend = mock(); + let mut storage = booted(); + storage.replace_manifest(manifest(&[(CLIENT_STORE_FOLDERS, "old")])); + let changed = storage.refresh_blocking(&backend).unwrap(); + assert_eq!(changed, [CLIENT_STORE_FOLDERS]); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "version": 1, "folders": [], "itemOrder": {} })) + ); + assert_eq!( + storage.refresh_blocking(&backend).unwrap(), + Vec::::new() + ); +} diff --git a/crates/accshift-app/tests/data/platform/time_formats.json b/crates/accshift-app/tests/data/platform/time_formats.json new file mode 100644 index 00000000..8cc39da4 --- /dev/null +++ b/crates/accshift-app/tests/data/platform/time_formats.json @@ -0,0 +1,9869 @@ +{ + "node": "v24.21.0", + "icu": "78.3", + "tz": "2026c", + "changes": { + "UTC": [], + "Europe/Paris": [1743296400, 1761440400, 1774746000, 1792890000, 2216250000, 2234998800], + "America/New_York": [1741503600, 1762063200, 1772953200, 1793512800, 2215062000, 2235621600], + "Asia/Kolkata": [] + }, + "rows": [ + { + "zone": "UTC", + "locale": "en", + "stamp": 1736408000, + "offset": 0, + "date": "Jan 9, 2025", + "dateTime": "Jan 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1738022700, + "offset": 0, + "date": "Jan 28, 2025", + "dateTime": "Jan 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1739086400, + "offset": 0, + "date": "Feb 9, 2025", + "dateTime": "Feb 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1740701100, + "offset": 0, + "date": "Feb 28, 2025", + "dateTime": "Feb 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1741505600, + "offset": 0, + "date": "Mar 9, 2025", + "dateTime": "Mar 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1743120300, + "offset": 0, + "date": "Mar 28, 2025", + "dateTime": "Mar 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1744184000, + "offset": 0, + "date": "Apr 9, 2025", + "dateTime": "Apr 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1745798700, + "offset": 0, + "date": "Apr 28, 2025", + "dateTime": "Apr 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1746776000, + "offset": 0, + "date": "May 9, 2025", + "dateTime": "May 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1748390700, + "offset": 0, + "date": "May 28, 2025", + "dateTime": "May 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1749454400, + "offset": 0, + "date": "Jun 9, 2025", + "dateTime": "Jun 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1751069100, + "offset": 0, + "date": "Jun 28, 2025", + "dateTime": "Jun 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1752046400, + "offset": 0, + "date": "Jul 9, 2025", + "dateTime": "Jul 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1753661100, + "offset": 0, + "date": "Jul 28, 2025", + "dateTime": "Jul 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1754724800, + "offset": 0, + "date": "Aug 9, 2025", + "dateTime": "Aug 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1756339500, + "offset": 0, + "date": "Aug 28, 2025", + "dateTime": "Aug 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1757403200, + "offset": 0, + "date": "Sep 9, 2025", + "dateTime": "Sep 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1759017900, + "offset": 0, + "date": "Sep 28, 2025", + "dateTime": "Sep 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1759995200, + "offset": 0, + "date": "Oct 9, 2025", + "dateTime": "Oct 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1761609900, + "offset": 0, + "date": "Oct 28, 2025", + "dateTime": "Oct 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1762673600, + "offset": 0, + "date": "Nov 9, 2025", + "dateTime": "Nov 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1764288300, + "offset": 0, + "date": "Nov 28, 2025", + "dateTime": "Nov 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1765265600, + "offset": 0, + "date": "Dec 9, 2025", + "dateTime": "Dec 9, 2025, 07:33 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1766880300, + "offset": 0, + "date": "Dec 28, 2025", + "dateTime": "Dec 28, 2025, 12:05 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1709208000, + "offset": 0, + "date": "Feb 29, 2024", + "dateTime": "Feb 29, 2024, 12:00 PM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1704067199, + "offset": 0, + "date": "Dec 31, 2023", + "dateTime": "Dec 31, 2023, 11:59 PM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 915196020, + "offset": 0, + "date": "Jan 1, 1999", + "dateTime": "Jan 1, 1999, 01:07 PM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1760000000, + "offset": 0, + "date": "Oct 9, 2025", + "dateTime": "Oct 9, 2025, 08:53 AM" + }, + { + "zone": "UTC", + "locale": "en", + "stamp": 1791000000, + "offset": 0, + "date": "Oct 3, 2026", + "dateTime": "Oct 3, 2026, 04:00 AM" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1736408000, + "offset": 0, + "date": "9 ene 2025", + "dateTime": "9 ene 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1738022700, + "offset": 0, + "date": "28 ene 2025", + "dateTime": "28 ene 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1739086400, + "offset": 0, + "date": "9 feb 2025", + "dateTime": "9 feb 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1740701100, + "offset": 0, + "date": "28 feb 2025", + "dateTime": "28 feb 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1741505600, + "offset": 0, + "date": "9 mar 2025", + "dateTime": "9 mar 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1743120300, + "offset": 0, + "date": "28 mar 2025", + "dateTime": "28 mar 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1744184000, + "offset": 0, + "date": "9 abr 2025", + "dateTime": "9 abr 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1745798700, + "offset": 0, + "date": "28 abr 2025", + "dateTime": "28 abr 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1746776000, + "offset": 0, + "date": "9 may 2025", + "dateTime": "9 may 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1748390700, + "offset": 0, + "date": "28 may 2025", + "dateTime": "28 may 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1749454400, + "offset": 0, + "date": "9 jun 2025", + "dateTime": "9 jun 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1751069100, + "offset": 0, + "date": "28 jun 2025", + "dateTime": "28 jun 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1752046400, + "offset": 0, + "date": "9 jul 2025", + "dateTime": "9 jul 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1753661100, + "offset": 0, + "date": "28 jul 2025", + "dateTime": "28 jul 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1754724800, + "offset": 0, + "date": "9 ago 2025", + "dateTime": "9 ago 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1756339500, + "offset": 0, + "date": "28 ago 2025", + "dateTime": "28 ago 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1757403200, + "offset": 0, + "date": "9 sept 2025", + "dateTime": "9 sept 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1759017900, + "offset": 0, + "date": "28 sept 2025", + "dateTime": "28 sept 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1759995200, + "offset": 0, + "date": "9 oct 2025", + "dateTime": "9 oct 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1761609900, + "offset": 0, + "date": "28 oct 2025", + "dateTime": "28 oct 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1762673600, + "offset": 0, + "date": "9 nov 2025", + "dateTime": "9 nov 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1764288300, + "offset": 0, + "date": "28 nov 2025", + "dateTime": "28 nov 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1765265600, + "offset": 0, + "date": "9 dic 2025", + "dateTime": "9 dic 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1766880300, + "offset": 0, + "date": "28 dic 2025", + "dateTime": "28 dic 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1709208000, + "offset": 0, + "date": "29 feb 2024", + "dateTime": "29 feb 2024, 12:00" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1704067199, + "offset": 0, + "date": "31 dic 2023", + "dateTime": "31 dic 2023, 23:59" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 915196020, + "offset": 0, + "date": "1 ene 1999", + "dateTime": "1 ene 1999, 13:07" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1760000000, + "offset": 0, + "date": "9 oct 2025", + "dateTime": "9 oct 2025, 08:53" + }, + { + "zone": "UTC", + "locale": "es", + "stamp": 1791000000, + "offset": 0, + "date": "3 oct 2026", + "dateTime": "3 oct 2026, 04:00" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1736408000, + "offset": 0, + "date": "9 janv. 2025", + "dateTime": "9 janv. 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1738022700, + "offset": 0, + "date": "28 janv. 2025", + "dateTime": "28 janv. 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1739086400, + "offset": 0, + "date": "9 févr. 2025", + "dateTime": "9 févr. 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1740701100, + "offset": 0, + "date": "28 févr. 2025", + "dateTime": "28 févr. 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1741505600, + "offset": 0, + "date": "9 mars 2025", + "dateTime": "9 mars 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1743120300, + "offset": 0, + "date": "28 mars 2025", + "dateTime": "28 mars 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1744184000, + "offset": 0, + "date": "9 avr. 2025", + "dateTime": "9 avr. 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1745798700, + "offset": 0, + "date": "28 avr. 2025", + "dateTime": "28 avr. 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1746776000, + "offset": 0, + "date": "9 mai 2025", + "dateTime": "9 mai 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1748390700, + "offset": 0, + "date": "28 mai 2025", + "dateTime": "28 mai 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1749454400, + "offset": 0, + "date": "9 juin 2025", + "dateTime": "9 juin 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1751069100, + "offset": 0, + "date": "28 juin 2025", + "dateTime": "28 juin 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1752046400, + "offset": 0, + "date": "9 juil. 2025", + "dateTime": "9 juil. 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1753661100, + "offset": 0, + "date": "28 juil. 2025", + "dateTime": "28 juil. 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1754724800, + "offset": 0, + "date": "9 août 2025", + "dateTime": "9 août 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1756339500, + "offset": 0, + "date": "28 août 2025", + "dateTime": "28 août 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1757403200, + "offset": 0, + "date": "9 sept. 2025", + "dateTime": "9 sept. 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1759017900, + "offset": 0, + "date": "28 sept. 2025", + "dateTime": "28 sept. 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1759995200, + "offset": 0, + "date": "9 oct. 2025", + "dateTime": "9 oct. 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1761609900, + "offset": 0, + "date": "28 oct. 2025", + "dateTime": "28 oct. 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1762673600, + "offset": 0, + "date": "9 nov. 2025", + "dateTime": "9 nov. 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1764288300, + "offset": 0, + "date": "28 nov. 2025", + "dateTime": "28 nov. 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1765265600, + "offset": 0, + "date": "9 déc. 2025", + "dateTime": "9 déc. 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1766880300, + "offset": 0, + "date": "28 déc. 2025", + "dateTime": "28 déc. 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1709208000, + "offset": 0, + "date": "29 févr. 2024", + "dateTime": "29 févr. 2024, 12:00" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1704067199, + "offset": 0, + "date": "31 déc. 2023", + "dateTime": "31 déc. 2023, 23:59" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 915196020, + "offset": 0, + "date": "1 janv. 1999", + "dateTime": "1 janv. 1999, 13:07" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1760000000, + "offset": 0, + "date": "9 oct. 2025", + "dateTime": "9 oct. 2025, 08:53" + }, + { + "zone": "UTC", + "locale": "fr", + "stamp": 1791000000, + "offset": 0, + "date": "3 oct. 2026", + "dateTime": "3 oct. 2026, 04:00" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1736408000, + "offset": 0, + "date": "9 de jan. de 2025", + "dateTime": "9 de jan. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1738022700, + "offset": 0, + "date": "28 de jan. de 2025", + "dateTime": "28 de jan. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1739086400, + "offset": 0, + "date": "9 de fev. de 2025", + "dateTime": "9 de fev. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1740701100, + "offset": 0, + "date": "28 de fev. de 2025", + "dateTime": "28 de fev. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1741505600, + "offset": 0, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1743120300, + "offset": 0, + "date": "28 de mar. de 2025", + "dateTime": "28 de mar. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1744184000, + "offset": 0, + "date": "9 de abr. de 2025", + "dateTime": "9 de abr. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1745798700, + "offset": 0, + "date": "28 de abr. de 2025", + "dateTime": "28 de abr. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1746776000, + "offset": 0, + "date": "9 de mai. de 2025", + "dateTime": "9 de mai. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1748390700, + "offset": 0, + "date": "28 de mai. de 2025", + "dateTime": "28 de mai. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1749454400, + "offset": 0, + "date": "9 de jun. de 2025", + "dateTime": "9 de jun. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1751069100, + "offset": 0, + "date": "28 de jun. de 2025", + "dateTime": "28 de jun. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1752046400, + "offset": 0, + "date": "9 de jul. de 2025", + "dateTime": "9 de jul. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1753661100, + "offset": 0, + "date": "28 de jul. de 2025", + "dateTime": "28 de jul. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1754724800, + "offset": 0, + "date": "9 de ago. de 2025", + "dateTime": "9 de ago. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1756339500, + "offset": 0, + "date": "28 de ago. de 2025", + "dateTime": "28 de ago. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1757403200, + "offset": 0, + "date": "9 de set. de 2025", + "dateTime": "9 de set. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1759017900, + "offset": 0, + "date": "28 de set. de 2025", + "dateTime": "28 de set. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1759995200, + "offset": 0, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1761609900, + "offset": 0, + "date": "28 de out. de 2025", + "dateTime": "28 de out. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1762673600, + "offset": 0, + "date": "9 de nov. de 2025", + "dateTime": "9 de nov. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1764288300, + "offset": 0, + "date": "28 de nov. de 2025", + "dateTime": "28 de nov. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1765265600, + "offset": 0, + "date": "9 de dez. de 2025", + "dateTime": "9 de dez. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1766880300, + "offset": 0, + "date": "28 de dez. de 2025", + "dateTime": "28 de dez. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1709208000, + "offset": 0, + "date": "29 de fev. de 2024", + "dateTime": "29 de fev. de 2024, 12:00" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1704067199, + "offset": 0, + "date": "31 de dez. de 2023", + "dateTime": "31 de dez. de 2023, 23:59" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 915196020, + "offset": 0, + "date": "1 de jan. de 1999", + "dateTime": "1 de jan. de 1999, 13:07" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1760000000, + "offset": 0, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 08:53" + }, + { + "zone": "UTC", + "locale": "pt", + "stamp": 1791000000, + "offset": 0, + "date": "3 de out. de 2026", + "dateTime": "3 de out. de 2026, 04:00" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1736408000, + "offset": 0, + "date": "9 de jan. de 2025", + "dateTime": "9 de jan. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1738022700, + "offset": 0, + "date": "28 de jan. de 2025", + "dateTime": "28 de jan. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1739086400, + "offset": 0, + "date": "9 de fev. de 2025", + "dateTime": "9 de fev. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1740701100, + "offset": 0, + "date": "28 de fev. de 2025", + "dateTime": "28 de fev. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1741505600, + "offset": 0, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1743120300, + "offset": 0, + "date": "28 de mar. de 2025", + "dateTime": "28 de mar. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1744184000, + "offset": 0, + "date": "9 de abr. de 2025", + "dateTime": "9 de abr. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1745798700, + "offset": 0, + "date": "28 de abr. de 2025", + "dateTime": "28 de abr. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1746776000, + "offset": 0, + "date": "9 de mai. de 2025", + "dateTime": "9 de mai. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1748390700, + "offset": 0, + "date": "28 de mai. de 2025", + "dateTime": "28 de mai. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1749454400, + "offset": 0, + "date": "9 de jun. de 2025", + "dateTime": "9 de jun. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1751069100, + "offset": 0, + "date": "28 de jun. de 2025", + "dateTime": "28 de jun. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1752046400, + "offset": 0, + "date": "9 de jul. de 2025", + "dateTime": "9 de jul. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1753661100, + "offset": 0, + "date": "28 de jul. de 2025", + "dateTime": "28 de jul. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1754724800, + "offset": 0, + "date": "9 de ago. de 2025", + "dateTime": "9 de ago. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1756339500, + "offset": 0, + "date": "28 de ago. de 2025", + "dateTime": "28 de ago. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1757403200, + "offset": 0, + "date": "9 de set. de 2025", + "dateTime": "9 de set. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1759017900, + "offset": 0, + "date": "28 de set. de 2025", + "dateTime": "28 de set. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1759995200, + "offset": 0, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1761609900, + "offset": 0, + "date": "28 de out. de 2025", + "dateTime": "28 de out. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1762673600, + "offset": 0, + "date": "9 de nov. de 2025", + "dateTime": "9 de nov. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1764288300, + "offset": 0, + "date": "28 de nov. de 2025", + "dateTime": "28 de nov. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1765265600, + "offset": 0, + "date": "9 de dez. de 2025", + "dateTime": "9 de dez. de 2025, 07:33" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1766880300, + "offset": 0, + "date": "28 de dez. de 2025", + "dateTime": "28 de dez. de 2025, 00:05" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1709208000, + "offset": 0, + "date": "29 de fev. de 2024", + "dateTime": "29 de fev. de 2024, 12:00" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1704067199, + "offset": 0, + "date": "31 de dez. de 2023", + "dateTime": "31 de dez. de 2023, 23:59" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 915196020, + "offset": 0, + "date": "1 de jan. de 1999", + "dateTime": "1 de jan. de 1999, 13:07" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1760000000, + "offset": 0, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 08:53" + }, + { + "zone": "UTC", + "locale": "pt-br", + "stamp": 1791000000, + "offset": 0, + "date": "3 de out. de 2026", + "dateTime": "3 de out. de 2026, 04:00" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1736408000, + "offset": 0, + "date": "9 янв. 2025 г.", + "dateTime": "9 янв. 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1738022700, + "offset": 0, + "date": "28 янв. 2025 г.", + "dateTime": "28 янв. 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1739086400, + "offset": 0, + "date": "9 февр. 2025 г.", + "dateTime": "9 февр. 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1740701100, + "offset": 0, + "date": "28 февр. 2025 г.", + "dateTime": "28 февр. 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1741505600, + "offset": 0, + "date": "9 мар. 2025 г.", + "dateTime": "9 мар. 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1743120300, + "offset": 0, + "date": "28 мар. 2025 г.", + "dateTime": "28 мар. 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1744184000, + "offset": 0, + "date": "9 апр. 2025 г.", + "dateTime": "9 апр. 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1745798700, + "offset": 0, + "date": "28 апр. 2025 г.", + "dateTime": "28 апр. 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1746776000, + "offset": 0, + "date": "9 мая 2025 г.", + "dateTime": "9 мая 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1748390700, + "offset": 0, + "date": "28 мая 2025 г.", + "dateTime": "28 мая 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1749454400, + "offset": 0, + "date": "9 июн. 2025 г.", + "dateTime": "9 июн. 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1751069100, + "offset": 0, + "date": "28 июн. 2025 г.", + "dateTime": "28 июн. 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1752046400, + "offset": 0, + "date": "9 июл. 2025 г.", + "dateTime": "9 июл. 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1753661100, + "offset": 0, + "date": "28 июл. 2025 г.", + "dateTime": "28 июл. 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1754724800, + "offset": 0, + "date": "9 авг. 2025 г.", + "dateTime": "9 авг. 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1756339500, + "offset": 0, + "date": "28 авг. 2025 г.", + "dateTime": "28 авг. 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1757403200, + "offset": 0, + "date": "9 сент. 2025 г.", + "dateTime": "9 сент. 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1759017900, + "offset": 0, + "date": "28 сент. 2025 г.", + "dateTime": "28 сент. 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1759995200, + "offset": 0, + "date": "9 окт. 2025 г.", + "dateTime": "9 окт. 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1761609900, + "offset": 0, + "date": "28 окт. 2025 г.", + "dateTime": "28 окт. 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1762673600, + "offset": 0, + "date": "9 нояб. 2025 г.", + "dateTime": "9 нояб. 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1764288300, + "offset": 0, + "date": "28 нояб. 2025 г.", + "dateTime": "28 нояб. 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1765265600, + "offset": 0, + "date": "9 дек. 2025 г.", + "dateTime": "9 дек. 2025 г., 07:33" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1766880300, + "offset": 0, + "date": "28 дек. 2025 г.", + "dateTime": "28 дек. 2025 г., 00:05" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1709208000, + "offset": 0, + "date": "29 февр. 2024 г.", + "dateTime": "29 февр. 2024 г., 12:00" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1704067199, + "offset": 0, + "date": "31 дек. 2023 г.", + "dateTime": "31 дек. 2023 г., 23:59" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 915196020, + "offset": 0, + "date": "1 янв. 1999 г.", + "dateTime": "1 янв. 1999 г., 13:07" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1760000000, + "offset": 0, + "date": "9 окт. 2025 г.", + "dateTime": "9 окт. 2025 г., 08:53" + }, + { + "zone": "UTC", + "locale": "ru", + "stamp": 1791000000, + "offset": 0, + "date": "3 окт. 2026 г.", + "dateTime": "3 окт. 2026 г., 04:00" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1736408000, + "offset": 0, + "date": "2025年1月9日", + "dateTime": "2025年1月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1738022700, + "offset": 0, + "date": "2025年1月28日", + "dateTime": "2025年1月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1739086400, + "offset": 0, + "date": "2025年2月9日", + "dateTime": "2025年2月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1740701100, + "offset": 0, + "date": "2025年2月28日", + "dateTime": "2025年2月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1741505600, + "offset": 0, + "date": "2025年3月9日", + "dateTime": "2025年3月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1743120300, + "offset": 0, + "date": "2025年3月28日", + "dateTime": "2025年3月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1744184000, + "offset": 0, + "date": "2025年4月9日", + "dateTime": "2025年4月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1745798700, + "offset": 0, + "date": "2025年4月28日", + "dateTime": "2025年4月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1746776000, + "offset": 0, + "date": "2025年5月9日", + "dateTime": "2025年5月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1748390700, + "offset": 0, + "date": "2025年5月28日", + "dateTime": "2025年5月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1749454400, + "offset": 0, + "date": "2025年6月9日", + "dateTime": "2025年6月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1751069100, + "offset": 0, + "date": "2025年6月28日", + "dateTime": "2025年6月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1752046400, + "offset": 0, + "date": "2025年7月9日", + "dateTime": "2025年7月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1753661100, + "offset": 0, + "date": "2025年7月28日", + "dateTime": "2025年7月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1754724800, + "offset": 0, + "date": "2025年8月9日", + "dateTime": "2025年8月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1756339500, + "offset": 0, + "date": "2025年8月28日", + "dateTime": "2025年8月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1757403200, + "offset": 0, + "date": "2025年9月9日", + "dateTime": "2025年9月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1759017900, + "offset": 0, + "date": "2025年9月28日", + "dateTime": "2025年9月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1759995200, + "offset": 0, + "date": "2025年10月9日", + "dateTime": "2025年10月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1761609900, + "offset": 0, + "date": "2025年10月28日", + "dateTime": "2025年10月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1762673600, + "offset": 0, + "date": "2025年11月9日", + "dateTime": "2025年11月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1764288300, + "offset": 0, + "date": "2025年11月28日", + "dateTime": "2025年11月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1765265600, + "offset": 0, + "date": "2025年12月9日", + "dateTime": "2025年12月9日 07:33" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1766880300, + "offset": 0, + "date": "2025年12月28日", + "dateTime": "2025年12月28日 00:05" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1709208000, + "offset": 0, + "date": "2024年2月29日", + "dateTime": "2024年2月29日 12:00" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1704067199, + "offset": 0, + "date": "2023年12月31日", + "dateTime": "2023年12月31日 23:59" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 915196020, + "offset": 0, + "date": "1999年1月1日", + "dateTime": "1999年1月1日 13:07" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1760000000, + "offset": 0, + "date": "2025年10月9日", + "dateTime": "2025年10月9日 08:53" + }, + { + "zone": "UTC", + "locale": "zh", + "stamp": 1791000000, + "offset": 0, + "date": "2026年10月3日", + "dateTime": "2026年10月3日 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1736408000, + "offset": 60, + "date": "Jan 9, 2025", + "dateTime": "Jan 9, 2025, 08:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1738022700, + "offset": 60, + "date": "Jan 28, 2025", + "dateTime": "Jan 28, 2025, 01:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1739086400, + "offset": 60, + "date": "Feb 9, 2025", + "dateTime": "Feb 9, 2025, 08:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1740701100, + "offset": 60, + "date": "Feb 28, 2025", + "dateTime": "Feb 28, 2025, 01:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1741505600, + "offset": 60, + "date": "Mar 9, 2025", + "dateTime": "Mar 9, 2025, 08:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1743120300, + "offset": 60, + "date": "Mar 28, 2025", + "dateTime": "Mar 28, 2025, 01:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1744184000, + "offset": 120, + "date": "Apr 9, 2025", + "dateTime": "Apr 9, 2025, 09:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1745798700, + "offset": 120, + "date": "Apr 28, 2025", + "dateTime": "Apr 28, 2025, 02:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1746776000, + "offset": 120, + "date": "May 9, 2025", + "dateTime": "May 9, 2025, 09:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1748390700, + "offset": 120, + "date": "May 28, 2025", + "dateTime": "May 28, 2025, 02:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1749454400, + "offset": 120, + "date": "Jun 9, 2025", + "dateTime": "Jun 9, 2025, 09:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1751069100, + "offset": 120, + "date": "Jun 28, 2025", + "dateTime": "Jun 28, 2025, 02:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1752046400, + "offset": 120, + "date": "Jul 9, 2025", + "dateTime": "Jul 9, 2025, 09:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1753661100, + "offset": 120, + "date": "Jul 28, 2025", + "dateTime": "Jul 28, 2025, 02:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1754724800, + "offset": 120, + "date": "Aug 9, 2025", + "dateTime": "Aug 9, 2025, 09:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1756339500, + "offset": 120, + "date": "Aug 28, 2025", + "dateTime": "Aug 28, 2025, 02:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1757403200, + "offset": 120, + "date": "Sep 9, 2025", + "dateTime": "Sep 9, 2025, 09:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1759017900, + "offset": 120, + "date": "Sep 28, 2025", + "dateTime": "Sep 28, 2025, 02:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1759995200, + "offset": 120, + "date": "Oct 9, 2025", + "dateTime": "Oct 9, 2025, 09:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1761609900, + "offset": 60, + "date": "Oct 28, 2025", + "dateTime": "Oct 28, 2025, 01:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1762673600, + "offset": 60, + "date": "Nov 9, 2025", + "dateTime": "Nov 9, 2025, 08:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1764288300, + "offset": 60, + "date": "Nov 28, 2025", + "dateTime": "Nov 28, 2025, 01:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1765265600, + "offset": 60, + "date": "Dec 9, 2025", + "dateTime": "Dec 9, 2025, 08:33 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1766880300, + "offset": 60, + "date": "Dec 28, 2025", + "dateTime": "Dec 28, 2025, 01:05 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1709208000, + "offset": 60, + "date": "Feb 29, 2024", + "dateTime": "Feb 29, 2024, 01:00 PM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1704067199, + "offset": 60, + "date": "Jan 1, 2024", + "dateTime": "Jan 1, 2024, 12:59 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 915196020, + "offset": 60, + "date": "Jan 1, 1999", + "dateTime": "Jan 1, 1999, 02:07 PM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1760000000, + "offset": 120, + "date": "Oct 9, 2025", + "dateTime": "Oct 9, 2025, 10:53 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1791000000, + "offset": 120, + "date": "Oct 3, 2026", + "dateTime": "Oct 3, 2026, 06:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1743292800, + "offset": 60, + "date": "Mar 30, 2025", + "dateTime": "Mar 30, 2025, 01:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1743296399, + "offset": 60, + "date": "Mar 30, 2025", + "dateTime": "Mar 30, 2025, 01:59 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1743296400, + "offset": 120, + "date": "Mar 30, 2025", + "dateTime": "Mar 30, 2025, 03:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1743298200, + "offset": 120, + "date": "Mar 30, 2025", + "dateTime": "Mar 30, 2025, 03:30 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1743300000, + "offset": 120, + "date": "Mar 30, 2025", + "dateTime": "Mar 30, 2025, 04:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1761436800, + "offset": 120, + "date": "Oct 26, 2025", + "dateTime": "Oct 26, 2025, 02:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1761440399, + "offset": 120, + "date": "Oct 26, 2025", + "dateTime": "Oct 26, 2025, 02:59 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1761440400, + "offset": 60, + "date": "Oct 26, 2025", + "dateTime": "Oct 26, 2025, 02:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1761442200, + "offset": 60, + "date": "Oct 26, 2025", + "dateTime": "Oct 26, 2025, 02:30 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1761444000, + "offset": 60, + "date": "Oct 26, 2025", + "dateTime": "Oct 26, 2025, 03:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1774742400, + "offset": 60, + "date": "Mar 29, 2026", + "dateTime": "Mar 29, 2026, 01:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1774745999, + "offset": 60, + "date": "Mar 29, 2026", + "dateTime": "Mar 29, 2026, 01:59 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1774746000, + "offset": 120, + "date": "Mar 29, 2026", + "dateTime": "Mar 29, 2026, 03:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1774747800, + "offset": 120, + "date": "Mar 29, 2026", + "dateTime": "Mar 29, 2026, 03:30 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1774749600, + "offset": 120, + "date": "Mar 29, 2026", + "dateTime": "Mar 29, 2026, 04:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1792886400, + "offset": 120, + "date": "Oct 25, 2026", + "dateTime": "Oct 25, 2026, 02:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1792889999, + "offset": 120, + "date": "Oct 25, 2026", + "dateTime": "Oct 25, 2026, 02:59 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1792890000, + "offset": 60, + "date": "Oct 25, 2026", + "dateTime": "Oct 25, 2026, 02:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1792891800, + "offset": 60, + "date": "Oct 25, 2026", + "dateTime": "Oct 25, 2026, 02:30 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 1792893600, + "offset": 60, + "date": "Oct 25, 2026", + "dateTime": "Oct 25, 2026, 03:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 2216246400, + "offset": 60, + "date": "Mar 25, 2040", + "dateTime": "Mar 25, 2040, 01:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 2216249999, + "offset": 60, + "date": "Mar 25, 2040", + "dateTime": "Mar 25, 2040, 01:59 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 2216250000, + "offset": 120, + "date": "Mar 25, 2040", + "dateTime": "Mar 25, 2040, 03:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 2216251800, + "offset": 120, + "date": "Mar 25, 2040", + "dateTime": "Mar 25, 2040, 03:30 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 2216253600, + "offset": 120, + "date": "Mar 25, 2040", + "dateTime": "Mar 25, 2040, 04:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 2234995200, + "offset": 120, + "date": "Oct 28, 2040", + "dateTime": "Oct 28, 2040, 02:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 2234998799, + "offset": 120, + "date": "Oct 28, 2040", + "dateTime": "Oct 28, 2040, 02:59 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 2234998800, + "offset": 60, + "date": "Oct 28, 2040", + "dateTime": "Oct 28, 2040, 02:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 2235000600, + "offset": 60, + "date": "Oct 28, 2040", + "dateTime": "Oct 28, 2040, 02:30 AM" + }, + { + "zone": "Europe/Paris", + "locale": "en", + "stamp": 2235002400, + "offset": 60, + "date": "Oct 28, 2040", + "dateTime": "Oct 28, 2040, 03:00 AM" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1736408000, + "offset": 60, + "date": "9 ene 2025", + "dateTime": "9 ene 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1738022700, + "offset": 60, + "date": "28 ene 2025", + "dateTime": "28 ene 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1739086400, + "offset": 60, + "date": "9 feb 2025", + "dateTime": "9 feb 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1740701100, + "offset": 60, + "date": "28 feb 2025", + "dateTime": "28 feb 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1741505600, + "offset": 60, + "date": "9 mar 2025", + "dateTime": "9 mar 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1743120300, + "offset": 60, + "date": "28 mar 2025", + "dateTime": "28 mar 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1744184000, + "offset": 120, + "date": "9 abr 2025", + "dateTime": "9 abr 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1745798700, + "offset": 120, + "date": "28 abr 2025", + "dateTime": "28 abr 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1746776000, + "offset": 120, + "date": "9 may 2025", + "dateTime": "9 may 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1748390700, + "offset": 120, + "date": "28 may 2025", + "dateTime": "28 may 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1749454400, + "offset": 120, + "date": "9 jun 2025", + "dateTime": "9 jun 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1751069100, + "offset": 120, + "date": "28 jun 2025", + "dateTime": "28 jun 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1752046400, + "offset": 120, + "date": "9 jul 2025", + "dateTime": "9 jul 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1753661100, + "offset": 120, + "date": "28 jul 2025", + "dateTime": "28 jul 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1754724800, + "offset": 120, + "date": "9 ago 2025", + "dateTime": "9 ago 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1756339500, + "offset": 120, + "date": "28 ago 2025", + "dateTime": "28 ago 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1757403200, + "offset": 120, + "date": "9 sept 2025", + "dateTime": "9 sept 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1759017900, + "offset": 120, + "date": "28 sept 2025", + "dateTime": "28 sept 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1759995200, + "offset": 120, + "date": "9 oct 2025", + "dateTime": "9 oct 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1761609900, + "offset": 60, + "date": "28 oct 2025", + "dateTime": "28 oct 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1762673600, + "offset": 60, + "date": "9 nov 2025", + "dateTime": "9 nov 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1764288300, + "offset": 60, + "date": "28 nov 2025", + "dateTime": "28 nov 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1765265600, + "offset": 60, + "date": "9 dic 2025", + "dateTime": "9 dic 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1766880300, + "offset": 60, + "date": "28 dic 2025", + "dateTime": "28 dic 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1709208000, + "offset": 60, + "date": "29 feb 2024", + "dateTime": "29 feb 2024, 13:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1704067199, + "offset": 60, + "date": "1 ene 2024", + "dateTime": "1 ene 2024, 00:59" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 915196020, + "offset": 60, + "date": "1 ene 1999", + "dateTime": "1 ene 1999, 14:07" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1760000000, + "offset": 120, + "date": "9 oct 2025", + "dateTime": "9 oct 2025, 10:53" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1791000000, + "offset": 120, + "date": "3 oct 2026", + "dateTime": "3 oct 2026, 06:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1743292800, + "offset": 60, + "date": "30 mar 2025", + "dateTime": "30 mar 2025, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1743296399, + "offset": 60, + "date": "30 mar 2025", + "dateTime": "30 mar 2025, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1743296400, + "offset": 120, + "date": "30 mar 2025", + "dateTime": "30 mar 2025, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1743298200, + "offset": 120, + "date": "30 mar 2025", + "dateTime": "30 mar 2025, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1743300000, + "offset": 120, + "date": "30 mar 2025", + "dateTime": "30 mar 2025, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1761436800, + "offset": 120, + "date": "26 oct 2025", + "dateTime": "26 oct 2025, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1761440399, + "offset": 120, + "date": "26 oct 2025", + "dateTime": "26 oct 2025, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1761440400, + "offset": 60, + "date": "26 oct 2025", + "dateTime": "26 oct 2025, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1761442200, + "offset": 60, + "date": "26 oct 2025", + "dateTime": "26 oct 2025, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1761444000, + "offset": 60, + "date": "26 oct 2025", + "dateTime": "26 oct 2025, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1774742400, + "offset": 60, + "date": "29 mar 2026", + "dateTime": "29 mar 2026, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1774745999, + "offset": 60, + "date": "29 mar 2026", + "dateTime": "29 mar 2026, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1774746000, + "offset": 120, + "date": "29 mar 2026", + "dateTime": "29 mar 2026, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1774747800, + "offset": 120, + "date": "29 mar 2026", + "dateTime": "29 mar 2026, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1774749600, + "offset": 120, + "date": "29 mar 2026", + "dateTime": "29 mar 2026, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1792886400, + "offset": 120, + "date": "25 oct 2026", + "dateTime": "25 oct 2026, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1792889999, + "offset": 120, + "date": "25 oct 2026", + "dateTime": "25 oct 2026, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1792890000, + "offset": 60, + "date": "25 oct 2026", + "dateTime": "25 oct 2026, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1792891800, + "offset": 60, + "date": "25 oct 2026", + "dateTime": "25 oct 2026, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 1792893600, + "offset": 60, + "date": "25 oct 2026", + "dateTime": "25 oct 2026, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 2216246400, + "offset": 60, + "date": "25 mar 2040", + "dateTime": "25 mar 2040, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 2216249999, + "offset": 60, + "date": "25 mar 2040", + "dateTime": "25 mar 2040, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 2216250000, + "offset": 120, + "date": "25 mar 2040", + "dateTime": "25 mar 2040, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 2216251800, + "offset": 120, + "date": "25 mar 2040", + "dateTime": "25 mar 2040, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 2216253600, + "offset": 120, + "date": "25 mar 2040", + "dateTime": "25 mar 2040, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 2234995200, + "offset": 120, + "date": "28 oct 2040", + "dateTime": "28 oct 2040, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 2234998799, + "offset": 120, + "date": "28 oct 2040", + "dateTime": "28 oct 2040, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 2234998800, + "offset": 60, + "date": "28 oct 2040", + "dateTime": "28 oct 2040, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 2235000600, + "offset": 60, + "date": "28 oct 2040", + "dateTime": "28 oct 2040, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "es", + "stamp": 2235002400, + "offset": 60, + "date": "28 oct 2040", + "dateTime": "28 oct 2040, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1736408000, + "offset": 60, + "date": "9 janv. 2025", + "dateTime": "9 janv. 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1738022700, + "offset": 60, + "date": "28 janv. 2025", + "dateTime": "28 janv. 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1739086400, + "offset": 60, + "date": "9 févr. 2025", + "dateTime": "9 févr. 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1740701100, + "offset": 60, + "date": "28 févr. 2025", + "dateTime": "28 févr. 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1741505600, + "offset": 60, + "date": "9 mars 2025", + "dateTime": "9 mars 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1743120300, + "offset": 60, + "date": "28 mars 2025", + "dateTime": "28 mars 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1744184000, + "offset": 120, + "date": "9 avr. 2025", + "dateTime": "9 avr. 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1745798700, + "offset": 120, + "date": "28 avr. 2025", + "dateTime": "28 avr. 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1746776000, + "offset": 120, + "date": "9 mai 2025", + "dateTime": "9 mai 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1748390700, + "offset": 120, + "date": "28 mai 2025", + "dateTime": "28 mai 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1749454400, + "offset": 120, + "date": "9 juin 2025", + "dateTime": "9 juin 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1751069100, + "offset": 120, + "date": "28 juin 2025", + "dateTime": "28 juin 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1752046400, + "offset": 120, + "date": "9 juil. 2025", + "dateTime": "9 juil. 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1753661100, + "offset": 120, + "date": "28 juil. 2025", + "dateTime": "28 juil. 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1754724800, + "offset": 120, + "date": "9 août 2025", + "dateTime": "9 août 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1756339500, + "offset": 120, + "date": "28 août 2025", + "dateTime": "28 août 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1757403200, + "offset": 120, + "date": "9 sept. 2025", + "dateTime": "9 sept. 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1759017900, + "offset": 120, + "date": "28 sept. 2025", + "dateTime": "28 sept. 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1759995200, + "offset": 120, + "date": "9 oct. 2025", + "dateTime": "9 oct. 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1761609900, + "offset": 60, + "date": "28 oct. 2025", + "dateTime": "28 oct. 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1762673600, + "offset": 60, + "date": "9 nov. 2025", + "dateTime": "9 nov. 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1764288300, + "offset": 60, + "date": "28 nov. 2025", + "dateTime": "28 nov. 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1765265600, + "offset": 60, + "date": "9 déc. 2025", + "dateTime": "9 déc. 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1766880300, + "offset": 60, + "date": "28 déc. 2025", + "dateTime": "28 déc. 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1709208000, + "offset": 60, + "date": "29 févr. 2024", + "dateTime": "29 févr. 2024, 13:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1704067199, + "offset": 60, + "date": "1 janv. 2024", + "dateTime": "1 janv. 2024, 00:59" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 915196020, + "offset": 60, + "date": "1 janv. 1999", + "dateTime": "1 janv. 1999, 14:07" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1760000000, + "offset": 120, + "date": "9 oct. 2025", + "dateTime": "9 oct. 2025, 10:53" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1791000000, + "offset": 120, + "date": "3 oct. 2026", + "dateTime": "3 oct. 2026, 06:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1743292800, + "offset": 60, + "date": "30 mars 2025", + "dateTime": "30 mars 2025, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1743296399, + "offset": 60, + "date": "30 mars 2025", + "dateTime": "30 mars 2025, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1743296400, + "offset": 120, + "date": "30 mars 2025", + "dateTime": "30 mars 2025, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1743298200, + "offset": 120, + "date": "30 mars 2025", + "dateTime": "30 mars 2025, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1743300000, + "offset": 120, + "date": "30 mars 2025", + "dateTime": "30 mars 2025, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1761436800, + "offset": 120, + "date": "26 oct. 2025", + "dateTime": "26 oct. 2025, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1761440399, + "offset": 120, + "date": "26 oct. 2025", + "dateTime": "26 oct. 2025, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1761440400, + "offset": 60, + "date": "26 oct. 2025", + "dateTime": "26 oct. 2025, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1761442200, + "offset": 60, + "date": "26 oct. 2025", + "dateTime": "26 oct. 2025, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1761444000, + "offset": 60, + "date": "26 oct. 2025", + "dateTime": "26 oct. 2025, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1774742400, + "offset": 60, + "date": "29 mars 2026", + "dateTime": "29 mars 2026, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1774745999, + "offset": 60, + "date": "29 mars 2026", + "dateTime": "29 mars 2026, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1774746000, + "offset": 120, + "date": "29 mars 2026", + "dateTime": "29 mars 2026, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1774747800, + "offset": 120, + "date": "29 mars 2026", + "dateTime": "29 mars 2026, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1774749600, + "offset": 120, + "date": "29 mars 2026", + "dateTime": "29 mars 2026, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1792886400, + "offset": 120, + "date": "25 oct. 2026", + "dateTime": "25 oct. 2026, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1792889999, + "offset": 120, + "date": "25 oct. 2026", + "dateTime": "25 oct. 2026, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1792890000, + "offset": 60, + "date": "25 oct. 2026", + "dateTime": "25 oct. 2026, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1792891800, + "offset": 60, + "date": "25 oct. 2026", + "dateTime": "25 oct. 2026, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 1792893600, + "offset": 60, + "date": "25 oct. 2026", + "dateTime": "25 oct. 2026, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 2216246400, + "offset": 60, + "date": "25 mars 2040", + "dateTime": "25 mars 2040, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 2216249999, + "offset": 60, + "date": "25 mars 2040", + "dateTime": "25 mars 2040, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 2216250000, + "offset": 120, + "date": "25 mars 2040", + "dateTime": "25 mars 2040, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 2216251800, + "offset": 120, + "date": "25 mars 2040", + "dateTime": "25 mars 2040, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 2216253600, + "offset": 120, + "date": "25 mars 2040", + "dateTime": "25 mars 2040, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 2234995200, + "offset": 120, + "date": "28 oct. 2040", + "dateTime": "28 oct. 2040, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 2234998799, + "offset": 120, + "date": "28 oct. 2040", + "dateTime": "28 oct. 2040, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 2234998800, + "offset": 60, + "date": "28 oct. 2040", + "dateTime": "28 oct. 2040, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 2235000600, + "offset": 60, + "date": "28 oct. 2040", + "dateTime": "28 oct. 2040, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "fr", + "stamp": 2235002400, + "offset": 60, + "date": "28 oct. 2040", + "dateTime": "28 oct. 2040, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1736408000, + "offset": 60, + "date": "9 de jan. de 2025", + "dateTime": "9 de jan. de 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1738022700, + "offset": 60, + "date": "28 de jan. de 2025", + "dateTime": "28 de jan. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1739086400, + "offset": 60, + "date": "9 de fev. de 2025", + "dateTime": "9 de fev. de 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1740701100, + "offset": 60, + "date": "28 de fev. de 2025", + "dateTime": "28 de fev. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1741505600, + "offset": 60, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1743120300, + "offset": 60, + "date": "28 de mar. de 2025", + "dateTime": "28 de mar. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1744184000, + "offset": 120, + "date": "9 de abr. de 2025", + "dateTime": "9 de abr. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1745798700, + "offset": 120, + "date": "28 de abr. de 2025", + "dateTime": "28 de abr. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1746776000, + "offset": 120, + "date": "9 de mai. de 2025", + "dateTime": "9 de mai. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1748390700, + "offset": 120, + "date": "28 de mai. de 2025", + "dateTime": "28 de mai. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1749454400, + "offset": 120, + "date": "9 de jun. de 2025", + "dateTime": "9 de jun. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1751069100, + "offset": 120, + "date": "28 de jun. de 2025", + "dateTime": "28 de jun. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1752046400, + "offset": 120, + "date": "9 de jul. de 2025", + "dateTime": "9 de jul. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1753661100, + "offset": 120, + "date": "28 de jul. de 2025", + "dateTime": "28 de jul. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1754724800, + "offset": 120, + "date": "9 de ago. de 2025", + "dateTime": "9 de ago. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1756339500, + "offset": 120, + "date": "28 de ago. de 2025", + "dateTime": "28 de ago. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1757403200, + "offset": 120, + "date": "9 de set. de 2025", + "dateTime": "9 de set. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1759017900, + "offset": 120, + "date": "28 de set. de 2025", + "dateTime": "28 de set. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1759995200, + "offset": 120, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1761609900, + "offset": 60, + "date": "28 de out. de 2025", + "dateTime": "28 de out. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1762673600, + "offset": 60, + "date": "9 de nov. de 2025", + "dateTime": "9 de nov. de 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1764288300, + "offset": 60, + "date": "28 de nov. de 2025", + "dateTime": "28 de nov. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1765265600, + "offset": 60, + "date": "9 de dez. de 2025", + "dateTime": "9 de dez. de 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1766880300, + "offset": 60, + "date": "28 de dez. de 2025", + "dateTime": "28 de dez. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1709208000, + "offset": 60, + "date": "29 de fev. de 2024", + "dateTime": "29 de fev. de 2024, 13:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1704067199, + "offset": 60, + "date": "1 de jan. de 2024", + "dateTime": "1 de jan. de 2024, 00:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 915196020, + "offset": 60, + "date": "1 de jan. de 1999", + "dateTime": "1 de jan. de 1999, 14:07" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1760000000, + "offset": 120, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 10:53" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1791000000, + "offset": 120, + "date": "3 de out. de 2026", + "dateTime": "3 de out. de 2026, 06:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1743292800, + "offset": 60, + "date": "30 de mar. de 2025", + "dateTime": "30 de mar. de 2025, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1743296399, + "offset": 60, + "date": "30 de mar. de 2025", + "dateTime": "30 de mar. de 2025, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1743296400, + "offset": 120, + "date": "30 de mar. de 2025", + "dateTime": "30 de mar. de 2025, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1743298200, + "offset": 120, + "date": "30 de mar. de 2025", + "dateTime": "30 de mar. de 2025, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1743300000, + "offset": 120, + "date": "30 de mar. de 2025", + "dateTime": "30 de mar. de 2025, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1761436800, + "offset": 120, + "date": "26 de out. de 2025", + "dateTime": "26 de out. de 2025, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1761440399, + "offset": 120, + "date": "26 de out. de 2025", + "dateTime": "26 de out. de 2025, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1761440400, + "offset": 60, + "date": "26 de out. de 2025", + "dateTime": "26 de out. de 2025, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1761442200, + "offset": 60, + "date": "26 de out. de 2025", + "dateTime": "26 de out. de 2025, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1761444000, + "offset": 60, + "date": "26 de out. de 2025", + "dateTime": "26 de out. de 2025, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1774742400, + "offset": 60, + "date": "29 de mar. de 2026", + "dateTime": "29 de mar. de 2026, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1774745999, + "offset": 60, + "date": "29 de mar. de 2026", + "dateTime": "29 de mar. de 2026, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1774746000, + "offset": 120, + "date": "29 de mar. de 2026", + "dateTime": "29 de mar. de 2026, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1774747800, + "offset": 120, + "date": "29 de mar. de 2026", + "dateTime": "29 de mar. de 2026, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1774749600, + "offset": 120, + "date": "29 de mar. de 2026", + "dateTime": "29 de mar. de 2026, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1792886400, + "offset": 120, + "date": "25 de out. de 2026", + "dateTime": "25 de out. de 2026, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1792889999, + "offset": 120, + "date": "25 de out. de 2026", + "dateTime": "25 de out. de 2026, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1792890000, + "offset": 60, + "date": "25 de out. de 2026", + "dateTime": "25 de out. de 2026, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1792891800, + "offset": 60, + "date": "25 de out. de 2026", + "dateTime": "25 de out. de 2026, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 1792893600, + "offset": 60, + "date": "25 de out. de 2026", + "dateTime": "25 de out. de 2026, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 2216246400, + "offset": 60, + "date": "25 de mar. de 2040", + "dateTime": "25 de mar. de 2040, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 2216249999, + "offset": 60, + "date": "25 de mar. de 2040", + "dateTime": "25 de mar. de 2040, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 2216250000, + "offset": 120, + "date": "25 de mar. de 2040", + "dateTime": "25 de mar. de 2040, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 2216251800, + "offset": 120, + "date": "25 de mar. de 2040", + "dateTime": "25 de mar. de 2040, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 2216253600, + "offset": 120, + "date": "25 de mar. de 2040", + "dateTime": "25 de mar. de 2040, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 2234995200, + "offset": 120, + "date": "28 de out. de 2040", + "dateTime": "28 de out. de 2040, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 2234998799, + "offset": 120, + "date": "28 de out. de 2040", + "dateTime": "28 de out. de 2040, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 2234998800, + "offset": 60, + "date": "28 de out. de 2040", + "dateTime": "28 de out. de 2040, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 2235000600, + "offset": 60, + "date": "28 de out. de 2040", + "dateTime": "28 de out. de 2040, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt", + "stamp": 2235002400, + "offset": 60, + "date": "28 de out. de 2040", + "dateTime": "28 de out. de 2040, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1736408000, + "offset": 60, + "date": "9 de jan. de 2025", + "dateTime": "9 de jan. de 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1738022700, + "offset": 60, + "date": "28 de jan. de 2025", + "dateTime": "28 de jan. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1739086400, + "offset": 60, + "date": "9 de fev. de 2025", + "dateTime": "9 de fev. de 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1740701100, + "offset": 60, + "date": "28 de fev. de 2025", + "dateTime": "28 de fev. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1741505600, + "offset": 60, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1743120300, + "offset": 60, + "date": "28 de mar. de 2025", + "dateTime": "28 de mar. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1744184000, + "offset": 120, + "date": "9 de abr. de 2025", + "dateTime": "9 de abr. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1745798700, + "offset": 120, + "date": "28 de abr. de 2025", + "dateTime": "28 de abr. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1746776000, + "offset": 120, + "date": "9 de mai. de 2025", + "dateTime": "9 de mai. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1748390700, + "offset": 120, + "date": "28 de mai. de 2025", + "dateTime": "28 de mai. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1749454400, + "offset": 120, + "date": "9 de jun. de 2025", + "dateTime": "9 de jun. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1751069100, + "offset": 120, + "date": "28 de jun. de 2025", + "dateTime": "28 de jun. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1752046400, + "offset": 120, + "date": "9 de jul. de 2025", + "dateTime": "9 de jul. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1753661100, + "offset": 120, + "date": "28 de jul. de 2025", + "dateTime": "28 de jul. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1754724800, + "offset": 120, + "date": "9 de ago. de 2025", + "dateTime": "9 de ago. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1756339500, + "offset": 120, + "date": "28 de ago. de 2025", + "dateTime": "28 de ago. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1757403200, + "offset": 120, + "date": "9 de set. de 2025", + "dateTime": "9 de set. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1759017900, + "offset": 120, + "date": "28 de set. de 2025", + "dateTime": "28 de set. de 2025, 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1759995200, + "offset": 120, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1761609900, + "offset": 60, + "date": "28 de out. de 2025", + "dateTime": "28 de out. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1762673600, + "offset": 60, + "date": "9 de nov. de 2025", + "dateTime": "9 de nov. de 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1764288300, + "offset": 60, + "date": "28 de nov. de 2025", + "dateTime": "28 de nov. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1765265600, + "offset": 60, + "date": "9 de dez. de 2025", + "dateTime": "9 de dez. de 2025, 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1766880300, + "offset": 60, + "date": "28 de dez. de 2025", + "dateTime": "28 de dez. de 2025, 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1709208000, + "offset": 60, + "date": "29 de fev. de 2024", + "dateTime": "29 de fev. de 2024, 13:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1704067199, + "offset": 60, + "date": "1 de jan. de 2024", + "dateTime": "1 de jan. de 2024, 00:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 915196020, + "offset": 60, + "date": "1 de jan. de 1999", + "dateTime": "1 de jan. de 1999, 14:07" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1760000000, + "offset": 120, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 10:53" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1791000000, + "offset": 120, + "date": "3 de out. de 2026", + "dateTime": "3 de out. de 2026, 06:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1743292800, + "offset": 60, + "date": "30 de mar. de 2025", + "dateTime": "30 de mar. de 2025, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1743296399, + "offset": 60, + "date": "30 de mar. de 2025", + "dateTime": "30 de mar. de 2025, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1743296400, + "offset": 120, + "date": "30 de mar. de 2025", + "dateTime": "30 de mar. de 2025, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1743298200, + "offset": 120, + "date": "30 de mar. de 2025", + "dateTime": "30 de mar. de 2025, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1743300000, + "offset": 120, + "date": "30 de mar. de 2025", + "dateTime": "30 de mar. de 2025, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1761436800, + "offset": 120, + "date": "26 de out. de 2025", + "dateTime": "26 de out. de 2025, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1761440399, + "offset": 120, + "date": "26 de out. de 2025", + "dateTime": "26 de out. de 2025, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1761440400, + "offset": 60, + "date": "26 de out. de 2025", + "dateTime": "26 de out. de 2025, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1761442200, + "offset": 60, + "date": "26 de out. de 2025", + "dateTime": "26 de out. de 2025, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1761444000, + "offset": 60, + "date": "26 de out. de 2025", + "dateTime": "26 de out. de 2025, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1774742400, + "offset": 60, + "date": "29 de mar. de 2026", + "dateTime": "29 de mar. de 2026, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1774745999, + "offset": 60, + "date": "29 de mar. de 2026", + "dateTime": "29 de mar. de 2026, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1774746000, + "offset": 120, + "date": "29 de mar. de 2026", + "dateTime": "29 de mar. de 2026, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1774747800, + "offset": 120, + "date": "29 de mar. de 2026", + "dateTime": "29 de mar. de 2026, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1774749600, + "offset": 120, + "date": "29 de mar. de 2026", + "dateTime": "29 de mar. de 2026, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1792886400, + "offset": 120, + "date": "25 de out. de 2026", + "dateTime": "25 de out. de 2026, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1792889999, + "offset": 120, + "date": "25 de out. de 2026", + "dateTime": "25 de out. de 2026, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1792890000, + "offset": 60, + "date": "25 de out. de 2026", + "dateTime": "25 de out. de 2026, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1792891800, + "offset": 60, + "date": "25 de out. de 2026", + "dateTime": "25 de out. de 2026, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 1792893600, + "offset": 60, + "date": "25 de out. de 2026", + "dateTime": "25 de out. de 2026, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 2216246400, + "offset": 60, + "date": "25 de mar. de 2040", + "dateTime": "25 de mar. de 2040, 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 2216249999, + "offset": 60, + "date": "25 de mar. de 2040", + "dateTime": "25 de mar. de 2040, 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 2216250000, + "offset": 120, + "date": "25 de mar. de 2040", + "dateTime": "25 de mar. de 2040, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 2216251800, + "offset": 120, + "date": "25 de mar. de 2040", + "dateTime": "25 de mar. de 2040, 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 2216253600, + "offset": 120, + "date": "25 de mar. de 2040", + "dateTime": "25 de mar. de 2040, 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 2234995200, + "offset": 120, + "date": "28 de out. de 2040", + "dateTime": "28 de out. de 2040, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 2234998799, + "offset": 120, + "date": "28 de out. de 2040", + "dateTime": "28 de out. de 2040, 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 2234998800, + "offset": 60, + "date": "28 de out. de 2040", + "dateTime": "28 de out. de 2040, 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 2235000600, + "offset": 60, + "date": "28 de out. de 2040", + "dateTime": "28 de out. de 2040, 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "pt-br", + "stamp": 2235002400, + "offset": 60, + "date": "28 de out. de 2040", + "dateTime": "28 de out. de 2040, 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1736408000, + "offset": 60, + "date": "9 янв. 2025 г.", + "dateTime": "9 янв. 2025 г., 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1738022700, + "offset": 60, + "date": "28 янв. 2025 г.", + "dateTime": "28 янв. 2025 г., 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1739086400, + "offset": 60, + "date": "9 февр. 2025 г.", + "dateTime": "9 февр. 2025 г., 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1740701100, + "offset": 60, + "date": "28 февр. 2025 г.", + "dateTime": "28 февр. 2025 г., 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1741505600, + "offset": 60, + "date": "9 мар. 2025 г.", + "dateTime": "9 мар. 2025 г., 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1743120300, + "offset": 60, + "date": "28 мар. 2025 г.", + "dateTime": "28 мар. 2025 г., 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1744184000, + "offset": 120, + "date": "9 апр. 2025 г.", + "dateTime": "9 апр. 2025 г., 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1745798700, + "offset": 120, + "date": "28 апр. 2025 г.", + "dateTime": "28 апр. 2025 г., 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1746776000, + "offset": 120, + "date": "9 мая 2025 г.", + "dateTime": "9 мая 2025 г., 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1748390700, + "offset": 120, + "date": "28 мая 2025 г.", + "dateTime": "28 мая 2025 г., 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1749454400, + "offset": 120, + "date": "9 июн. 2025 г.", + "dateTime": "9 июн. 2025 г., 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1751069100, + "offset": 120, + "date": "28 июн. 2025 г.", + "dateTime": "28 июн. 2025 г., 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1752046400, + "offset": 120, + "date": "9 июл. 2025 г.", + "dateTime": "9 июл. 2025 г., 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1753661100, + "offset": 120, + "date": "28 июл. 2025 г.", + "dateTime": "28 июл. 2025 г., 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1754724800, + "offset": 120, + "date": "9 авг. 2025 г.", + "dateTime": "9 авг. 2025 г., 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1756339500, + "offset": 120, + "date": "28 авг. 2025 г.", + "dateTime": "28 авг. 2025 г., 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1757403200, + "offset": 120, + "date": "9 сент. 2025 г.", + "dateTime": "9 сент. 2025 г., 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1759017900, + "offset": 120, + "date": "28 сент. 2025 г.", + "dateTime": "28 сент. 2025 г., 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1759995200, + "offset": 120, + "date": "9 окт. 2025 г.", + "dateTime": "9 окт. 2025 г., 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1761609900, + "offset": 60, + "date": "28 окт. 2025 г.", + "dateTime": "28 окт. 2025 г., 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1762673600, + "offset": 60, + "date": "9 нояб. 2025 г.", + "dateTime": "9 нояб. 2025 г., 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1764288300, + "offset": 60, + "date": "28 нояб. 2025 г.", + "dateTime": "28 нояб. 2025 г., 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1765265600, + "offset": 60, + "date": "9 дек. 2025 г.", + "dateTime": "9 дек. 2025 г., 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1766880300, + "offset": 60, + "date": "28 дек. 2025 г.", + "dateTime": "28 дек. 2025 г., 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1709208000, + "offset": 60, + "date": "29 февр. 2024 г.", + "dateTime": "29 февр. 2024 г., 13:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1704067199, + "offset": 60, + "date": "1 янв. 2024 г.", + "dateTime": "1 янв. 2024 г., 00:59" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 915196020, + "offset": 60, + "date": "1 янв. 1999 г.", + "dateTime": "1 янв. 1999 г., 14:07" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1760000000, + "offset": 120, + "date": "9 окт. 2025 г.", + "dateTime": "9 окт. 2025 г., 10:53" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1791000000, + "offset": 120, + "date": "3 окт. 2026 г.", + "dateTime": "3 окт. 2026 г., 06:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1743292800, + "offset": 60, + "date": "30 мар. 2025 г.", + "dateTime": "30 мар. 2025 г., 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1743296399, + "offset": 60, + "date": "30 мар. 2025 г.", + "dateTime": "30 мар. 2025 г., 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1743296400, + "offset": 120, + "date": "30 мар. 2025 г.", + "dateTime": "30 мар. 2025 г., 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1743298200, + "offset": 120, + "date": "30 мар. 2025 г.", + "dateTime": "30 мар. 2025 г., 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1743300000, + "offset": 120, + "date": "30 мар. 2025 г.", + "dateTime": "30 мар. 2025 г., 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1761436800, + "offset": 120, + "date": "26 окт. 2025 г.", + "dateTime": "26 окт. 2025 г., 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1761440399, + "offset": 120, + "date": "26 окт. 2025 г.", + "dateTime": "26 окт. 2025 г., 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1761440400, + "offset": 60, + "date": "26 окт. 2025 г.", + "dateTime": "26 окт. 2025 г., 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1761442200, + "offset": 60, + "date": "26 окт. 2025 г.", + "dateTime": "26 окт. 2025 г., 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1761444000, + "offset": 60, + "date": "26 окт. 2025 г.", + "dateTime": "26 окт. 2025 г., 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1774742400, + "offset": 60, + "date": "29 мар. 2026 г.", + "dateTime": "29 мар. 2026 г., 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1774745999, + "offset": 60, + "date": "29 мар. 2026 г.", + "dateTime": "29 мар. 2026 г., 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1774746000, + "offset": 120, + "date": "29 мар. 2026 г.", + "dateTime": "29 мар. 2026 г., 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1774747800, + "offset": 120, + "date": "29 мар. 2026 г.", + "dateTime": "29 мар. 2026 г., 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1774749600, + "offset": 120, + "date": "29 мар. 2026 г.", + "dateTime": "29 мар. 2026 г., 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1792886400, + "offset": 120, + "date": "25 окт. 2026 г.", + "dateTime": "25 окт. 2026 г., 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1792889999, + "offset": 120, + "date": "25 окт. 2026 г.", + "dateTime": "25 окт. 2026 г., 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1792890000, + "offset": 60, + "date": "25 окт. 2026 г.", + "dateTime": "25 окт. 2026 г., 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1792891800, + "offset": 60, + "date": "25 окт. 2026 г.", + "dateTime": "25 окт. 2026 г., 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 1792893600, + "offset": 60, + "date": "25 окт. 2026 г.", + "dateTime": "25 окт. 2026 г., 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 2216246400, + "offset": 60, + "date": "25 мар. 2040 г.", + "dateTime": "25 мар. 2040 г., 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 2216249999, + "offset": 60, + "date": "25 мар. 2040 г.", + "dateTime": "25 мар. 2040 г., 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 2216250000, + "offset": 120, + "date": "25 мар. 2040 г.", + "dateTime": "25 мар. 2040 г., 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 2216251800, + "offset": 120, + "date": "25 мар. 2040 г.", + "dateTime": "25 мар. 2040 г., 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 2216253600, + "offset": 120, + "date": "25 мар. 2040 г.", + "dateTime": "25 мар. 2040 г., 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 2234995200, + "offset": 120, + "date": "28 окт. 2040 г.", + "dateTime": "28 окт. 2040 г., 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 2234998799, + "offset": 120, + "date": "28 окт. 2040 г.", + "dateTime": "28 окт. 2040 г., 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 2234998800, + "offset": 60, + "date": "28 окт. 2040 г.", + "dateTime": "28 окт. 2040 г., 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 2235000600, + "offset": 60, + "date": "28 окт. 2040 г.", + "dateTime": "28 окт. 2040 г., 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "ru", + "stamp": 2235002400, + "offset": 60, + "date": "28 окт. 2040 г.", + "dateTime": "28 окт. 2040 г., 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1736408000, + "offset": 60, + "date": "2025年1月9日", + "dateTime": "2025年1月9日 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1738022700, + "offset": 60, + "date": "2025年1月28日", + "dateTime": "2025年1月28日 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1739086400, + "offset": 60, + "date": "2025年2月9日", + "dateTime": "2025年2月9日 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1740701100, + "offset": 60, + "date": "2025年2月28日", + "dateTime": "2025年2月28日 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1741505600, + "offset": 60, + "date": "2025年3月9日", + "dateTime": "2025年3月9日 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1743120300, + "offset": 60, + "date": "2025年3月28日", + "dateTime": "2025年3月28日 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1744184000, + "offset": 120, + "date": "2025年4月9日", + "dateTime": "2025年4月9日 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1745798700, + "offset": 120, + "date": "2025年4月28日", + "dateTime": "2025年4月28日 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1746776000, + "offset": 120, + "date": "2025年5月9日", + "dateTime": "2025年5月9日 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1748390700, + "offset": 120, + "date": "2025年5月28日", + "dateTime": "2025年5月28日 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1749454400, + "offset": 120, + "date": "2025年6月9日", + "dateTime": "2025年6月9日 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1751069100, + "offset": 120, + "date": "2025年6月28日", + "dateTime": "2025年6月28日 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1752046400, + "offset": 120, + "date": "2025年7月9日", + "dateTime": "2025年7月9日 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1753661100, + "offset": 120, + "date": "2025年7月28日", + "dateTime": "2025年7月28日 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1754724800, + "offset": 120, + "date": "2025年8月9日", + "dateTime": "2025年8月9日 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1756339500, + "offset": 120, + "date": "2025年8月28日", + "dateTime": "2025年8月28日 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1757403200, + "offset": 120, + "date": "2025年9月9日", + "dateTime": "2025年9月9日 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1759017900, + "offset": 120, + "date": "2025年9月28日", + "dateTime": "2025年9月28日 02:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1759995200, + "offset": 120, + "date": "2025年10月9日", + "dateTime": "2025年10月9日 09:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1761609900, + "offset": 60, + "date": "2025年10月28日", + "dateTime": "2025年10月28日 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1762673600, + "offset": 60, + "date": "2025年11月9日", + "dateTime": "2025年11月9日 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1764288300, + "offset": 60, + "date": "2025年11月28日", + "dateTime": "2025年11月28日 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1765265600, + "offset": 60, + "date": "2025年12月9日", + "dateTime": "2025年12月9日 08:33" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1766880300, + "offset": 60, + "date": "2025年12月28日", + "dateTime": "2025年12月28日 01:05" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1709208000, + "offset": 60, + "date": "2024年2月29日", + "dateTime": "2024年2月29日 13:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1704067199, + "offset": 60, + "date": "2024年1月1日", + "dateTime": "2024年1月1日 00:59" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 915196020, + "offset": 60, + "date": "1999年1月1日", + "dateTime": "1999年1月1日 14:07" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1760000000, + "offset": 120, + "date": "2025年10月9日", + "dateTime": "2025年10月9日 10:53" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1791000000, + "offset": 120, + "date": "2026年10月3日", + "dateTime": "2026年10月3日 06:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1743292800, + "offset": 60, + "date": "2025年3月30日", + "dateTime": "2025年3月30日 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1743296399, + "offset": 60, + "date": "2025年3月30日", + "dateTime": "2025年3月30日 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1743296400, + "offset": 120, + "date": "2025年3月30日", + "dateTime": "2025年3月30日 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1743298200, + "offset": 120, + "date": "2025年3月30日", + "dateTime": "2025年3月30日 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1743300000, + "offset": 120, + "date": "2025年3月30日", + "dateTime": "2025年3月30日 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1761436800, + "offset": 120, + "date": "2025年10月26日", + "dateTime": "2025年10月26日 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1761440399, + "offset": 120, + "date": "2025年10月26日", + "dateTime": "2025年10月26日 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1761440400, + "offset": 60, + "date": "2025年10月26日", + "dateTime": "2025年10月26日 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1761442200, + "offset": 60, + "date": "2025年10月26日", + "dateTime": "2025年10月26日 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1761444000, + "offset": 60, + "date": "2025年10月26日", + "dateTime": "2025年10月26日 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1774742400, + "offset": 60, + "date": "2026年3月29日", + "dateTime": "2026年3月29日 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1774745999, + "offset": 60, + "date": "2026年3月29日", + "dateTime": "2026年3月29日 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1774746000, + "offset": 120, + "date": "2026年3月29日", + "dateTime": "2026年3月29日 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1774747800, + "offset": 120, + "date": "2026年3月29日", + "dateTime": "2026年3月29日 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1774749600, + "offset": 120, + "date": "2026年3月29日", + "dateTime": "2026年3月29日 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1792886400, + "offset": 120, + "date": "2026年10月25日", + "dateTime": "2026年10月25日 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1792889999, + "offset": 120, + "date": "2026年10月25日", + "dateTime": "2026年10月25日 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1792890000, + "offset": 60, + "date": "2026年10月25日", + "dateTime": "2026年10月25日 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1792891800, + "offset": 60, + "date": "2026年10月25日", + "dateTime": "2026年10月25日 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 1792893600, + "offset": 60, + "date": "2026年10月25日", + "dateTime": "2026年10月25日 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 2216246400, + "offset": 60, + "date": "2040年3月25日", + "dateTime": "2040年3月25日 01:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 2216249999, + "offset": 60, + "date": "2040年3月25日", + "dateTime": "2040年3月25日 01:59" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 2216250000, + "offset": 120, + "date": "2040年3月25日", + "dateTime": "2040年3月25日 03:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 2216251800, + "offset": 120, + "date": "2040年3月25日", + "dateTime": "2040年3月25日 03:30" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 2216253600, + "offset": 120, + "date": "2040年3月25日", + "dateTime": "2040年3月25日 04:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 2234995200, + "offset": 120, + "date": "2040年10月28日", + "dateTime": "2040年10月28日 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 2234998799, + "offset": 120, + "date": "2040年10月28日", + "dateTime": "2040年10月28日 02:59" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 2234998800, + "offset": 60, + "date": "2040年10月28日", + "dateTime": "2040年10月28日 02:00" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 2235000600, + "offset": 60, + "date": "2040年10月28日", + "dateTime": "2040年10月28日 02:30" + }, + { + "zone": "Europe/Paris", + "locale": "zh", + "stamp": 2235002400, + "offset": 60, + "date": "2040年10月28日", + "dateTime": "2040年10月28日 03:00" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1736408000, + "offset": -300, + "date": "Jan 9, 2025", + "dateTime": "Jan 9, 2025, 02:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1738022700, + "offset": -300, + "date": "Jan 27, 2025", + "dateTime": "Jan 27, 2025, 07:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1739086400, + "offset": -300, + "date": "Feb 9, 2025", + "dateTime": "Feb 9, 2025, 02:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1740701100, + "offset": -300, + "date": "Feb 27, 2025", + "dateTime": "Feb 27, 2025, 07:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1741505600, + "offset": -240, + "date": "Mar 9, 2025", + "dateTime": "Mar 9, 2025, 03:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1743120300, + "offset": -240, + "date": "Mar 27, 2025", + "dateTime": "Mar 27, 2025, 08:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1744184000, + "offset": -240, + "date": "Apr 9, 2025", + "dateTime": "Apr 9, 2025, 03:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1745798700, + "offset": -240, + "date": "Apr 27, 2025", + "dateTime": "Apr 27, 2025, 08:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1746776000, + "offset": -240, + "date": "May 9, 2025", + "dateTime": "May 9, 2025, 03:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1748390700, + "offset": -240, + "date": "May 27, 2025", + "dateTime": "May 27, 2025, 08:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1749454400, + "offset": -240, + "date": "Jun 9, 2025", + "dateTime": "Jun 9, 2025, 03:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1751069100, + "offset": -240, + "date": "Jun 27, 2025", + "dateTime": "Jun 27, 2025, 08:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1752046400, + "offset": -240, + "date": "Jul 9, 2025", + "dateTime": "Jul 9, 2025, 03:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1753661100, + "offset": -240, + "date": "Jul 27, 2025", + "dateTime": "Jul 27, 2025, 08:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1754724800, + "offset": -240, + "date": "Aug 9, 2025", + "dateTime": "Aug 9, 2025, 03:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1756339500, + "offset": -240, + "date": "Aug 27, 2025", + "dateTime": "Aug 27, 2025, 08:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1757403200, + "offset": -240, + "date": "Sep 9, 2025", + "dateTime": "Sep 9, 2025, 03:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1759017900, + "offset": -240, + "date": "Sep 27, 2025", + "dateTime": "Sep 27, 2025, 08:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1759995200, + "offset": -240, + "date": "Oct 9, 2025", + "dateTime": "Oct 9, 2025, 03:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1761609900, + "offset": -240, + "date": "Oct 27, 2025", + "dateTime": "Oct 27, 2025, 08:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1762673600, + "offset": -300, + "date": "Nov 9, 2025", + "dateTime": "Nov 9, 2025, 02:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1764288300, + "offset": -300, + "date": "Nov 27, 2025", + "dateTime": "Nov 27, 2025, 07:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1765265600, + "offset": -300, + "date": "Dec 9, 2025", + "dateTime": "Dec 9, 2025, 02:33 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1766880300, + "offset": -300, + "date": "Dec 27, 2025", + "dateTime": "Dec 27, 2025, 07:05 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1709208000, + "offset": -300, + "date": "Feb 29, 2024", + "dateTime": "Feb 29, 2024, 07:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1704067199, + "offset": -300, + "date": "Dec 31, 2023", + "dateTime": "Dec 31, 2023, 06:59 PM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 915196020, + "offset": -300, + "date": "Jan 1, 1999", + "dateTime": "Jan 1, 1999, 08:07 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1760000000, + "offset": -240, + "date": "Oct 9, 2025", + "dateTime": "Oct 9, 2025, 04:53 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1791000000, + "offset": -240, + "date": "Oct 3, 2026", + "dateTime": "Oct 3, 2026, 12:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1741500000, + "offset": -300, + "date": "Mar 9, 2025", + "dateTime": "Mar 9, 2025, 01:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1741503599, + "offset": -300, + "date": "Mar 9, 2025", + "dateTime": "Mar 9, 2025, 01:59 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1741503600, + "offset": -240, + "date": "Mar 9, 2025", + "dateTime": "Mar 9, 2025, 03:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1741505400, + "offset": -240, + "date": "Mar 9, 2025", + "dateTime": "Mar 9, 2025, 03:30 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1741507200, + "offset": -240, + "date": "Mar 9, 2025", + "dateTime": "Mar 9, 2025, 04:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1762059600, + "offset": -240, + "date": "Nov 2, 2025", + "dateTime": "Nov 2, 2025, 01:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1762063199, + "offset": -240, + "date": "Nov 2, 2025", + "dateTime": "Nov 2, 2025, 01:59 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1762063200, + "offset": -300, + "date": "Nov 2, 2025", + "dateTime": "Nov 2, 2025, 01:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1762065000, + "offset": -300, + "date": "Nov 2, 2025", + "dateTime": "Nov 2, 2025, 01:30 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1762066800, + "offset": -300, + "date": "Nov 2, 2025", + "dateTime": "Nov 2, 2025, 02:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1772949600, + "offset": -300, + "date": "Mar 8, 2026", + "dateTime": "Mar 8, 2026, 01:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1772953199, + "offset": -300, + "date": "Mar 8, 2026", + "dateTime": "Mar 8, 2026, 01:59 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1772953200, + "offset": -240, + "date": "Mar 8, 2026", + "dateTime": "Mar 8, 2026, 03:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1772955000, + "offset": -240, + "date": "Mar 8, 2026", + "dateTime": "Mar 8, 2026, 03:30 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1772956800, + "offset": -240, + "date": "Mar 8, 2026", + "dateTime": "Mar 8, 2026, 04:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1793509200, + "offset": -240, + "date": "Nov 1, 2026", + "dateTime": "Nov 1, 2026, 01:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1793512799, + "offset": -240, + "date": "Nov 1, 2026", + "dateTime": "Nov 1, 2026, 01:59 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1793512800, + "offset": -300, + "date": "Nov 1, 2026", + "dateTime": "Nov 1, 2026, 01:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1793514600, + "offset": -300, + "date": "Nov 1, 2026", + "dateTime": "Nov 1, 2026, 01:30 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 1793516400, + "offset": -300, + "date": "Nov 1, 2026", + "dateTime": "Nov 1, 2026, 02:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 2215058400, + "offset": -300, + "date": "Mar 11, 2040", + "dateTime": "Mar 11, 2040, 01:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 2215061999, + "offset": -300, + "date": "Mar 11, 2040", + "dateTime": "Mar 11, 2040, 01:59 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 2215062000, + "offset": -240, + "date": "Mar 11, 2040", + "dateTime": "Mar 11, 2040, 03:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 2215063800, + "offset": -240, + "date": "Mar 11, 2040", + "dateTime": "Mar 11, 2040, 03:30 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 2215065600, + "offset": -240, + "date": "Mar 11, 2040", + "dateTime": "Mar 11, 2040, 04:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 2235618000, + "offset": -240, + "date": "Nov 4, 2040", + "dateTime": "Nov 4, 2040, 01:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 2235621599, + "offset": -240, + "date": "Nov 4, 2040", + "dateTime": "Nov 4, 2040, 01:59 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 2235621600, + "offset": -300, + "date": "Nov 4, 2040", + "dateTime": "Nov 4, 2040, 01:00 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 2235623400, + "offset": -300, + "date": "Nov 4, 2040", + "dateTime": "Nov 4, 2040, 01:30 AM" + }, + { + "zone": "America/New_York", + "locale": "en", + "stamp": 2235625200, + "offset": -300, + "date": "Nov 4, 2040", + "dateTime": "Nov 4, 2040, 02:00 AM" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1736408000, + "offset": -300, + "date": "9 ene 2025", + "dateTime": "9 ene 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1738022700, + "offset": -300, + "date": "27 ene 2025", + "dateTime": "27 ene 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1739086400, + "offset": -300, + "date": "9 feb 2025", + "dateTime": "9 feb 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1740701100, + "offset": -300, + "date": "27 feb 2025", + "dateTime": "27 feb 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1741505600, + "offset": -240, + "date": "9 mar 2025", + "dateTime": "9 mar 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1743120300, + "offset": -240, + "date": "27 mar 2025", + "dateTime": "27 mar 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1744184000, + "offset": -240, + "date": "9 abr 2025", + "dateTime": "9 abr 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1745798700, + "offset": -240, + "date": "27 abr 2025", + "dateTime": "27 abr 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1746776000, + "offset": -240, + "date": "9 may 2025", + "dateTime": "9 may 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1748390700, + "offset": -240, + "date": "27 may 2025", + "dateTime": "27 may 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1749454400, + "offset": -240, + "date": "9 jun 2025", + "dateTime": "9 jun 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1751069100, + "offset": -240, + "date": "27 jun 2025", + "dateTime": "27 jun 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1752046400, + "offset": -240, + "date": "9 jul 2025", + "dateTime": "9 jul 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1753661100, + "offset": -240, + "date": "27 jul 2025", + "dateTime": "27 jul 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1754724800, + "offset": -240, + "date": "9 ago 2025", + "dateTime": "9 ago 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1756339500, + "offset": -240, + "date": "27 ago 2025", + "dateTime": "27 ago 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1757403200, + "offset": -240, + "date": "9 sept 2025", + "dateTime": "9 sept 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1759017900, + "offset": -240, + "date": "27 sept 2025", + "dateTime": "27 sept 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1759995200, + "offset": -240, + "date": "9 oct 2025", + "dateTime": "9 oct 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1761609900, + "offset": -240, + "date": "27 oct 2025", + "dateTime": "27 oct 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1762673600, + "offset": -300, + "date": "9 nov 2025", + "dateTime": "9 nov 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1764288300, + "offset": -300, + "date": "27 nov 2025", + "dateTime": "27 nov 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1765265600, + "offset": -300, + "date": "9 dic 2025", + "dateTime": "9 dic 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1766880300, + "offset": -300, + "date": "27 dic 2025", + "dateTime": "27 dic 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1709208000, + "offset": -300, + "date": "29 feb 2024", + "dateTime": "29 feb 2024, 07:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1704067199, + "offset": -300, + "date": "31 dic 2023", + "dateTime": "31 dic 2023, 18:59" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 915196020, + "offset": -300, + "date": "1 ene 1999", + "dateTime": "1 ene 1999, 08:07" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1760000000, + "offset": -240, + "date": "9 oct 2025", + "dateTime": "9 oct 2025, 04:53" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1791000000, + "offset": -240, + "date": "3 oct 2026", + "dateTime": "3 oct 2026, 00:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1741500000, + "offset": -300, + "date": "9 mar 2025", + "dateTime": "9 mar 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1741503599, + "offset": -300, + "date": "9 mar 2025", + "dateTime": "9 mar 2025, 01:59" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1741503600, + "offset": -240, + "date": "9 mar 2025", + "dateTime": "9 mar 2025, 03:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1741505400, + "offset": -240, + "date": "9 mar 2025", + "dateTime": "9 mar 2025, 03:30" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1741507200, + "offset": -240, + "date": "9 mar 2025", + "dateTime": "9 mar 2025, 04:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1762059600, + "offset": -240, + "date": "2 nov 2025", + "dateTime": "2 nov 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1762063199, + "offset": -240, + "date": "2 nov 2025", + "dateTime": "2 nov 2025, 01:59" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1762063200, + "offset": -300, + "date": "2 nov 2025", + "dateTime": "2 nov 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1762065000, + "offset": -300, + "date": "2 nov 2025", + "dateTime": "2 nov 2025, 01:30" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1762066800, + "offset": -300, + "date": "2 nov 2025", + "dateTime": "2 nov 2025, 02:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1772949600, + "offset": -300, + "date": "8 mar 2026", + "dateTime": "8 mar 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1772953199, + "offset": -300, + "date": "8 mar 2026", + "dateTime": "8 mar 2026, 01:59" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1772953200, + "offset": -240, + "date": "8 mar 2026", + "dateTime": "8 mar 2026, 03:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1772955000, + "offset": -240, + "date": "8 mar 2026", + "dateTime": "8 mar 2026, 03:30" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1772956800, + "offset": -240, + "date": "8 mar 2026", + "dateTime": "8 mar 2026, 04:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1793509200, + "offset": -240, + "date": "1 nov 2026", + "dateTime": "1 nov 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1793512799, + "offset": -240, + "date": "1 nov 2026", + "dateTime": "1 nov 2026, 01:59" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1793512800, + "offset": -300, + "date": "1 nov 2026", + "dateTime": "1 nov 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1793514600, + "offset": -300, + "date": "1 nov 2026", + "dateTime": "1 nov 2026, 01:30" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 1793516400, + "offset": -300, + "date": "1 nov 2026", + "dateTime": "1 nov 2026, 02:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 2215058400, + "offset": -300, + "date": "11 mar 2040", + "dateTime": "11 mar 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 2215061999, + "offset": -300, + "date": "11 mar 2040", + "dateTime": "11 mar 2040, 01:59" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 2215062000, + "offset": -240, + "date": "11 mar 2040", + "dateTime": "11 mar 2040, 03:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 2215063800, + "offset": -240, + "date": "11 mar 2040", + "dateTime": "11 mar 2040, 03:30" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 2215065600, + "offset": -240, + "date": "11 mar 2040", + "dateTime": "11 mar 2040, 04:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 2235618000, + "offset": -240, + "date": "4 nov 2040", + "dateTime": "4 nov 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 2235621599, + "offset": -240, + "date": "4 nov 2040", + "dateTime": "4 nov 2040, 01:59" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 2235621600, + "offset": -300, + "date": "4 nov 2040", + "dateTime": "4 nov 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 2235623400, + "offset": -300, + "date": "4 nov 2040", + "dateTime": "4 nov 2040, 01:30" + }, + { + "zone": "America/New_York", + "locale": "es", + "stamp": 2235625200, + "offset": -300, + "date": "4 nov 2040", + "dateTime": "4 nov 2040, 02:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1736408000, + "offset": -300, + "date": "9 janv. 2025", + "dateTime": "9 janv. 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1738022700, + "offset": -300, + "date": "27 janv. 2025", + "dateTime": "27 janv. 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1739086400, + "offset": -300, + "date": "9 févr. 2025", + "dateTime": "9 févr. 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1740701100, + "offset": -300, + "date": "27 févr. 2025", + "dateTime": "27 févr. 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1741505600, + "offset": -240, + "date": "9 mars 2025", + "dateTime": "9 mars 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1743120300, + "offset": -240, + "date": "27 mars 2025", + "dateTime": "27 mars 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1744184000, + "offset": -240, + "date": "9 avr. 2025", + "dateTime": "9 avr. 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1745798700, + "offset": -240, + "date": "27 avr. 2025", + "dateTime": "27 avr. 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1746776000, + "offset": -240, + "date": "9 mai 2025", + "dateTime": "9 mai 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1748390700, + "offset": -240, + "date": "27 mai 2025", + "dateTime": "27 mai 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1749454400, + "offset": -240, + "date": "9 juin 2025", + "dateTime": "9 juin 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1751069100, + "offset": -240, + "date": "27 juin 2025", + "dateTime": "27 juin 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1752046400, + "offset": -240, + "date": "9 juil. 2025", + "dateTime": "9 juil. 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1753661100, + "offset": -240, + "date": "27 juil. 2025", + "dateTime": "27 juil. 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1754724800, + "offset": -240, + "date": "9 août 2025", + "dateTime": "9 août 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1756339500, + "offset": -240, + "date": "27 août 2025", + "dateTime": "27 août 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1757403200, + "offset": -240, + "date": "9 sept. 2025", + "dateTime": "9 sept. 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1759017900, + "offset": -240, + "date": "27 sept. 2025", + "dateTime": "27 sept. 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1759995200, + "offset": -240, + "date": "9 oct. 2025", + "dateTime": "9 oct. 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1761609900, + "offset": -240, + "date": "27 oct. 2025", + "dateTime": "27 oct. 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1762673600, + "offset": -300, + "date": "9 nov. 2025", + "dateTime": "9 nov. 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1764288300, + "offset": -300, + "date": "27 nov. 2025", + "dateTime": "27 nov. 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1765265600, + "offset": -300, + "date": "9 déc. 2025", + "dateTime": "9 déc. 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1766880300, + "offset": -300, + "date": "27 déc. 2025", + "dateTime": "27 déc. 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1709208000, + "offset": -300, + "date": "29 févr. 2024", + "dateTime": "29 févr. 2024, 07:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1704067199, + "offset": -300, + "date": "31 déc. 2023", + "dateTime": "31 déc. 2023, 18:59" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 915196020, + "offset": -300, + "date": "1 janv. 1999", + "dateTime": "1 janv. 1999, 08:07" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1760000000, + "offset": -240, + "date": "9 oct. 2025", + "dateTime": "9 oct. 2025, 04:53" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1791000000, + "offset": -240, + "date": "3 oct. 2026", + "dateTime": "3 oct. 2026, 00:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1741500000, + "offset": -300, + "date": "9 mars 2025", + "dateTime": "9 mars 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1741503599, + "offset": -300, + "date": "9 mars 2025", + "dateTime": "9 mars 2025, 01:59" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1741503600, + "offset": -240, + "date": "9 mars 2025", + "dateTime": "9 mars 2025, 03:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1741505400, + "offset": -240, + "date": "9 mars 2025", + "dateTime": "9 mars 2025, 03:30" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1741507200, + "offset": -240, + "date": "9 mars 2025", + "dateTime": "9 mars 2025, 04:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1762059600, + "offset": -240, + "date": "2 nov. 2025", + "dateTime": "2 nov. 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1762063199, + "offset": -240, + "date": "2 nov. 2025", + "dateTime": "2 nov. 2025, 01:59" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1762063200, + "offset": -300, + "date": "2 nov. 2025", + "dateTime": "2 nov. 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1762065000, + "offset": -300, + "date": "2 nov. 2025", + "dateTime": "2 nov. 2025, 01:30" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1762066800, + "offset": -300, + "date": "2 nov. 2025", + "dateTime": "2 nov. 2025, 02:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1772949600, + "offset": -300, + "date": "8 mars 2026", + "dateTime": "8 mars 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1772953199, + "offset": -300, + "date": "8 mars 2026", + "dateTime": "8 mars 2026, 01:59" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1772953200, + "offset": -240, + "date": "8 mars 2026", + "dateTime": "8 mars 2026, 03:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1772955000, + "offset": -240, + "date": "8 mars 2026", + "dateTime": "8 mars 2026, 03:30" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1772956800, + "offset": -240, + "date": "8 mars 2026", + "dateTime": "8 mars 2026, 04:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1793509200, + "offset": -240, + "date": "1 nov. 2026", + "dateTime": "1 nov. 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1793512799, + "offset": -240, + "date": "1 nov. 2026", + "dateTime": "1 nov. 2026, 01:59" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1793512800, + "offset": -300, + "date": "1 nov. 2026", + "dateTime": "1 nov. 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1793514600, + "offset": -300, + "date": "1 nov. 2026", + "dateTime": "1 nov. 2026, 01:30" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 1793516400, + "offset": -300, + "date": "1 nov. 2026", + "dateTime": "1 nov. 2026, 02:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 2215058400, + "offset": -300, + "date": "11 mars 2040", + "dateTime": "11 mars 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 2215061999, + "offset": -300, + "date": "11 mars 2040", + "dateTime": "11 mars 2040, 01:59" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 2215062000, + "offset": -240, + "date": "11 mars 2040", + "dateTime": "11 mars 2040, 03:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 2215063800, + "offset": -240, + "date": "11 mars 2040", + "dateTime": "11 mars 2040, 03:30" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 2215065600, + "offset": -240, + "date": "11 mars 2040", + "dateTime": "11 mars 2040, 04:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 2235618000, + "offset": -240, + "date": "4 nov. 2040", + "dateTime": "4 nov. 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 2235621599, + "offset": -240, + "date": "4 nov. 2040", + "dateTime": "4 nov. 2040, 01:59" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 2235621600, + "offset": -300, + "date": "4 nov. 2040", + "dateTime": "4 nov. 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 2235623400, + "offset": -300, + "date": "4 nov. 2040", + "dateTime": "4 nov. 2040, 01:30" + }, + { + "zone": "America/New_York", + "locale": "fr", + "stamp": 2235625200, + "offset": -300, + "date": "4 nov. 2040", + "dateTime": "4 nov. 2040, 02:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1736408000, + "offset": -300, + "date": "9 de jan. de 2025", + "dateTime": "9 de jan. de 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1738022700, + "offset": -300, + "date": "27 de jan. de 2025", + "dateTime": "27 de jan. de 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1739086400, + "offset": -300, + "date": "9 de fev. de 2025", + "dateTime": "9 de fev. de 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1740701100, + "offset": -300, + "date": "27 de fev. de 2025", + "dateTime": "27 de fev. de 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1741505600, + "offset": -240, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1743120300, + "offset": -240, + "date": "27 de mar. de 2025", + "dateTime": "27 de mar. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1744184000, + "offset": -240, + "date": "9 de abr. de 2025", + "dateTime": "9 de abr. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1745798700, + "offset": -240, + "date": "27 de abr. de 2025", + "dateTime": "27 de abr. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1746776000, + "offset": -240, + "date": "9 de mai. de 2025", + "dateTime": "9 de mai. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1748390700, + "offset": -240, + "date": "27 de mai. de 2025", + "dateTime": "27 de mai. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1749454400, + "offset": -240, + "date": "9 de jun. de 2025", + "dateTime": "9 de jun. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1751069100, + "offset": -240, + "date": "27 de jun. de 2025", + "dateTime": "27 de jun. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1752046400, + "offset": -240, + "date": "9 de jul. de 2025", + "dateTime": "9 de jul. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1753661100, + "offset": -240, + "date": "27 de jul. de 2025", + "dateTime": "27 de jul. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1754724800, + "offset": -240, + "date": "9 de ago. de 2025", + "dateTime": "9 de ago. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1756339500, + "offset": -240, + "date": "27 de ago. de 2025", + "dateTime": "27 de ago. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1757403200, + "offset": -240, + "date": "9 de set. de 2025", + "dateTime": "9 de set. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1759017900, + "offset": -240, + "date": "27 de set. de 2025", + "dateTime": "27 de set. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1759995200, + "offset": -240, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1761609900, + "offset": -240, + "date": "27 de out. de 2025", + "dateTime": "27 de out. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1762673600, + "offset": -300, + "date": "9 de nov. de 2025", + "dateTime": "9 de nov. de 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1764288300, + "offset": -300, + "date": "27 de nov. de 2025", + "dateTime": "27 de nov. de 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1765265600, + "offset": -300, + "date": "9 de dez. de 2025", + "dateTime": "9 de dez. de 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1766880300, + "offset": -300, + "date": "27 de dez. de 2025", + "dateTime": "27 de dez. de 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1709208000, + "offset": -300, + "date": "29 de fev. de 2024", + "dateTime": "29 de fev. de 2024, 07:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1704067199, + "offset": -300, + "date": "31 de dez. de 2023", + "dateTime": "31 de dez. de 2023, 18:59" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 915196020, + "offset": -300, + "date": "1 de jan. de 1999", + "dateTime": "1 de jan. de 1999, 08:07" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1760000000, + "offset": -240, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 04:53" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1791000000, + "offset": -240, + "date": "3 de out. de 2026", + "dateTime": "3 de out. de 2026, 00:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1741500000, + "offset": -300, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1741503599, + "offset": -300, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1741503600, + "offset": -240, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 03:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1741505400, + "offset": -240, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 03:30" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1741507200, + "offset": -240, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 04:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1762059600, + "offset": -240, + "date": "2 de nov. de 2025", + "dateTime": "2 de nov. de 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1762063199, + "offset": -240, + "date": "2 de nov. de 2025", + "dateTime": "2 de nov. de 2025, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1762063200, + "offset": -300, + "date": "2 de nov. de 2025", + "dateTime": "2 de nov. de 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1762065000, + "offset": -300, + "date": "2 de nov. de 2025", + "dateTime": "2 de nov. de 2025, 01:30" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1762066800, + "offset": -300, + "date": "2 de nov. de 2025", + "dateTime": "2 de nov. de 2025, 02:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1772949600, + "offset": -300, + "date": "8 de mar. de 2026", + "dateTime": "8 de mar. de 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1772953199, + "offset": -300, + "date": "8 de mar. de 2026", + "dateTime": "8 de mar. de 2026, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1772953200, + "offset": -240, + "date": "8 de mar. de 2026", + "dateTime": "8 de mar. de 2026, 03:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1772955000, + "offset": -240, + "date": "8 de mar. de 2026", + "dateTime": "8 de mar. de 2026, 03:30" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1772956800, + "offset": -240, + "date": "8 de mar. de 2026", + "dateTime": "8 de mar. de 2026, 04:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1793509200, + "offset": -240, + "date": "1 de nov. de 2026", + "dateTime": "1 de nov. de 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1793512799, + "offset": -240, + "date": "1 de nov. de 2026", + "dateTime": "1 de nov. de 2026, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1793512800, + "offset": -300, + "date": "1 de nov. de 2026", + "dateTime": "1 de nov. de 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1793514600, + "offset": -300, + "date": "1 de nov. de 2026", + "dateTime": "1 de nov. de 2026, 01:30" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 1793516400, + "offset": -300, + "date": "1 de nov. de 2026", + "dateTime": "1 de nov. de 2026, 02:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 2215058400, + "offset": -300, + "date": "11 de mar. de 2040", + "dateTime": "11 de mar. de 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 2215061999, + "offset": -300, + "date": "11 de mar. de 2040", + "dateTime": "11 de mar. de 2040, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 2215062000, + "offset": -240, + "date": "11 de mar. de 2040", + "dateTime": "11 de mar. de 2040, 03:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 2215063800, + "offset": -240, + "date": "11 de mar. de 2040", + "dateTime": "11 de mar. de 2040, 03:30" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 2215065600, + "offset": -240, + "date": "11 de mar. de 2040", + "dateTime": "11 de mar. de 2040, 04:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 2235618000, + "offset": -240, + "date": "4 de nov. de 2040", + "dateTime": "4 de nov. de 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 2235621599, + "offset": -240, + "date": "4 de nov. de 2040", + "dateTime": "4 de nov. de 2040, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 2235621600, + "offset": -300, + "date": "4 de nov. de 2040", + "dateTime": "4 de nov. de 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 2235623400, + "offset": -300, + "date": "4 de nov. de 2040", + "dateTime": "4 de nov. de 2040, 01:30" + }, + { + "zone": "America/New_York", + "locale": "pt", + "stamp": 2235625200, + "offset": -300, + "date": "4 de nov. de 2040", + "dateTime": "4 de nov. de 2040, 02:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1736408000, + "offset": -300, + "date": "9 de jan. de 2025", + "dateTime": "9 de jan. de 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1738022700, + "offset": -300, + "date": "27 de jan. de 2025", + "dateTime": "27 de jan. de 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1739086400, + "offset": -300, + "date": "9 de fev. de 2025", + "dateTime": "9 de fev. de 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1740701100, + "offset": -300, + "date": "27 de fev. de 2025", + "dateTime": "27 de fev. de 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1741505600, + "offset": -240, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1743120300, + "offset": -240, + "date": "27 de mar. de 2025", + "dateTime": "27 de mar. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1744184000, + "offset": -240, + "date": "9 de abr. de 2025", + "dateTime": "9 de abr. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1745798700, + "offset": -240, + "date": "27 de abr. de 2025", + "dateTime": "27 de abr. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1746776000, + "offset": -240, + "date": "9 de mai. de 2025", + "dateTime": "9 de mai. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1748390700, + "offset": -240, + "date": "27 de mai. de 2025", + "dateTime": "27 de mai. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1749454400, + "offset": -240, + "date": "9 de jun. de 2025", + "dateTime": "9 de jun. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1751069100, + "offset": -240, + "date": "27 de jun. de 2025", + "dateTime": "27 de jun. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1752046400, + "offset": -240, + "date": "9 de jul. de 2025", + "dateTime": "9 de jul. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1753661100, + "offset": -240, + "date": "27 de jul. de 2025", + "dateTime": "27 de jul. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1754724800, + "offset": -240, + "date": "9 de ago. de 2025", + "dateTime": "9 de ago. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1756339500, + "offset": -240, + "date": "27 de ago. de 2025", + "dateTime": "27 de ago. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1757403200, + "offset": -240, + "date": "9 de set. de 2025", + "dateTime": "9 de set. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1759017900, + "offset": -240, + "date": "27 de set. de 2025", + "dateTime": "27 de set. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1759995200, + "offset": -240, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 03:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1761609900, + "offset": -240, + "date": "27 de out. de 2025", + "dateTime": "27 de out. de 2025, 20:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1762673600, + "offset": -300, + "date": "9 de nov. de 2025", + "dateTime": "9 de nov. de 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1764288300, + "offset": -300, + "date": "27 de nov. de 2025", + "dateTime": "27 de nov. de 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1765265600, + "offset": -300, + "date": "9 de dez. de 2025", + "dateTime": "9 de dez. de 2025, 02:33" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1766880300, + "offset": -300, + "date": "27 de dez. de 2025", + "dateTime": "27 de dez. de 2025, 19:05" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1709208000, + "offset": -300, + "date": "29 de fev. de 2024", + "dateTime": "29 de fev. de 2024, 07:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1704067199, + "offset": -300, + "date": "31 de dez. de 2023", + "dateTime": "31 de dez. de 2023, 18:59" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 915196020, + "offset": -300, + "date": "1 de jan. de 1999", + "dateTime": "1 de jan. de 1999, 08:07" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1760000000, + "offset": -240, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 04:53" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1791000000, + "offset": -240, + "date": "3 de out. de 2026", + "dateTime": "3 de out. de 2026, 00:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1741500000, + "offset": -300, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1741503599, + "offset": -300, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1741503600, + "offset": -240, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 03:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1741505400, + "offset": -240, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 03:30" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1741507200, + "offset": -240, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 04:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1762059600, + "offset": -240, + "date": "2 de nov. de 2025", + "dateTime": "2 de nov. de 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1762063199, + "offset": -240, + "date": "2 de nov. de 2025", + "dateTime": "2 de nov. de 2025, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1762063200, + "offset": -300, + "date": "2 de nov. de 2025", + "dateTime": "2 de nov. de 2025, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1762065000, + "offset": -300, + "date": "2 de nov. de 2025", + "dateTime": "2 de nov. de 2025, 01:30" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1762066800, + "offset": -300, + "date": "2 de nov. de 2025", + "dateTime": "2 de nov. de 2025, 02:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1772949600, + "offset": -300, + "date": "8 de mar. de 2026", + "dateTime": "8 de mar. de 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1772953199, + "offset": -300, + "date": "8 de mar. de 2026", + "dateTime": "8 de mar. de 2026, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1772953200, + "offset": -240, + "date": "8 de mar. de 2026", + "dateTime": "8 de mar. de 2026, 03:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1772955000, + "offset": -240, + "date": "8 de mar. de 2026", + "dateTime": "8 de mar. de 2026, 03:30" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1772956800, + "offset": -240, + "date": "8 de mar. de 2026", + "dateTime": "8 de mar. de 2026, 04:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1793509200, + "offset": -240, + "date": "1 de nov. de 2026", + "dateTime": "1 de nov. de 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1793512799, + "offset": -240, + "date": "1 de nov. de 2026", + "dateTime": "1 de nov. de 2026, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1793512800, + "offset": -300, + "date": "1 de nov. de 2026", + "dateTime": "1 de nov. de 2026, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1793514600, + "offset": -300, + "date": "1 de nov. de 2026", + "dateTime": "1 de nov. de 2026, 01:30" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 1793516400, + "offset": -300, + "date": "1 de nov. de 2026", + "dateTime": "1 de nov. de 2026, 02:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 2215058400, + "offset": -300, + "date": "11 de mar. de 2040", + "dateTime": "11 de mar. de 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 2215061999, + "offset": -300, + "date": "11 de mar. de 2040", + "dateTime": "11 de mar. de 2040, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 2215062000, + "offset": -240, + "date": "11 de mar. de 2040", + "dateTime": "11 de mar. de 2040, 03:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 2215063800, + "offset": -240, + "date": "11 de mar. de 2040", + "dateTime": "11 de mar. de 2040, 03:30" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 2215065600, + "offset": -240, + "date": "11 de mar. de 2040", + "dateTime": "11 de mar. de 2040, 04:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 2235618000, + "offset": -240, + "date": "4 de nov. de 2040", + "dateTime": "4 de nov. de 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 2235621599, + "offset": -240, + "date": "4 de nov. de 2040", + "dateTime": "4 de nov. de 2040, 01:59" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 2235621600, + "offset": -300, + "date": "4 de nov. de 2040", + "dateTime": "4 de nov. de 2040, 01:00" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 2235623400, + "offset": -300, + "date": "4 de nov. de 2040", + "dateTime": "4 de nov. de 2040, 01:30" + }, + { + "zone": "America/New_York", + "locale": "pt-br", + "stamp": 2235625200, + "offset": -300, + "date": "4 de nov. de 2040", + "dateTime": "4 de nov. de 2040, 02:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1736408000, + "offset": -300, + "date": "9 янв. 2025 г.", + "dateTime": "9 янв. 2025 г., 02:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1738022700, + "offset": -300, + "date": "27 янв. 2025 г.", + "dateTime": "27 янв. 2025 г., 19:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1739086400, + "offset": -300, + "date": "9 февр. 2025 г.", + "dateTime": "9 февр. 2025 г., 02:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1740701100, + "offset": -300, + "date": "27 февр. 2025 г.", + "dateTime": "27 февр. 2025 г., 19:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1741505600, + "offset": -240, + "date": "9 мар. 2025 г.", + "dateTime": "9 мар. 2025 г., 03:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1743120300, + "offset": -240, + "date": "27 мар. 2025 г.", + "dateTime": "27 мар. 2025 г., 20:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1744184000, + "offset": -240, + "date": "9 апр. 2025 г.", + "dateTime": "9 апр. 2025 г., 03:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1745798700, + "offset": -240, + "date": "27 апр. 2025 г.", + "dateTime": "27 апр. 2025 г., 20:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1746776000, + "offset": -240, + "date": "9 мая 2025 г.", + "dateTime": "9 мая 2025 г., 03:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1748390700, + "offset": -240, + "date": "27 мая 2025 г.", + "dateTime": "27 мая 2025 г., 20:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1749454400, + "offset": -240, + "date": "9 июн. 2025 г.", + "dateTime": "9 июн. 2025 г., 03:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1751069100, + "offset": -240, + "date": "27 июн. 2025 г.", + "dateTime": "27 июн. 2025 г., 20:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1752046400, + "offset": -240, + "date": "9 июл. 2025 г.", + "dateTime": "9 июл. 2025 г., 03:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1753661100, + "offset": -240, + "date": "27 июл. 2025 г.", + "dateTime": "27 июл. 2025 г., 20:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1754724800, + "offset": -240, + "date": "9 авг. 2025 г.", + "dateTime": "9 авг. 2025 г., 03:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1756339500, + "offset": -240, + "date": "27 авг. 2025 г.", + "dateTime": "27 авг. 2025 г., 20:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1757403200, + "offset": -240, + "date": "9 сент. 2025 г.", + "dateTime": "9 сент. 2025 г., 03:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1759017900, + "offset": -240, + "date": "27 сент. 2025 г.", + "dateTime": "27 сент. 2025 г., 20:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1759995200, + "offset": -240, + "date": "9 окт. 2025 г.", + "dateTime": "9 окт. 2025 г., 03:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1761609900, + "offset": -240, + "date": "27 окт. 2025 г.", + "dateTime": "27 окт. 2025 г., 20:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1762673600, + "offset": -300, + "date": "9 нояб. 2025 г.", + "dateTime": "9 нояб. 2025 г., 02:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1764288300, + "offset": -300, + "date": "27 нояб. 2025 г.", + "dateTime": "27 нояб. 2025 г., 19:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1765265600, + "offset": -300, + "date": "9 дек. 2025 г.", + "dateTime": "9 дек. 2025 г., 02:33" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1766880300, + "offset": -300, + "date": "27 дек. 2025 г.", + "dateTime": "27 дек. 2025 г., 19:05" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1709208000, + "offset": -300, + "date": "29 февр. 2024 г.", + "dateTime": "29 февр. 2024 г., 07:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1704067199, + "offset": -300, + "date": "31 дек. 2023 г.", + "dateTime": "31 дек. 2023 г., 18:59" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 915196020, + "offset": -300, + "date": "1 янв. 1999 г.", + "dateTime": "1 янв. 1999 г., 08:07" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1760000000, + "offset": -240, + "date": "9 окт. 2025 г.", + "dateTime": "9 окт. 2025 г., 04:53" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1791000000, + "offset": -240, + "date": "3 окт. 2026 г.", + "dateTime": "3 окт. 2026 г., 00:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1741500000, + "offset": -300, + "date": "9 мар. 2025 г.", + "dateTime": "9 мар. 2025 г., 01:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1741503599, + "offset": -300, + "date": "9 мар. 2025 г.", + "dateTime": "9 мар. 2025 г., 01:59" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1741503600, + "offset": -240, + "date": "9 мар. 2025 г.", + "dateTime": "9 мар. 2025 г., 03:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1741505400, + "offset": -240, + "date": "9 мар. 2025 г.", + "dateTime": "9 мар. 2025 г., 03:30" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1741507200, + "offset": -240, + "date": "9 мар. 2025 г.", + "dateTime": "9 мар. 2025 г., 04:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1762059600, + "offset": -240, + "date": "2 нояб. 2025 г.", + "dateTime": "2 нояб. 2025 г., 01:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1762063199, + "offset": -240, + "date": "2 нояб. 2025 г.", + "dateTime": "2 нояб. 2025 г., 01:59" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1762063200, + "offset": -300, + "date": "2 нояб. 2025 г.", + "dateTime": "2 нояб. 2025 г., 01:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1762065000, + "offset": -300, + "date": "2 нояб. 2025 г.", + "dateTime": "2 нояб. 2025 г., 01:30" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1762066800, + "offset": -300, + "date": "2 нояб. 2025 г.", + "dateTime": "2 нояб. 2025 г., 02:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1772949600, + "offset": -300, + "date": "8 мар. 2026 г.", + "dateTime": "8 мар. 2026 г., 01:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1772953199, + "offset": -300, + "date": "8 мар. 2026 г.", + "dateTime": "8 мар. 2026 г., 01:59" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1772953200, + "offset": -240, + "date": "8 мар. 2026 г.", + "dateTime": "8 мар. 2026 г., 03:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1772955000, + "offset": -240, + "date": "8 мар. 2026 г.", + "dateTime": "8 мар. 2026 г., 03:30" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1772956800, + "offset": -240, + "date": "8 мар. 2026 г.", + "dateTime": "8 мар. 2026 г., 04:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1793509200, + "offset": -240, + "date": "1 нояб. 2026 г.", + "dateTime": "1 нояб. 2026 г., 01:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1793512799, + "offset": -240, + "date": "1 нояб. 2026 г.", + "dateTime": "1 нояб. 2026 г., 01:59" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1793512800, + "offset": -300, + "date": "1 нояб. 2026 г.", + "dateTime": "1 нояб. 2026 г., 01:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1793514600, + "offset": -300, + "date": "1 нояб. 2026 г.", + "dateTime": "1 нояб. 2026 г., 01:30" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 1793516400, + "offset": -300, + "date": "1 нояб. 2026 г.", + "dateTime": "1 нояб. 2026 г., 02:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 2215058400, + "offset": -300, + "date": "11 мар. 2040 г.", + "dateTime": "11 мар. 2040 г., 01:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 2215061999, + "offset": -300, + "date": "11 мар. 2040 г.", + "dateTime": "11 мар. 2040 г., 01:59" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 2215062000, + "offset": -240, + "date": "11 мар. 2040 г.", + "dateTime": "11 мар. 2040 г., 03:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 2215063800, + "offset": -240, + "date": "11 мар. 2040 г.", + "dateTime": "11 мар. 2040 г., 03:30" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 2215065600, + "offset": -240, + "date": "11 мар. 2040 г.", + "dateTime": "11 мар. 2040 г., 04:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 2235618000, + "offset": -240, + "date": "4 нояб. 2040 г.", + "dateTime": "4 нояб. 2040 г., 01:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 2235621599, + "offset": -240, + "date": "4 нояб. 2040 г.", + "dateTime": "4 нояб. 2040 г., 01:59" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 2235621600, + "offset": -300, + "date": "4 нояб. 2040 г.", + "dateTime": "4 нояб. 2040 г., 01:00" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 2235623400, + "offset": -300, + "date": "4 нояб. 2040 г.", + "dateTime": "4 нояб. 2040 г., 01:30" + }, + { + "zone": "America/New_York", + "locale": "ru", + "stamp": 2235625200, + "offset": -300, + "date": "4 нояб. 2040 г.", + "dateTime": "4 нояб. 2040 г., 02:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1736408000, + "offset": -300, + "date": "2025年1月9日", + "dateTime": "2025年1月9日 02:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1738022700, + "offset": -300, + "date": "2025年1月27日", + "dateTime": "2025年1月27日 19:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1739086400, + "offset": -300, + "date": "2025年2月9日", + "dateTime": "2025年2月9日 02:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1740701100, + "offset": -300, + "date": "2025年2月27日", + "dateTime": "2025年2月27日 19:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1741505600, + "offset": -240, + "date": "2025年3月9日", + "dateTime": "2025年3月9日 03:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1743120300, + "offset": -240, + "date": "2025年3月27日", + "dateTime": "2025年3月27日 20:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1744184000, + "offset": -240, + "date": "2025年4月9日", + "dateTime": "2025年4月9日 03:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1745798700, + "offset": -240, + "date": "2025年4月27日", + "dateTime": "2025年4月27日 20:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1746776000, + "offset": -240, + "date": "2025年5月9日", + "dateTime": "2025年5月9日 03:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1748390700, + "offset": -240, + "date": "2025年5月27日", + "dateTime": "2025年5月27日 20:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1749454400, + "offset": -240, + "date": "2025年6月9日", + "dateTime": "2025年6月9日 03:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1751069100, + "offset": -240, + "date": "2025年6月27日", + "dateTime": "2025年6月27日 20:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1752046400, + "offset": -240, + "date": "2025年7月9日", + "dateTime": "2025年7月9日 03:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1753661100, + "offset": -240, + "date": "2025年7月27日", + "dateTime": "2025年7月27日 20:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1754724800, + "offset": -240, + "date": "2025年8月9日", + "dateTime": "2025年8月9日 03:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1756339500, + "offset": -240, + "date": "2025年8月27日", + "dateTime": "2025年8月27日 20:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1757403200, + "offset": -240, + "date": "2025年9月9日", + "dateTime": "2025年9月9日 03:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1759017900, + "offset": -240, + "date": "2025年9月27日", + "dateTime": "2025年9月27日 20:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1759995200, + "offset": -240, + "date": "2025年10月9日", + "dateTime": "2025年10月9日 03:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1761609900, + "offset": -240, + "date": "2025年10月27日", + "dateTime": "2025年10月27日 20:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1762673600, + "offset": -300, + "date": "2025年11月9日", + "dateTime": "2025年11月9日 02:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1764288300, + "offset": -300, + "date": "2025年11月27日", + "dateTime": "2025年11月27日 19:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1765265600, + "offset": -300, + "date": "2025年12月9日", + "dateTime": "2025年12月9日 02:33" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1766880300, + "offset": -300, + "date": "2025年12月27日", + "dateTime": "2025年12月27日 19:05" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1709208000, + "offset": -300, + "date": "2024年2月29日", + "dateTime": "2024年2月29日 07:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1704067199, + "offset": -300, + "date": "2023年12月31日", + "dateTime": "2023年12月31日 18:59" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 915196020, + "offset": -300, + "date": "1999年1月1日", + "dateTime": "1999年1月1日 08:07" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1760000000, + "offset": -240, + "date": "2025年10月9日", + "dateTime": "2025年10月9日 04:53" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1791000000, + "offset": -240, + "date": "2026年10月3日", + "dateTime": "2026年10月3日 00:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1741500000, + "offset": -300, + "date": "2025年3月9日", + "dateTime": "2025年3月9日 01:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1741503599, + "offset": -300, + "date": "2025年3月9日", + "dateTime": "2025年3月9日 01:59" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1741503600, + "offset": -240, + "date": "2025年3月9日", + "dateTime": "2025年3月9日 03:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1741505400, + "offset": -240, + "date": "2025年3月9日", + "dateTime": "2025年3月9日 03:30" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1741507200, + "offset": -240, + "date": "2025年3月9日", + "dateTime": "2025年3月9日 04:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1762059600, + "offset": -240, + "date": "2025年11月2日", + "dateTime": "2025年11月2日 01:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1762063199, + "offset": -240, + "date": "2025年11月2日", + "dateTime": "2025年11月2日 01:59" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1762063200, + "offset": -300, + "date": "2025年11月2日", + "dateTime": "2025年11月2日 01:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1762065000, + "offset": -300, + "date": "2025年11月2日", + "dateTime": "2025年11月2日 01:30" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1762066800, + "offset": -300, + "date": "2025年11月2日", + "dateTime": "2025年11月2日 02:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1772949600, + "offset": -300, + "date": "2026年3月8日", + "dateTime": "2026年3月8日 01:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1772953199, + "offset": -300, + "date": "2026年3月8日", + "dateTime": "2026年3月8日 01:59" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1772953200, + "offset": -240, + "date": "2026年3月8日", + "dateTime": "2026年3月8日 03:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1772955000, + "offset": -240, + "date": "2026年3月8日", + "dateTime": "2026年3月8日 03:30" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1772956800, + "offset": -240, + "date": "2026年3月8日", + "dateTime": "2026年3月8日 04:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1793509200, + "offset": -240, + "date": "2026年11月1日", + "dateTime": "2026年11月1日 01:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1793512799, + "offset": -240, + "date": "2026年11月1日", + "dateTime": "2026年11月1日 01:59" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1793512800, + "offset": -300, + "date": "2026年11月1日", + "dateTime": "2026年11月1日 01:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1793514600, + "offset": -300, + "date": "2026年11月1日", + "dateTime": "2026年11月1日 01:30" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 1793516400, + "offset": -300, + "date": "2026年11月1日", + "dateTime": "2026年11月1日 02:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 2215058400, + "offset": -300, + "date": "2040年3月11日", + "dateTime": "2040年3月11日 01:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 2215061999, + "offset": -300, + "date": "2040年3月11日", + "dateTime": "2040年3月11日 01:59" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 2215062000, + "offset": -240, + "date": "2040年3月11日", + "dateTime": "2040年3月11日 03:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 2215063800, + "offset": -240, + "date": "2040年3月11日", + "dateTime": "2040年3月11日 03:30" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 2215065600, + "offset": -240, + "date": "2040年3月11日", + "dateTime": "2040年3月11日 04:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 2235618000, + "offset": -240, + "date": "2040年11月4日", + "dateTime": "2040年11月4日 01:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 2235621599, + "offset": -240, + "date": "2040年11月4日", + "dateTime": "2040年11月4日 01:59" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 2235621600, + "offset": -300, + "date": "2040年11月4日", + "dateTime": "2040年11月4日 01:00" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 2235623400, + "offset": -300, + "date": "2040年11月4日", + "dateTime": "2040年11月4日 01:30" + }, + { + "zone": "America/New_York", + "locale": "zh", + "stamp": 2235625200, + "offset": -300, + "date": "2040年11月4日", + "dateTime": "2040年11月4日 02:00" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1736408000, + "offset": 330, + "date": "Jan 9, 2025", + "dateTime": "Jan 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1738022700, + "offset": 330, + "date": "Jan 28, 2025", + "dateTime": "Jan 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1739086400, + "offset": 330, + "date": "Feb 9, 2025", + "dateTime": "Feb 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1740701100, + "offset": 330, + "date": "Feb 28, 2025", + "dateTime": "Feb 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1741505600, + "offset": 330, + "date": "Mar 9, 2025", + "dateTime": "Mar 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1743120300, + "offset": 330, + "date": "Mar 28, 2025", + "dateTime": "Mar 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1744184000, + "offset": 330, + "date": "Apr 9, 2025", + "dateTime": "Apr 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1745798700, + "offset": 330, + "date": "Apr 28, 2025", + "dateTime": "Apr 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1746776000, + "offset": 330, + "date": "May 9, 2025", + "dateTime": "May 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1748390700, + "offset": 330, + "date": "May 28, 2025", + "dateTime": "May 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1749454400, + "offset": 330, + "date": "Jun 9, 2025", + "dateTime": "Jun 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1751069100, + "offset": 330, + "date": "Jun 28, 2025", + "dateTime": "Jun 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1752046400, + "offset": 330, + "date": "Jul 9, 2025", + "dateTime": "Jul 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1753661100, + "offset": 330, + "date": "Jul 28, 2025", + "dateTime": "Jul 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1754724800, + "offset": 330, + "date": "Aug 9, 2025", + "dateTime": "Aug 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1756339500, + "offset": 330, + "date": "Aug 28, 2025", + "dateTime": "Aug 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1757403200, + "offset": 330, + "date": "Sep 9, 2025", + "dateTime": "Sep 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1759017900, + "offset": 330, + "date": "Sep 28, 2025", + "dateTime": "Sep 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1759995200, + "offset": 330, + "date": "Oct 9, 2025", + "dateTime": "Oct 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1761609900, + "offset": 330, + "date": "Oct 28, 2025", + "dateTime": "Oct 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1762673600, + "offset": 330, + "date": "Nov 9, 2025", + "dateTime": "Nov 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1764288300, + "offset": 330, + "date": "Nov 28, 2025", + "dateTime": "Nov 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1765265600, + "offset": 330, + "date": "Dec 9, 2025", + "dateTime": "Dec 9, 2025, 01:03 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1766880300, + "offset": 330, + "date": "Dec 28, 2025", + "dateTime": "Dec 28, 2025, 05:35 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1709208000, + "offset": 330, + "date": "Feb 29, 2024", + "dateTime": "Feb 29, 2024, 05:30 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1704067199, + "offset": 330, + "date": "Jan 1, 2024", + "dateTime": "Jan 1, 2024, 05:29 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 915196020, + "offset": 330, + "date": "Jan 1, 1999", + "dateTime": "Jan 1, 1999, 06:37 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1760000000, + "offset": 330, + "date": "Oct 9, 2025", + "dateTime": "Oct 9, 2025, 02:23 PM" + }, + { + "zone": "Asia/Kolkata", + "locale": "en", + "stamp": 1791000000, + "offset": 330, + "date": "Oct 3, 2026", + "dateTime": "Oct 3, 2026, 09:30 AM" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1736408000, + "offset": 330, + "date": "9 ene 2025", + "dateTime": "9 ene 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1738022700, + "offset": 330, + "date": "28 ene 2025", + "dateTime": "28 ene 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1739086400, + "offset": 330, + "date": "9 feb 2025", + "dateTime": "9 feb 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1740701100, + "offset": 330, + "date": "28 feb 2025", + "dateTime": "28 feb 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1741505600, + "offset": 330, + "date": "9 mar 2025", + "dateTime": "9 mar 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1743120300, + "offset": 330, + "date": "28 mar 2025", + "dateTime": "28 mar 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1744184000, + "offset": 330, + "date": "9 abr 2025", + "dateTime": "9 abr 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1745798700, + "offset": 330, + "date": "28 abr 2025", + "dateTime": "28 abr 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1746776000, + "offset": 330, + "date": "9 may 2025", + "dateTime": "9 may 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1748390700, + "offset": 330, + "date": "28 may 2025", + "dateTime": "28 may 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1749454400, + "offset": 330, + "date": "9 jun 2025", + "dateTime": "9 jun 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1751069100, + "offset": 330, + "date": "28 jun 2025", + "dateTime": "28 jun 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1752046400, + "offset": 330, + "date": "9 jul 2025", + "dateTime": "9 jul 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1753661100, + "offset": 330, + "date": "28 jul 2025", + "dateTime": "28 jul 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1754724800, + "offset": 330, + "date": "9 ago 2025", + "dateTime": "9 ago 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1756339500, + "offset": 330, + "date": "28 ago 2025", + "dateTime": "28 ago 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1757403200, + "offset": 330, + "date": "9 sept 2025", + "dateTime": "9 sept 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1759017900, + "offset": 330, + "date": "28 sept 2025", + "dateTime": "28 sept 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1759995200, + "offset": 330, + "date": "9 oct 2025", + "dateTime": "9 oct 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1761609900, + "offset": 330, + "date": "28 oct 2025", + "dateTime": "28 oct 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1762673600, + "offset": 330, + "date": "9 nov 2025", + "dateTime": "9 nov 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1764288300, + "offset": 330, + "date": "28 nov 2025", + "dateTime": "28 nov 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1765265600, + "offset": 330, + "date": "9 dic 2025", + "dateTime": "9 dic 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1766880300, + "offset": 330, + "date": "28 dic 2025", + "dateTime": "28 dic 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1709208000, + "offset": 330, + "date": "29 feb 2024", + "dateTime": "29 feb 2024, 17:30" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1704067199, + "offset": 330, + "date": "1 ene 2024", + "dateTime": "1 ene 2024, 05:29" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 915196020, + "offset": 330, + "date": "1 ene 1999", + "dateTime": "1 ene 1999, 18:37" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1760000000, + "offset": 330, + "date": "9 oct 2025", + "dateTime": "9 oct 2025, 14:23" + }, + { + "zone": "Asia/Kolkata", + "locale": "es", + "stamp": 1791000000, + "offset": 330, + "date": "3 oct 2026", + "dateTime": "3 oct 2026, 09:30" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1736408000, + "offset": 330, + "date": "9 janv. 2025", + "dateTime": "9 janv. 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1738022700, + "offset": 330, + "date": "28 janv. 2025", + "dateTime": "28 janv. 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1739086400, + "offset": 330, + "date": "9 févr. 2025", + "dateTime": "9 févr. 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1740701100, + "offset": 330, + "date": "28 févr. 2025", + "dateTime": "28 févr. 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1741505600, + "offset": 330, + "date": "9 mars 2025", + "dateTime": "9 mars 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1743120300, + "offset": 330, + "date": "28 mars 2025", + "dateTime": "28 mars 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1744184000, + "offset": 330, + "date": "9 avr. 2025", + "dateTime": "9 avr. 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1745798700, + "offset": 330, + "date": "28 avr. 2025", + "dateTime": "28 avr. 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1746776000, + "offset": 330, + "date": "9 mai 2025", + "dateTime": "9 mai 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1748390700, + "offset": 330, + "date": "28 mai 2025", + "dateTime": "28 mai 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1749454400, + "offset": 330, + "date": "9 juin 2025", + "dateTime": "9 juin 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1751069100, + "offset": 330, + "date": "28 juin 2025", + "dateTime": "28 juin 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1752046400, + "offset": 330, + "date": "9 juil. 2025", + "dateTime": "9 juil. 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1753661100, + "offset": 330, + "date": "28 juil. 2025", + "dateTime": "28 juil. 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1754724800, + "offset": 330, + "date": "9 août 2025", + "dateTime": "9 août 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1756339500, + "offset": 330, + "date": "28 août 2025", + "dateTime": "28 août 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1757403200, + "offset": 330, + "date": "9 sept. 2025", + "dateTime": "9 sept. 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1759017900, + "offset": 330, + "date": "28 sept. 2025", + "dateTime": "28 sept. 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1759995200, + "offset": 330, + "date": "9 oct. 2025", + "dateTime": "9 oct. 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1761609900, + "offset": 330, + "date": "28 oct. 2025", + "dateTime": "28 oct. 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1762673600, + "offset": 330, + "date": "9 nov. 2025", + "dateTime": "9 nov. 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1764288300, + "offset": 330, + "date": "28 nov. 2025", + "dateTime": "28 nov. 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1765265600, + "offset": 330, + "date": "9 déc. 2025", + "dateTime": "9 déc. 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1766880300, + "offset": 330, + "date": "28 déc. 2025", + "dateTime": "28 déc. 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1709208000, + "offset": 330, + "date": "29 févr. 2024", + "dateTime": "29 févr. 2024, 17:30" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1704067199, + "offset": 330, + "date": "1 janv. 2024", + "dateTime": "1 janv. 2024, 05:29" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 915196020, + "offset": 330, + "date": "1 janv. 1999", + "dateTime": "1 janv. 1999, 18:37" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1760000000, + "offset": 330, + "date": "9 oct. 2025", + "dateTime": "9 oct. 2025, 14:23" + }, + { + "zone": "Asia/Kolkata", + "locale": "fr", + "stamp": 1791000000, + "offset": 330, + "date": "3 oct. 2026", + "dateTime": "3 oct. 2026, 09:30" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1736408000, + "offset": 330, + "date": "9 de jan. de 2025", + "dateTime": "9 de jan. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1738022700, + "offset": 330, + "date": "28 de jan. de 2025", + "dateTime": "28 de jan. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1739086400, + "offset": 330, + "date": "9 de fev. de 2025", + "dateTime": "9 de fev. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1740701100, + "offset": 330, + "date": "28 de fev. de 2025", + "dateTime": "28 de fev. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1741505600, + "offset": 330, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1743120300, + "offset": 330, + "date": "28 de mar. de 2025", + "dateTime": "28 de mar. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1744184000, + "offset": 330, + "date": "9 de abr. de 2025", + "dateTime": "9 de abr. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1745798700, + "offset": 330, + "date": "28 de abr. de 2025", + "dateTime": "28 de abr. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1746776000, + "offset": 330, + "date": "9 de mai. de 2025", + "dateTime": "9 de mai. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1748390700, + "offset": 330, + "date": "28 de mai. de 2025", + "dateTime": "28 de mai. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1749454400, + "offset": 330, + "date": "9 de jun. de 2025", + "dateTime": "9 de jun. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1751069100, + "offset": 330, + "date": "28 de jun. de 2025", + "dateTime": "28 de jun. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1752046400, + "offset": 330, + "date": "9 de jul. de 2025", + "dateTime": "9 de jul. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1753661100, + "offset": 330, + "date": "28 de jul. de 2025", + "dateTime": "28 de jul. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1754724800, + "offset": 330, + "date": "9 de ago. de 2025", + "dateTime": "9 de ago. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1756339500, + "offset": 330, + "date": "28 de ago. de 2025", + "dateTime": "28 de ago. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1757403200, + "offset": 330, + "date": "9 de set. de 2025", + "dateTime": "9 de set. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1759017900, + "offset": 330, + "date": "28 de set. de 2025", + "dateTime": "28 de set. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1759995200, + "offset": 330, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1761609900, + "offset": 330, + "date": "28 de out. de 2025", + "dateTime": "28 de out. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1762673600, + "offset": 330, + "date": "9 de nov. de 2025", + "dateTime": "9 de nov. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1764288300, + "offset": 330, + "date": "28 de nov. de 2025", + "dateTime": "28 de nov. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1765265600, + "offset": 330, + "date": "9 de dez. de 2025", + "dateTime": "9 de dez. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1766880300, + "offset": 330, + "date": "28 de dez. de 2025", + "dateTime": "28 de dez. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1709208000, + "offset": 330, + "date": "29 de fev. de 2024", + "dateTime": "29 de fev. de 2024, 17:30" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1704067199, + "offset": 330, + "date": "1 de jan. de 2024", + "dateTime": "1 de jan. de 2024, 05:29" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 915196020, + "offset": 330, + "date": "1 de jan. de 1999", + "dateTime": "1 de jan. de 1999, 18:37" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1760000000, + "offset": 330, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 14:23" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt", + "stamp": 1791000000, + "offset": 330, + "date": "3 de out. de 2026", + "dateTime": "3 de out. de 2026, 09:30" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1736408000, + "offset": 330, + "date": "9 de jan. de 2025", + "dateTime": "9 de jan. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1738022700, + "offset": 330, + "date": "28 de jan. de 2025", + "dateTime": "28 de jan. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1739086400, + "offset": 330, + "date": "9 de fev. de 2025", + "dateTime": "9 de fev. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1740701100, + "offset": 330, + "date": "28 de fev. de 2025", + "dateTime": "28 de fev. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1741505600, + "offset": 330, + "date": "9 de mar. de 2025", + "dateTime": "9 de mar. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1743120300, + "offset": 330, + "date": "28 de mar. de 2025", + "dateTime": "28 de mar. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1744184000, + "offset": 330, + "date": "9 de abr. de 2025", + "dateTime": "9 de abr. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1745798700, + "offset": 330, + "date": "28 de abr. de 2025", + "dateTime": "28 de abr. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1746776000, + "offset": 330, + "date": "9 de mai. de 2025", + "dateTime": "9 de mai. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1748390700, + "offset": 330, + "date": "28 de mai. de 2025", + "dateTime": "28 de mai. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1749454400, + "offset": 330, + "date": "9 de jun. de 2025", + "dateTime": "9 de jun. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1751069100, + "offset": 330, + "date": "28 de jun. de 2025", + "dateTime": "28 de jun. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1752046400, + "offset": 330, + "date": "9 de jul. de 2025", + "dateTime": "9 de jul. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1753661100, + "offset": 330, + "date": "28 de jul. de 2025", + "dateTime": "28 de jul. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1754724800, + "offset": 330, + "date": "9 de ago. de 2025", + "dateTime": "9 de ago. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1756339500, + "offset": 330, + "date": "28 de ago. de 2025", + "dateTime": "28 de ago. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1757403200, + "offset": 330, + "date": "9 de set. de 2025", + "dateTime": "9 de set. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1759017900, + "offset": 330, + "date": "28 de set. de 2025", + "dateTime": "28 de set. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1759995200, + "offset": 330, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1761609900, + "offset": 330, + "date": "28 de out. de 2025", + "dateTime": "28 de out. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1762673600, + "offset": 330, + "date": "9 de nov. de 2025", + "dateTime": "9 de nov. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1764288300, + "offset": 330, + "date": "28 de nov. de 2025", + "dateTime": "28 de nov. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1765265600, + "offset": 330, + "date": "9 de dez. de 2025", + "dateTime": "9 de dez. de 2025, 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1766880300, + "offset": 330, + "date": "28 de dez. de 2025", + "dateTime": "28 de dez. de 2025, 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1709208000, + "offset": 330, + "date": "29 de fev. de 2024", + "dateTime": "29 de fev. de 2024, 17:30" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1704067199, + "offset": 330, + "date": "1 de jan. de 2024", + "dateTime": "1 de jan. de 2024, 05:29" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 915196020, + "offset": 330, + "date": "1 de jan. de 1999", + "dateTime": "1 de jan. de 1999, 18:37" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1760000000, + "offset": 330, + "date": "9 de out. de 2025", + "dateTime": "9 de out. de 2025, 14:23" + }, + { + "zone": "Asia/Kolkata", + "locale": "pt-br", + "stamp": 1791000000, + "offset": 330, + "date": "3 de out. de 2026", + "dateTime": "3 de out. de 2026, 09:30" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1736408000, + "offset": 330, + "date": "9 янв. 2025 г.", + "dateTime": "9 янв. 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1738022700, + "offset": 330, + "date": "28 янв. 2025 г.", + "dateTime": "28 янв. 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1739086400, + "offset": 330, + "date": "9 февр. 2025 г.", + "dateTime": "9 февр. 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1740701100, + "offset": 330, + "date": "28 февр. 2025 г.", + "dateTime": "28 февр. 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1741505600, + "offset": 330, + "date": "9 мар. 2025 г.", + "dateTime": "9 мар. 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1743120300, + "offset": 330, + "date": "28 мар. 2025 г.", + "dateTime": "28 мар. 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1744184000, + "offset": 330, + "date": "9 апр. 2025 г.", + "dateTime": "9 апр. 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1745798700, + "offset": 330, + "date": "28 апр. 2025 г.", + "dateTime": "28 апр. 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1746776000, + "offset": 330, + "date": "9 мая 2025 г.", + "dateTime": "9 мая 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1748390700, + "offset": 330, + "date": "28 мая 2025 г.", + "dateTime": "28 мая 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1749454400, + "offset": 330, + "date": "9 июн. 2025 г.", + "dateTime": "9 июн. 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1751069100, + "offset": 330, + "date": "28 июн. 2025 г.", + "dateTime": "28 июн. 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1752046400, + "offset": 330, + "date": "9 июл. 2025 г.", + "dateTime": "9 июл. 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1753661100, + "offset": 330, + "date": "28 июл. 2025 г.", + "dateTime": "28 июл. 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1754724800, + "offset": 330, + "date": "9 авг. 2025 г.", + "dateTime": "9 авг. 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1756339500, + "offset": 330, + "date": "28 авг. 2025 г.", + "dateTime": "28 авг. 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1757403200, + "offset": 330, + "date": "9 сент. 2025 г.", + "dateTime": "9 сент. 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1759017900, + "offset": 330, + "date": "28 сент. 2025 г.", + "dateTime": "28 сент. 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1759995200, + "offset": 330, + "date": "9 окт. 2025 г.", + "dateTime": "9 окт. 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1761609900, + "offset": 330, + "date": "28 окт. 2025 г.", + "dateTime": "28 окт. 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1762673600, + "offset": 330, + "date": "9 нояб. 2025 г.", + "dateTime": "9 нояб. 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1764288300, + "offset": 330, + "date": "28 нояб. 2025 г.", + "dateTime": "28 нояб. 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1765265600, + "offset": 330, + "date": "9 дек. 2025 г.", + "dateTime": "9 дек. 2025 г., 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1766880300, + "offset": 330, + "date": "28 дек. 2025 г.", + "dateTime": "28 дек. 2025 г., 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1709208000, + "offset": 330, + "date": "29 февр. 2024 г.", + "dateTime": "29 февр. 2024 г., 17:30" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1704067199, + "offset": 330, + "date": "1 янв. 2024 г.", + "dateTime": "1 янв. 2024 г., 05:29" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 915196020, + "offset": 330, + "date": "1 янв. 1999 г.", + "dateTime": "1 янв. 1999 г., 18:37" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1760000000, + "offset": 330, + "date": "9 окт. 2025 г.", + "dateTime": "9 окт. 2025 г., 14:23" + }, + { + "zone": "Asia/Kolkata", + "locale": "ru", + "stamp": 1791000000, + "offset": 330, + "date": "3 окт. 2026 г.", + "dateTime": "3 окт. 2026 г., 09:30" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1736408000, + "offset": 330, + "date": "2025年1月9日", + "dateTime": "2025年1月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1738022700, + "offset": 330, + "date": "2025年1月28日", + "dateTime": "2025年1月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1739086400, + "offset": 330, + "date": "2025年2月9日", + "dateTime": "2025年2月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1740701100, + "offset": 330, + "date": "2025年2月28日", + "dateTime": "2025年2月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1741505600, + "offset": 330, + "date": "2025年3月9日", + "dateTime": "2025年3月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1743120300, + "offset": 330, + "date": "2025年3月28日", + "dateTime": "2025年3月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1744184000, + "offset": 330, + "date": "2025年4月9日", + "dateTime": "2025年4月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1745798700, + "offset": 330, + "date": "2025年4月28日", + "dateTime": "2025年4月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1746776000, + "offset": 330, + "date": "2025年5月9日", + "dateTime": "2025年5月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1748390700, + "offset": 330, + "date": "2025年5月28日", + "dateTime": "2025年5月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1749454400, + "offset": 330, + "date": "2025年6月9日", + "dateTime": "2025年6月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1751069100, + "offset": 330, + "date": "2025年6月28日", + "dateTime": "2025年6月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1752046400, + "offset": 330, + "date": "2025年7月9日", + "dateTime": "2025年7月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1753661100, + "offset": 330, + "date": "2025年7月28日", + "dateTime": "2025年7月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1754724800, + "offset": 330, + "date": "2025年8月9日", + "dateTime": "2025年8月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1756339500, + "offset": 330, + "date": "2025年8月28日", + "dateTime": "2025年8月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1757403200, + "offset": 330, + "date": "2025年9月9日", + "dateTime": "2025年9月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1759017900, + "offset": 330, + "date": "2025年9月28日", + "dateTime": "2025年9月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1759995200, + "offset": 330, + "date": "2025年10月9日", + "dateTime": "2025年10月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1761609900, + "offset": 330, + "date": "2025年10月28日", + "dateTime": "2025年10月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1762673600, + "offset": 330, + "date": "2025年11月9日", + "dateTime": "2025年11月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1764288300, + "offset": 330, + "date": "2025年11月28日", + "dateTime": "2025年11月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1765265600, + "offset": 330, + "date": "2025年12月9日", + "dateTime": "2025年12月9日 13:03" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1766880300, + "offset": 330, + "date": "2025年12月28日", + "dateTime": "2025年12月28日 05:35" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1709208000, + "offset": 330, + "date": "2024年2月29日", + "dateTime": "2024年2月29日 17:30" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1704067199, + "offset": 330, + "date": "2024年1月1日", + "dateTime": "2024年1月1日 05:29" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 915196020, + "offset": 330, + "date": "1999年1月1日", + "dateTime": "1999年1月1日 18:37" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1760000000, + "offset": 330, + "date": "2025年10月9日", + "dateTime": "2025年10月9日 14:23" + }, + { + "zone": "Asia/Kolkata", + "locale": "zh", + "stamp": 1791000000, + "offset": 330, + "date": "2026年10月3日", + "dateTime": "2026年10月3日 09:30" + } + ] +} diff --git a/crates/accshift-app/tests/data/real/descriptor.json b/crates/accshift-app/tests/data/real/descriptor.json new file mode 100644 index 00000000..44beac77 --- /dev/null +++ b/crates/accshift-app/tests/data/real/descriptor.json @@ -0,0 +1,56 @@ +{ + "id": "egui-fixture", + "schemaVersion": 1, + "name": "Fixture Launcher", + "shortName": "Fixture", + "os": { + "windows": { + "roots": { "files": ["${LIVE}"] }, + "detect": { "pathExists": ["${LIVE}/never-here"] }, + "identity": { + "source": { "kind": "synthetic" }, + "format": { "charset": "alphanumeric", "maxLength": 64 }, + "current": "config" + }, + "state": { + "files": [ + { "live": "${LIVE}/session.json", "snapshot": "session.json", "snapshotMarker": true } + ] + }, + "close": { "processes": ["nothing-here.exe"] }, + "setup": { "missingSnapshotHint": "Add this account through setup first." } + }, + "linux": { + "roots": { "files": ["${LIVE}"] }, + "detect": { "pathExists": ["${LIVE}/never-here"] }, + "identity": { + "source": { "kind": "synthetic" }, + "format": { "charset": "alphanumeric", "maxLength": 64 }, + "current": "config" + }, + "state": { + "files": [ + { "live": "${LIVE}/session.json", "snapshot": "session.json", "snapshotMarker": true } + ] + }, + "close": { "processes": ["nothing-here"] }, + "setup": { "missingSnapshotHint": "Add this account through setup first." } + }, + "macos": { + "roots": { "files": ["${LIVE}"] }, + "detect": { "pathExists": ["${LIVE}/never-here"] }, + "identity": { + "source": { "kind": "synthetic" }, + "format": { "charset": "alphanumeric", "maxLength": 64 }, + "current": "config" + }, + "state": { + "files": [ + { "live": "${LIVE}/session.json", "snapshot": "session.json", "snapshotMarker": true } + ] + }, + "close": { "processes": ["nothing-here"] }, + "setup": { "missingSnapshotHint": "Add this account through setup first." } + } + } +} diff --git a/crates/accshift-app/tests/data/real/legacy-config.json b/crates/accshift-app/tests/data/real/legacy-config.json new file mode 100644 index 00000000..05ebf6b2 --- /dev/null +++ b/crates/accshift-app/tests/data/real/legacy-config.json @@ -0,0 +1,10 @@ +{ + "riot": { + "profiles": [ + { "id": "riot-fixture-1", "label": "Main" }, + { "id": "riot-fixture-2", "label": "Smurf" } + ] + }, + "window_width": 1180, + "window_height": 760 +} diff --git a/crates/accshift-app/tests/data/real/theme.json b/crates/accshift-app/tests/data/real/theme.json new file mode 100644 index 00000000..56236a3e --- /dev/null +++ b/crates/accshift-app/tests/data/real/theme.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 2, + "id": "egui-fixture-theme", + "name": "Fixture Violet", + "author": "accshift tests", + "version": "1.0.0", + "colorScheme": "dark", + "extends": "dark", + "tokens": { + "accent": "#a78bfa", + "background": "#0f0d17" + } +} diff --git a/crates/accshift-app/tests/data/stores/context.json b/crates/accshift-app/tests/data/stores/context.json new file mode 100644 index 00000000..3e0d73e2 --- /dev/null +++ b/crates/accshift-app/tests/data/stores/context.json @@ -0,0 +1,15 @@ +{ + "platformIds": [ + "steam", + "riot", + "battle-net", + "ubisoft", + "roblox", + "epic", + "gog", + "jagex", + "discord" + ], + "themeIds": ["dark", "light", "midnight", "glass-dark", "glass-light", "liquid-glass"], + "preferredLanguages": ["fr-FR"] +} diff --git a/crates/accshift-app/tests/data/stores/roundtrip.json b/crates/accshift-app/tests/data/stores/roundtrip.json new file mode 100644 index 00000000..8fcb9048 --- /dev/null +++ b/crates/accshift-app/tests/data/stores/roundtrip.json @@ -0,0 +1,3599 @@ +[ + { + "name": "demo client.settings", + "storeId": "client.settings", + "input": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "themeId": "glass-dark", + "enabledPlatforms": ["steam", "riot", "roblox"] + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "glass-dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "on", + "streamerMode": "off", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam", "riot", "roblox"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 0, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "demo client.folders", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + } + }, + { + "name": "demo client.personas", + "storeId": "client.personas", + "input": [], + "op": { + "kind": "deletePersona", + "id": "__none__" + }, + "wrote": true, + "expected": [] + }, + { + "name": "demo client.account-card-notes", + "storeId": "client.account-card-notes", + "input": {}, + "op": { + "kind": "clearNote", + "id": "__none__" + }, + "wrote": true, + "expected": {} + }, + { + "name": "demo client.account-card-colors", + "storeId": "client.account-card-colors", + "input": { + "76561198000000001": "#8b5cf6", + "76561198000000005": "#f97316", + "76561198000000009": "#ec4899" + }, + "op": { + "kind": "setColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": { + "76561198000000001": "#8b5cf6", + "76561198000000005": "#f97316", + "76561198000000009": "#ec4899" + } + }, + { + "name": "demo client.account-default-game", + "storeId": "client.account-default-game", + "input": {}, + "op": { + "kind": "setDefaultGame", + "id": "__roundtrip__", + "game": { + "appId": " 730 ", + "name": "Counter-Strike 2" + } + }, + "wrote": true, + "expected": { + "__roundtrip__": { + "appId": "730", + "name": "Counter-Strike 2" + } + } + }, + { + "name": "demo client.folder-card-colors", + "storeId": "client.folder-card-colors", + "input": {}, + "op": { + "kind": "setFolderColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": {} + }, + { + "name": "demo client.view-mode", + "storeId": "client.view-mode", + "input": "grid", + "op": { + "kind": "resaveViewMode" + }, + "wrote": true, + "expected": "grid" + }, + { + "name": "dev client.settings", + "storeId": "client.settings", + "input": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam", "riot"], + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": true + } + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "on", + "streamerMode": "off", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam", "riot"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 0, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": true, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "dev client.folders", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + } + }, + { + "name": "dev client.account-card-notes", + "storeId": "client.account-card-notes", + "input": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + }, + "op": { + "kind": "clearNote", + "id": "__none__" + }, + "wrote": true, + "expected": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + } + }, + { + "name": "dev client.account-card-colors", + "storeId": "client.account-card-colors", + "input": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + }, + "op": { + "kind": "setColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + } + }, + { + "name": "empty client.settings", + "storeId": "client.settings", + "input": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam"] + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "on", + "streamerMode": "off", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 0, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "empty client.folders", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [], + "itemOrder": {} + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [] + } + } + }, + { + "name": "empty client.account-card-colors", + "storeId": "client.account-card-colors", + "input": {}, + "op": { + "kind": "setColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": {} + }, + { + "name": "missing client.settings", + "storeId": "client.settings", + "input": null, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "missing client.folders", + "storeId": "client.folders", + "input": null, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [] + } + } + }, + { + "name": "missing client.personas", + "storeId": "client.personas", + "input": null, + "op": { + "kind": "deletePersona", + "id": "__none__" + }, + "wrote": true, + "expected": [] + }, + { + "name": "missing client.account-card-notes", + "storeId": "client.account-card-notes", + "input": null, + "op": { + "kind": "clearNote", + "id": "__none__" + }, + "wrote": true, + "expected": {} + }, + { + "name": "missing client.account-card-colors", + "storeId": "client.account-card-colors", + "input": null, + "op": { + "kind": "setColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": {} + }, + { + "name": "missing client.account-default-game", + "storeId": "client.account-default-game", + "input": null, + "op": { + "kind": "setDefaultGame", + "id": "__roundtrip__", + "game": { + "appId": " 730 ", + "name": "Counter-Strike 2" + } + }, + "wrote": true, + "expected": { + "__roundtrip__": { + "appId": "730", + "name": "Counter-Strike 2" + } + } + }, + { + "name": "missing client.folder-card-colors", + "storeId": "client.folder-card-colors", + "input": null, + "op": { + "kind": "setFolderColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": {} + }, + { + "name": "missing client.view-mode", + "storeId": "client.view-mode", + "input": null, + "op": { + "kind": "resaveViewMode" + }, + "wrote": true, + "expected": "grid" + }, + { + "name": "settings from settingsPatch test", + "storeId": "client.settings", + "input": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam", "riot"], + "healthCheckPerPlatform": {}, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": {}, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": true, + "pinHash": "legacy-hash" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam", "riot"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings language only", + "storeId": "client.settings", + "input": { + "language": "fr" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings ui scale only", + "storeId": "client.settings", + "input": { + "uiScalePercent": 110 + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 110, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings not an object", + "storeId": "client.settings", + "input": ["steam"], + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings null language", + "storeId": "client.settings", + "input": { + "language": null + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings legacy flat keys", + "storeId": "client.settings", + "input": { + "theme": "light", + "avatarCacheDays": "30", + "banCheckDays": null, + "showUsernames": false, + "showCardNotesInline": 1, + "expandedFolders": "", + "steamRunAsAdmin": true, + "steamLaunchOptions": " -novid -console ", + "accountDisplay": { + "showLastLogin": true, + "showRiotLastLogin": 0 + } + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "light", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 30, + "banCheckDays": 0 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": true, + "launchOptions": "-novid -console", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": false, + "showLastLoginPerPlatform": { + "steam": true, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": true, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings bad values", + "storeId": "client.settings", + "input": { + "language": "de", + "themeId": "nope", + "backgroundOpacity": 140.5, + "uiScalePercent": "abc", + "animations": "ON", + "streamerMode": "off", + "suspendGraphicsWhenMinimized": 0, + "minimizeOnAccountSwitch": "yes", + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": -3, + "banCheckDays": [45] + }, + "enabledPlatforms": ["riot", "riot", 42, "nope", "steam"], + "defaultPlatformId": "roblox", + "healthCheckPerPlatform": { + "roblox": null + }, + "inactivityBlurSeconds": 1000000000, + "deepLinksEnabled": null, + "cliEnabled": false, + "personasEnabled": false, + "pinEnabled": true, + "pinHash": " AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB ", + "platformSettings": { + "steam": { + "runAsAdmin": "", + "launchOptions": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "shutdownMode": "force" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": 1, + "discord": false, + "ghost": true + }, + "showCardNotesInline": null, + "expandedFolders": true + } + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "off", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": true, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 0, + "banCheckDays": 45 + }, + "enabledPlatforms": ["riot", "steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": false, + "defaultPlatformId": "riot", + "inactivityBlurSeconds": 3600, + "deepLinksEnabled": true, + "cliEnabled": false, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "shutdownMode": "force" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": true, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": false + }, + "showCardNotesInline": false, + "expandedFolders": true + }, + "pinEnabled": true, + "pinHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + { + "name": "settings pin without hash", + "storeId": "client.settings", + "input": { + "pinEnabled": true, + "pinHash": "1234" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings legacy pin hash", + "storeId": "client.settings", + "input": { + "pinEnabled": 1, + "pinHash": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": true, + "pinHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + }, + { + "name": "settings hash without pin", + "storeId": "client.settings", + "input": { + "pinEnabled": false, + "pinHash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings empty platforms", + "storeId": "client.settings", + "input": { + "enabledPlatforms": [], + "defaultPlatformId": 5, + "backgroundOpacity": " 42.5 ", + "uiScalePercent": "0x80", + "inactivityBlurSeconds": [], + "themeId": "", + "theme": "light" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 43, + "uiScalePercent": 128, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 0, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings custom theme id unknown here", + "storeId": "client.settings", + "input": { + "themeId": "midnight", + "animations": "off", + "dataRefresh": { + "avatarCacheDays": 0, + "banCheckDays": 90.5 + }, + "healthCheckPerPlatform": { + "roblox": false + }, + "platformSettings": { + "steam": "broken" + } + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "midnight", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "off", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { + "avatarCacheDays": 0, + "banCheckDays": 90 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": false + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "folders ghost bucket", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "live", + "name": "Live", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "live" + } + ], + "live": [ + { + "type": "account", + "id": "account-1" + } + ], + "ghost": [ + { + "type": "account", + "id": "account-2" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "live", + "name": "Live", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "live" + } + ], + "live": [ + { + "type": "account", + "id": "account-1" + } + ] + } + } + }, + { + "name": "folders root buckets of two platforms", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "account-1" + } + ], + "root:riot": [ + { + "type": "account", + "id": "account-2" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "account-1" + } + ], + "root:riot": [ + { + "type": "account", + "id": "account-2" + } + ] + } + } + }, + { + "name": "folders bare root prefix", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [], + "itemOrder": { + "root:": [ + { + "type": "account", + "id": "account-1" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [] + } + } + }, + { + "name": "folders move root account into folder", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "moveItem", + "item": { + "type": "account", + "id": "at-root" + }, + "from": null, + "to": "old", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + }, + { + "type": "account", + "id": "at-root" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders move account back to root", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "moveItem", + "item": { + "type": "account", + "id": "in-old" + }, + "from": "old", + "to": null, + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + }, + { + "type": "account", + "id": "in-old" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders move at index", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "moveItem", + "item": { + "type": "account", + "id": "at-root" + }, + "from": null, + "to": "smurfs", + "platform": "steam", + "index": 0 + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + } + ], + "smurfs": [ + { + "type": "account", + "id": "at-root" + }, + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders move folder into its own subtree", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "moveItem", + "item": { + "type": "folder", + "id": "smurfs" + }, + "from": null, + "to": "old", + "platform": "steam" + }, + "wrote": false, + "expected": null + }, + { + "name": "folders move folder to riot root", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "moveItem", + "item": { + "type": "folder", + "id": "old" + }, + "from": "smurfs", + "to": null, + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": null, + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + }, + { + "type": "folder", + "id": "old" + } + ], + "smurfs": [ + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders delete nested folder tree", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "deleteFolder", + "id": "smurfs" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "in-old" + }, + { + "type": "account", + "id": "in-smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders delete missing folder", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "deleteFolder", + "id": "nope" + }, + "wrote": false, + "expected": null + }, + { + "name": "folders rename", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "renameFolder", + "id": "old", + "name": "Older" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Older", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders sync accounts", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "syncAccounts", + "ids": ["at-root", "in-old", "new-1", "new-2"], + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + }, + { + "type": "account", + "id": "new-1" + }, + { + "type": "account", + "id": "new-2" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders sync unchanged", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "syncAccounts", + "ids": ["at-root", "in-smurfs", "in-old"], + "platform": "steam" + }, + "wrote": false, + "expected": null + }, + { + "name": "folders sync new platform", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "syncAccounts", + "ids": ["epic-1"], + "platform": "epic" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ], + "root:epic": [ + { + "type": "account", + "id": "epic-1" + } + ] + } + } + }, + { + "name": "folders bad values", + "storeId": "client.folders", + "input": { + "folders": [ + { + "id": " a ", + "name": " A ", + "parentId": " ", + "platform": " steam " + }, + { + "id": "b", + "name": "", + "platform": "steam" + }, + { + "id": "c", + "name": "C", + "parentId": 5, + "platform": "steam" + }, + "junk", + null + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "a" + }, + { + "type": "folder", + "id": "b" + }, + { + "type": "account", + "id": " x " + }, + { + "type": "account", + "id": "" + }, + { + "type": "user", + "id": "y" + }, + null + ], + "a": "not a list", + "c": [ + { + "type": "folder", + "id": "c" + } + ], + " ": [ + { + "type": "account", + "id": "z" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "a", + "name": "A", + "parentId": null, + "platform": "steam" + }, + { + "id": "c", + "name": "C", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "a" + }, + { + "type": "account", + "id": "x" + } + ], + "c": [ + { + "type": "folder", + "id": "c" + } + ] + } + } + }, + { + "name": "folders not an object", + "storeId": "client.folders", + "input": [1, 2], + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [] + } + } + }, + { + "name": "personas bad values", + "storeId": "client.personas", + "input": [ + { + "id": "p1", + "name": " Big brother ", + "color": "#A1b2C3", + "image": "data:image/png;base64,AAAA", + "assignments": [ + { + "platformId": "steam", + "accountId": "s1" + }, + { + "platformId": "steam", + "accountId": "s2" + }, + { + "platformId": "ghost", + "accountId": "g1" + }, + { + "platformId": "riot", + "accountId": "" + }, + { + "platformId": "riot", + "accountId": 7 + }, + { + "platformId": "discord", + "accountId": "d1" + }, + [] + ] + }, + { + "id": "p1", + "name": "Duplicate" + }, + { + "id": "", + "name": "No id" + }, + { + "name": "No id" + }, + { + "id": "p2", + "name": " ", + "color": "#abc", + "image": "https://example.com/a.png", + "assignments": "nope" + }, + { + "id": "p3", + "name": "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", + "color": 5, + "image": "data:image/png;aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "id": "p4", + "name": "Emoji 😀 name", + "image": null + }, + "junk" + ], + "op": { + "kind": "deletePersona", + "id": "__none__" + }, + "wrote": true, + "expected": [ + { + "id": "p1", + "name": "Big brother", + "color": "#A1b2C3", + "image": "data:image/png;base64,AAAA", + "assignments": [ + { + "platformId": "steam", + "accountId": "s1" + }, + { + "platformId": "discord", + "accountId": "d1" + } + ] + }, + { + "id": "p2", + "name": "Persona", + "color": "", + "image": null, + "assignments": [] + }, + { + "id": "p3", + "name": "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", + "color": "", + "image": null, + "assignments": [] + }, + { + "id": "p4", + "name": "Emoji 😀 name", + "color": "", + "image": null, + "assignments": [] + } + ] + }, + { + "name": "personas not a list", + "storeId": "client.personas", + "input": { + "id": "p1" + }, + "op": { + "kind": "deletePersona", + "id": "__none__" + }, + "wrote": true, + "expected": [] + }, + { + "name": "personas update", + "storeId": "client.personas", + "input": [ + { + "id": "p1", + "name": "One", + "color": "", + "image": null, + "assignments": [] + }, + { + "id": "p2", + "name": "Two", + "color": "", + "image": null, + "assignments": [] + } + ], + "op": { + "kind": "updatePersona", + "id": "p2", + "patch": { + "name": " Renamed ", + "color": "#123456", + "assignments": [ + { + "platformId": "roblox", + "accountId": "r1" + } + ] + } + }, + "wrote": true, + "expected": [ + { + "id": "p1", + "name": "One", + "color": "", + "image": null, + "assignments": [] + }, + { + "id": "p2", + "name": "Renamed", + "color": "#123456", + "image": null, + "assignments": [ + { + "platformId": "roblox", + "accountId": "r1" + } + ] + } + ] + }, + { + "name": "personas delete", + "storeId": "client.personas", + "input": [ + { + "id": "p1", + "name": "One", + "color": "", + "image": null, + "assignments": [] + }, + { + "id": "p2", + "name": "Two", + "color": "", + "image": null, + "assignments": [] + } + ], + "op": { + "kind": "deletePersona", + "id": "p1" + }, + "wrote": true, + "expected": [ + { + "id": "p2", + "name": "Two", + "color": "", + "image": null, + "assignments": [] + } + ] + }, + { + "name": "notes bad values", + "storeId": "client.account-card-notes", + "input": { + "acc1": " hello ", + "acc2": "line1\u0000line2\u0001line3…end", + "acc3": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "acc4": " ", + "acc5": 5, + " ": "blank key", + "acc6": "😀 emoji" + }, + "op": { + "kind": "clearNote", + "id": "__none__" + }, + "wrote": true, + "expected": { + "acc1": "hello", + "acc2": "line1 line2 line3 end", + "acc3": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "acc6": "😀 emoji" + } + }, + { + "name": "notes set", + "storeId": "client.account-card-notes", + "input": { + "acc1": "keep" + }, + "op": { + "kind": "setNote", + "id": "acc2", + "note": " new\tnote " + }, + "wrote": true, + "expected": { + "acc1": "keep", + "acc2": "new note" + } + }, + { + "name": "notes set empty removes", + "storeId": "client.account-card-notes", + "input": { + "acc1": "keep", + "acc2": "gone" + }, + "op": { + "kind": "setNote", + "id": "acc2", + "note": " \u0000 " + }, + "wrote": true, + "expected": { + "acc1": "keep" + } + }, + { + "name": "colors bad values", + "storeId": "client.account-card-colors", + "input": { + "a": "#ff0000", + "b": "#abc", + "c": "#aabbccdd", + "d": "red", + "e": "#xyz", + "f": "#abcd", + "g": 5, + "": "#ffffff" + }, + "op": { + "kind": "setColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": { + "a": "#ff0000", + "b": "#abc", + "c": "#aabbccdd" + } + }, + { + "name": "colors set many", + "storeId": "client.account-card-colors", + "input": { + "keep": "#000" + }, + "op": { + "kind": "setColors", + "ids": ["a", "b"], + "color": "#3b82f6" + }, + "wrote": true, + "expected": { + "keep": "#000", + "a": "#3b82f6", + "b": "#3b82f6" + } + }, + { + "name": "colors set many invalid removes", + "storeId": "client.account-card-colors", + "input": { + "a": "#000", + "b": "#111", + "c": "#222" + }, + "op": { + "kind": "setColors", + "ids": ["a", "b"], + "color": "bad" + }, + "wrote": true, + "expected": { + "c": "#222" + } + }, + { + "name": "colors set many empty list", + "storeId": "client.account-card-colors", + "input": { + "a": "#000" + }, + "op": { + "kind": "setColors", + "ids": [], + "color": "#fff" + }, + "wrote": false, + "expected": null + }, + { + "name": "folder colors bad values", + "storeId": "client.folder-card-colors", + "input": { + "f1": "#123456", + "f2": "blue" + }, + "op": { + "kind": "setFolderColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": { + "f1": "#123456" + } + }, + { + "name": "default game bad values", + "storeId": "client.account-default-game", + "input": { + "s1": { + "appId": " 730 ", + "name": "CS2" + }, + "s2": { + "appId": "abc", + "name": "Bad" + }, + "s3": { + "appId": "570" + }, + "s4": { + "appId": "440", + "name": 5 + }, + "s5": [], + "s6": "730", + " ": { + "appId": "1", + "name": "blank key" + } + }, + "op": { + "kind": "setDefaultGame", + "id": "__roundtrip__", + "game": { + "appId": " 730 ", + "name": "Counter-Strike 2" + } + }, + "wrote": true, + "expected": { + "s1": { + "appId": "730", + "name": "CS2" + }, + "s3": { + "appId": "570", + "name": "" + }, + "s4": { + "appId": "440", + "name": "" + }, + "__roundtrip__": { + "appId": "730", + "name": "Counter-Strike 2" + } + } + }, + { + "name": "default game invalid set", + "storeId": "client.account-default-game", + "input": { + "s1": { + "appId": "730", + "name": "CS2" + } + }, + "op": { + "kind": "setDefaultGame", + "id": "s2", + "game": { + "appId": "x1", + "name": "Bad" + } + }, + "wrote": false, + "expected": null + }, + { + "name": "view mode list", + "storeId": "client.view-mode", + "input": "list", + "op": { + "kind": "resaveViewMode" + }, + "wrote": true, + "expected": "list" + }, + { + "name": "view mode other", + "storeId": "client.view-mode", + "input": 5, + "op": { + "kind": "resaveViewMode" + }, + "wrote": true, + "expected": "grid" + } +] diff --git a/crates/accshift-app/tests/display_golden.rs b/crates/accshift-app/tests/display_golden.rs new file mode 100644 index 00000000..c97e5a1c --- /dev/null +++ b/crates/accshift-app/tests/display_golden.rs @@ -0,0 +1,226 @@ +//! Golden display lists of the demo scenario on the Steam tab, against the +//! Tauri reference captures (`spike-main`: hover, folder and search shots). +//! The accounts load through `AccountLoading` and the job pool over the +//! mock, as the shell does. + +mod support; + +use accshift_app::accounts::loader::LoadOptions; +use accshift_app::accounts::loading::{AccountLoading, LoadingContext}; +use accshift_app::accounts::profile_cache::{ProfileCaches, UrlPolicy}; +use accshift_app::display::{ + build_display_view, CardContext, DisplayBody, DisplayItem, DisplayLists, DisplayPipeline, + DisplayView, DragPreview, FolderNavigation, PipelineInput, +}; +use accshift_app::jobs::Jobs; +use accshift_app::platforms::{PlatformRegistry, RuntimeOs}; +use accshift_app::storage; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::time::Clock; +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const TAB: &str = "steam"; +const SMURFS: &str = "demo-folder-smurfs"; + +struct Demo { + jobs: Jobs, + registry: PlatformRegistry, + stores: ClientStores, + loading: AccountLoading, + clock: Clock, +} + +impl Demo { + fn load() -> Self { + let backend = support::mock("demo"); + let (storage, report) = storage::initialize(&backend, None, None, true); + assert!(report.load_error.is_none(), "{:?}", report.load_error); + let stores = ClientStores::new(storage, StoreContext::shipped()); + let clock = Clock::mock(1_791_000_000); + let caches = Arc::new(ProfileCaches::new(UrlPolicy::HttpOrFile, clock.now_ms())); + let mut demo = Demo { + jobs: Jobs::new(Arc::new(backend), 2, || {}), + registry: PlatformRegistry::shipped(), + stores, + loading: AccountLoading::new(Some(TAB.to_string()), caches), + clock, + }; + assert!(demo.with_ctx(&[], |loading, ctx| loading + .load_accounts(ctx, LoadOptions::default()))); + let navigation = root_navigation(&demo); + demo.settle(&navigation); + assert!(!demo.loading.loader.loading()); + demo + } + + fn with_ctx( + &mut self, + visible: &[String], + run: impl FnOnce(&mut AccountLoading, &mut LoadingContext<'_>) -> R, + ) -> R { + let mut ctx = LoadingContext { + jobs: &self.jobs, + registry: &self.registry, + stores: &mut self.stores, + active_tab: TAB, + runtime_os: RuntimeOs::Windows, + visible_ids: visible, + is_searching: false, + clock: &self.clock, + now: Instant::now(), + }; + run(&mut self.loading, &mut ctx) + } + + /// The shell's frame loop until every job, deferred step and priming + /// timer landed: poll, recompute the grid, track what it shows. + fn settle(&mut self, navigation: &FolderNavigation) { + let deadline = Instant::now() + Duration::from_secs(20); + let mut pipeline = DisplayPipeline::new(); + let mut visible: Vec = Vec::new(); + loop { + self.with_ctx(&visible, |loading, ctx| loading.poll(ctx)); + visible = self.view(navigation, &mut pipeline).0.visible_account_ids; + self.with_ctx(&visible, |loading, ctx| loading.track_priming(ctx, false)); + if !self.loading.has_pending_work() && self.loading.priming.next_deadline().is_none() { + break; + } + assert!(Instant::now() < deadline, "jobs did not settle"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn view( + &self, + navigation: &FolderNavigation, + pipeline: &mut DisplayPipeline, + ) -> (DisplayLists, DisplayView) { + let lists = pipeline.compute(&PipelineInput { + navigation, + drag: DragPreview::default(), + accounts: self.loading.loader.accounts(), + pending_setup_account: None, + expanded_folders: false, + active_tab: TAB, + stores: &self.stores, + }); + let adapter = self.registry.adapter(TAB); + let collapsed = BTreeSet::new(); + let view = build_display_view( + &CardContext { + loader: &self.loading.loader, + stores: &self.stores, + navigation, + platform: self.registry.definition(TAB), + adapter: adapter.as_deref(), + active_tab: TAB, + pending_setup_account: None, + bulk_selection: None, + drag_item: None, + collapsed_sections: &collapsed, + }, + &lists, + ); + (lists, view) + } +} + +fn root_navigation(demo: &Demo) -> FolderNavigation { + let mut navigation = FolderNavigation::new(); + navigation.refresh_current_items(&demo.stores, TAB); + navigation +} + +#[test] +fn golden_root_matches_the_tauri_grid() { + let demo = Demo::load(); + let navigation = root_navigation(&demo); + let mut pipeline = DisplayPipeline::new(); + let (lists, view) = demo.view(&navigation, &mut pipeline); + assert_eq!( + view.labels(), + [ + "Smurfs", + "main", + "bro's account", + "alt", + "trading", + "faceit", + "ranked grind", + "chill", + "lan party", + "streams", + "esea", + "tournaments", + "family", + "practice", + ] + ); + assert_eq!(lists.rendered_account_count, 17); + + // The background refresh after the load filled the avatars from the + // mock files; the card colours come from the demo store. + let cards: Vec<_> = view + .items() + .iter() + .filter_map(|item| match item { + DisplayItem::Account(card) => Some(card), + _ => None, + }) + .collect(); + assert_eq!(cards.len(), 13); + let main = cards[0]; + assert_eq!(main.card_color, "#8b5cf6"); + assert!(main.active, "the demo's current account is main"); + let with_avatar = cards + .iter() + .filter(|card| card.avatar.url.is_some()) + .count(); + assert_eq!(with_avatar, cards.len(), "every visible card has an avatar"); + assert!(cards + .iter() + .all(|card| !card.avatar.loading && !card.avatar.pending)); + match &view.items()[0] { + DisplayItem::Folder(folder) => assert_eq!(folder.folder.id, SMURFS), + other => panic!("expected the Smurfs folder first, got {other:?}"), + } +} + +#[test] +fn golden_smurfs_folder_matches_the_tauri_grid() { + let demo = Demo::load(); + let mut navigation = FolderNavigation::new(); + navigation.set_current_folder_id(Some(SMURFS.to_string())); + navigation.refresh_current_items(&demo.stores, TAB); + let mut pipeline = DisplayPipeline::new(); + let (_, view) = demo.view(&navigation, &mut pipeline); + assert_eq!( + view.labels(), + ["Back", "smurf 1", "smurf 2", "smurf 3", "smurf 4"] + ); + assert!(matches!( + view.body, + DisplayBody::Items { + folder_empty: false, + .. + } + )); +} + +#[test] +fn golden_search_ra_matches_the_tauri_grid() { + let demo = Demo::load(); + let mut navigation = root_navigation(&demo); + let mut pipeline = DisplayPipeline::new(); + let start = Instant::now(); + navigation.set_search_query("ra"); + pipeline.set_query("ra", start); + // Inside the 80 ms debounce the grid still shows the root. + assert!(!pipeline.tick(start)); + assert!(pipeline.tick(start + Duration::from_millis(80))); + let (lists, view) = demo.view(&navigation, &mut pipeline); + assert!(lists.search_layout); + assert_eq!(view.labels(), ["trading", "ranked grind", "practice"]); +} diff --git a/crates/accshift-app/tests/display_pipeline.rs b/crates/accshift-app/tests/display_pipeline.rs new file mode 100644 index 00000000..1ead712f --- /dev/null +++ b/crates/accshift-app/tests/display_pipeline.rs @@ -0,0 +1,166 @@ +//! `src/lib/app/useDisplayPipeline.test.ts`, ported, plus the search fold +//! and the pipeline behaviour the TS deriveds carry (debounce, folder +//! flattening, pending setup card). + +use accshift_app::display::{ + collect_visible_account_ids, fold_search_text, matches_search, section_collapse_key, + DisplayPipeline, SEARCH_DEBOUNCE, +}; +use accshift_app::folders::{FolderInfo, FolderSection, ItemKind, ItemRef}; +use accshift_app::platforms::PlatformAccount; +use std::collections::BTreeSet; +use std::time::Instant; + +fn make_account(id: &str, username: &str, display_name: &str) -> PlatformAccount { + PlatformAccount { + id: id.into(), + display_name: display_name.into(), + username: username.into(), + last_login_at_sec: None, + } +} + +fn default_account() -> PlatformAccount { + make_account("acc-1", "testuser", "Test User") +} + +// ------------------------------------------------------------ matchesSearch + +#[test] +fn matches_by_id() { + let account = make_account("user123", "testuser", "Test User"); + assert!(matches_search(&account, "user123", "")); +} + +#[test] +fn matches_by_username() { + let account = make_account("acc-1", "alice", "Test User"); + assert!(matches_search(&account, "alice", "")); +} + +#[test] +fn matches_by_display_name() { + let account = make_account("acc-1", "testuser", "Bob Smith"); + assert!(matches_search(&account, "bob", "")); +} + +#[test] +fn is_case_insensitive() { + let account = make_account("acc-1", "Alice", "Test User"); + assert!(matches_search(&account, "alice", "")); +} + +#[test] +fn returns_false_for_no_match() { + assert!(!matches_search(&default_account(), "zzzzz", "")); +} + +#[test] +fn handles_empty_display_name() { + let account = make_account("xyz", "xyz", ""); + assert!(!matches_search(&account, "test", "")); +} + +#[test] +fn matches_partial_strings() { + let account = make_account("longid12345", "testuser", "Test User"); + assert!(matches_search(&account, "id123", "")); +} + +#[test] +fn matches_the_card_note_and_ignores_accents() { + let account = make_account("acc-1", "testuser", "Zoé"); + assert!(matches_search(&account, "zoe", "")); + assert!(matches_search(&account, "ZOÉ", "")); + assert!(matches_search(&default_account(), "smurf", "Smurf for EU")); +} + +#[test] +fn folds_like_nfd_without_combining_marks() { + assert_eq!(fold_search_text("Crème Brûlée"), "creme brulee"); + assert_eq!(fold_search_text("ÅNGSTRÖM"), "angstrom"); + assert_eq!(fold_search_text("e\u{0301}"), "e"); + // Hangul decomposes into jamo, as `normalize("NFD")` does. + assert_eq!(fold_search_text("한"), "\u{1112}\u{1161}\u{11ab}"); + // No decomposition: kept as is, lowercased. + assert_eq!(fold_search_text("ß Ω"), "ß ω"); +} + +// ------------------------------------------------------------ collectVisibleAccountIds + +fn account(id: &str) -> ItemRef { + ItemRef::account(id) +} + +fn section(folder_id: Option<&str>, account_ids: &[&str]) -> FolderSection { + FolderSection { + folder: folder_id.map(|id| FolderInfo { + id: id.into(), + name: id.into(), + parent_id: None, + platform: "steam".into(), + }), + folder_items: Vec::new(), + account_items: account_ids.iter().map(|id| account(id)).collect(), + } +} + +fn set(keys: &[&str]) -> BTreeSet { + keys.iter().map(|key| key.to_string()).collect() +} + +#[test] +fn leaves_out_accounts_of_collapsed_sections() { + let sections = [ + section(None, &["r1", "r2"]), + section(Some("f1"), &["a1"]), + section(Some("f2"), &["b1"]), + ]; + assert_eq!( + collect_visible_account_ids(Some(§ions), &[], &set(&["f1"])), + ["r1", "r2", "b1"] + ); +} + +#[test] +fn collapses_the_root_section_by_its_own_key() { + let sections = [section(None, &["r1"]), section(Some("f1"), &["a1"])]; + let collapsed = set(&[section_collapse_key(§ions[0])]); + assert_eq!( + collect_visible_account_ids(Some(§ions), &[], &collapsed), + ["a1"] + ); +} + +#[test] +fn uses_the_flat_list_outside_sections_mode_and_drops_duplicates() { + let items = [ + account("a1"), + ItemRef { + kind: ItemKind::Folder, + id: "f1".into(), + }, + account("a1"), + account("a2"), + ]; + assert_eq!( + collect_visible_account_ids(None, &items, &set(&["f1"])), + ["a1", "a2"] + ); +} + +// ------------------------------------------------------------ debounce + +#[test] +fn the_search_debounce_applies_the_last_query_only() { + let mut pipeline = DisplayPipeline::new(); + let start = Instant::now(); + pipeline.set_query("r", start); + pipeline.set_query("ra", start + SEARCH_DEBOUNCE / 2); + assert!(!pipeline.tick(start + SEARCH_DEBOUNCE)); + assert_eq!(pipeline.debounced_query(), ""); + assert!(pipeline.tick(start + SEARCH_DEBOUNCE * 2)); + assert_eq!(pipeline.debounced_query(), "ra"); + assert!(pipeline.is_search_layout()); + assert!(pipeline.next_deadline().is_none()); +} diff --git a/crates/accshift-app/tests/feature_stores.rs b/crates/accshift-app/tests/feature_stores.rs new file mode 100644 index 00000000..cd5f88bb --- /dev/null +++ b/crates/accshift-app/tests/feature_stores.rs @@ -0,0 +1,670 @@ +//! The TS tests of the ported stores (settings, folders, personas, the +//! small shared stores), as Rust tests. + +use accshift_app::folders::{FolderStore, ItemRef}; +use accshift_app::personas::{persona_assignments_for_save, AssignmentsForSave, PersonaAssignment}; +use accshift_app::settings::{ + api_key_draft_after_successful_save, complete_onboarding_consent, diff_settings, + merge_settings_draft, AnimationsMode, AppSettings, OnboardingConsentOutcome, SettingsChange, + StreamerMode, +}; +use accshift_app::storage::{ + ClientStorage, CLIENT_STORE_ACCOUNT_CARD_COLORS, CLIENT_STORE_ACCOUNT_CARD_NOTES, + CLIENT_STORE_FOLDERS, +}; +use accshift_app::stores::{ + ClientStores, StoreContext, StringMapStore, ACCOUNT_CARD_COLOR_PRESETS, +}; +use accshift_app::types::ClientStorageSnapshot; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +// ------------------------------------------------------------ settingsPatch + +fn settings(edit: impl FnOnce(&mut Value)) -> AppSettings { + let mut value = json!({ + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "startWithSystem": false, + "closeToTray": false, + "dataRefresh": { "avatarCacheDays": 7, "banCheckDays": 7 }, + "enabledPlatforms": ["steam", "riot"], + "healthCheckPerPlatform": {}, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { "runAsAdmin": false, "launchOptions": "", "shutdownMode": "graceful" } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": {}, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": true, + "pinHash": "legacy-hash" + }); + edit(&mut value); + serde_json::from_value(value).unwrap() +} + +fn base() -> AppSettings { + settings(|_| {}) +} + +fn merge(current: &AppSettings, baseline: &AppSettings, draft: &AppSettings) -> AppSettings { + merge_settings_draft(current, baseline, draft).unwrap() +} + +#[test] +fn keeps_a_zoom_change_made_elsewhere_while_the_panel_was_open() { + let current = settings(|s| s["uiScalePercent"] = json!(120)); + let draft = settings(|s| s["minimizeOnAccountSwitch"] = json!(true)); + let merged = merge(¤t, &base(), &draft); + assert_eq!(merged.ui_scale_percent, 120); + assert!(merged.minimize_on_account_switch); +} + +#[test] +fn keeps_the_streamer_flag_and_a_rehashed_pin_written_elsewhere() { + let current = settings(|s| { + s["streamerMode"] = json!("off"); + s["pinHash"] = json!("upgraded-hash"); + }); + let draft = settings(|s| s["themeId"] = json!("light")); + let merged = merge(¤t, &base(), &draft); + assert_eq!(merged.streamer_mode, StreamerMode::Off); + assert_eq!(merged.pin_hash, "upgraded-hash"); + assert_eq!(merged.theme_id, "light"); +} + +#[test] +fn merges_nested_fields_one_leaf_at_a_time() { + let current = settings(|s| s["accountDisplay"]["expandedFolders"] = json!(true)); + let draft = settings(|s| s["accountDisplay"]["showUsernames"] = json!(false)); + let merged = merge(¤t, &base(), &draft); + assert!(merged.account_display.expanded_folders); + assert!(!merged.account_display.show_usernames); +} + +#[test] +fn replaces_an_edited_list_whole() { + let current = settings(|s| s["enabledPlatforms"] = json!(["steam", "riot", "roblox"])); + let draft = settings(|s| { + s["enabledPlatforms"] = json!(["riot"]); + s["defaultPlatformId"] = json!("riot"); + }); + let merged = merge(¤t, &base(), &draft); + assert_eq!(merged.enabled_platforms, ["riot"]); + assert_eq!(merged.default_platform_id, "riot"); +} + +#[test] +fn applies_the_users_own_edit_over_a_different_value_written_elsewhere() { + let merged = merge( + &settings(|s| s["uiScalePercent"] = json!(120)), + &base(), + &settings(|s| s["uiScalePercent"] = json!(90)), + ); + assert_eq!(merged.ui_scale_percent, 90); +} + +#[test] +fn leaves_the_current_settings_untouched() { + let current = base(); + merge( + ¤t, + &base(), + &settings(|s| s["language"] = json!("fr")), + ); + assert_eq!(current, base()); +} + +#[test] +fn rebases_an_open_panel_and_keeps_its_own_edits() { + let baseline = base(); + let draft = settings(|s| s["language"] = json!("fr")); + let stored = settings(|s| s["uiScalePercent"] = json!(110)); + let shown = merge(&stored, &baseline, &draft); + assert_eq!(shown.ui_scale_percent, 110); + assert_eq!(shown.language.code(), "fr"); + let saved = merge(&stored, &stored, &shown); + assert_eq!( + saved, + settings(|s| { + s["language"] = json!("fr"); + s["uiScalePercent"] = json!(110); + }) + ); + assert_eq!( + diff_settings(Some(&stored.to_value()), Some(&shown.to_value())), + [SettingsChange { + path: vec!["language".to_string()], + value: Some(json!("fr")), + }] + ); +} + +#[test] +fn diff_reports_nothing_when_the_draft_matches() { + assert!(diff_settings(Some(&base().to_value()), Some(&base().to_value())).is_empty()); +} + +#[test] +fn diff_reports_the_path_of_each_changed_leaf() { + let after = settings(|s| { + s["dataRefresh"] = json!({ "avatarCacheDays": 3, "banCheckDays": 7 }); + s["enabledPlatforms"] = json!(["steam"]); + }); + assert_eq!( + diff_settings(Some(&base().to_value()), Some(&after.to_value())), + [ + SettingsChange { + path: vec!["dataRefresh".into(), "avatarCacheDays".into()], + value: Some(json!(3)), + }, + SettingsChange { + path: vec!["enabledPlatforms".into()], + value: Some(json!(["steam"])), + }, + ] + ); +} + +// ------------------------------------------------------------ apiKeyDraft, onboardingConsent + +#[test] +fn clears_the_api_key_input_when_it_still_holds_the_saved_key() { + let draft = api_key_draft_after_successful_save(" steam-key ", "steam-key"); + assert_eq!(draft.api_key, ""); + assert!(!draft.touched); +} + +#[test] +fn keeps_a_replacement_key_typed_while_the_first_save_was_in_flight() { + let draft = api_key_draft_after_successful_save("next-key", "steam-key"); + assert_eq!(draft.api_key, "next-key"); + assert!(draft.touched); +} + +#[test] +fn onboarding_consent_returns_saved_when_persisted() { + assert_eq!( + complete_onboarding_consent(|| Ok::<(), String>(())), + OnboardingConsentOutcome::Saved + ); +} + +#[test] +fn onboarding_consent_returns_failed_without_panicking() { + assert_eq!( + complete_onboarding_consent(|| Err::<(), _>("disk")), + OnboardingConsentOutcome::Failed + ); +} + +// ------------------------------------------------------------ folders + +fn stores_with(store_id: &str, value: Value) -> ClientStores { + let snapshot = ClientStorageSnapshot { + manifest: Default::default(), + stores: BTreeMap::from([(store_id.to_string(), value)]), + }; + ClientStores::new( + ClientStorage::from_snapshot(snapshot), + StoreContext::shipped(), + ) +} + +fn saved(stores: &mut ClientStores, store_id: &str) -> Value { + stores + .take_all_saves() + .into_iter() + .find(|save| save.store_id == store_id) + .expect("saved") + .value +} + +#[test] +fn drops_an_item_order_bucket_keyed_by_a_folder_that_no_longer_exists() { + let mut stores = stores_with( + CLIENT_STORE_FOLDERS, + json!({ + "version": 1, + "folders": [{ "id": "live", "name": "Live", "parentId": null, "platform": "steam" }], + "itemOrder": { + "root:steam": [{ "type": "folder", "id": "live" }], + "live": [{ "type": "account", "id": "account-1" }], + "ghost": [{ "type": "account", "id": "account-2" }] + } + }), + ); + assert_eq!( + stores.items_in_folder(Some("live"), "steam"), + [ItemRef::account("account-1")] + ); + assert!(stores.items_in_folder(Some("ghost"), "steam").is_empty()); + stores.create_folder("New", None, "steam"); + let value = saved(&mut stores, CLIENT_STORE_FOLDERS); + assert!(value["itemOrder"].get("ghost").is_none()); +} + +#[test] +fn keeps_every_platform_root_bucket() { + let stores = stores_with( + CLIENT_STORE_FOLDERS, + json!({ + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [{ "type": "account", "id": "account-1" }], + "root:riot": [{ "type": "account", "id": "account-2" }] + } + }), + ); + assert_eq!( + stores.items_in_folder(None, "steam"), + [ItemRef::account("account-1")] + ); + assert_eq!( + stores.items_in_folder(None, "riot"), + [ItemRef::account("account-2")] + ); +} + +#[test] +fn drops_a_bare_root_prefix() { + let mut stores = stores_with( + CLIENT_STORE_FOLDERS, + json!({ "version": 1, "folders": [], "itemOrder": { "root:": [{ "type": "account", "id": "account-1" }] } }), + ); + stores.create_folder("New", None, "steam"); + assert!(saved(&mut stores, CLIENT_STORE_FOLDERS)["itemOrder"] + .get("root:") + .is_none()); +} + +fn move_store() -> ClientStores { + stores_with( + CLIENT_STORE_FOLDERS, + json!({ + "version": 1, + "folders": [ + { "id": "smurfs", "name": "Smurfs", "parentId": null, "platform": "steam" }, + { "id": "old", "name": "Old", "parentId": "smurfs", "platform": "steam" }, + { "id": "riot-folder", "name": "Ranked", "parentId": null, "platform": "riot" } + ], + "itemOrder": { + "root:steam": [{ "type": "folder", "id": "smurfs" }, { "type": "account", "id": "at-root" }], + "smurfs": [{ "type": "folder", "id": "old" }, { "type": "account", "id": "in-smurfs" }], + "old": [{ "type": "account", "id": "in-old" }], + "root:riot": [{ "type": "folder", "id": "riot-folder" }] + } + }), + ) +} + +fn ids(folders: &FolderStore, platform: &str) -> Vec { + folders + .list_folders(platform) + .iter() + .map(|folder| folder.id.clone()) + .collect() +} + +#[test] +fn lists_only_the_folders_of_the_active_platform() { + let stores = move_store(); + assert_eq!(ids(stores.folders(), "steam"), ["smurfs", "old"]); + assert_eq!(ids(stores.folders(), "riot"), ["riot-folder"]); +} + +#[test] +fn finds_the_folder_holding_an_account() { + let stores = move_store(); + let folders = stores.folders(); + assert_eq!( + folders.find_item_folder_id(&ItemRef::account("in-smurfs"), "steam"), + Some("smurfs") + ); + assert_eq!( + folders.find_item_folder_id(&ItemRef::account("in-old"), "steam"), + Some("old") + ); +} + +#[test] +fn reads_an_account_at_the_platform_root_as_no_folder() { + let stores = move_store(); + let folders = stores.folders(); + assert_eq!( + folders.find_item_folder_id(&ItemRef::account("at-root"), "steam"), + None + ); + assert_eq!( + folders.find_item_folder_id(&ItemRef::account("unknown"), "steam"), + None + ); +} + +#[test] +fn puts_a_root_account_into_a_folder() { + let mut stores = move_store(); + stores.move_item( + &ItemRef::account("at-root"), + None, + Some("old"), + "steam", + None, + ); + assert_eq!( + stores.items_in_folder(Some("old"), "steam"), + [ItemRef::account("in-old"), ItemRef::account("at-root")] + ); + assert_eq!( + stores.items_in_folder(None, "steam"), + [ItemRef::folder("smurfs")] + ); +} + +#[test] +fn sends_an_account_back_to_the_root() { + let mut stores = move_store(); + stores.move_item( + &ItemRef::account("in-old"), + Some("old"), + None, + "steam", + None, + ); + assert!(stores.items_in_folder(Some("old"), "steam").is_empty()); + assert_eq!( + stores.items_in_folder(None, "steam"), + [ + ItemRef::folder("smurfs"), + ItemRef::account("at-root"), + ItemRef::account("in-old") + ] + ); +} + +// ------------------------------------------------------------ personaAssignmentsForSave + +fn steam() -> PersonaAssignment { + PersonaAssignment { + platform_id: "steam".into(), + account_id: "steam-1".into(), + } +} + +fn discord() -> PersonaAssignment { + PersonaAssignment { + platform_id: "discord".into(), + account_id: "discord-1".into(), + } +} + +struct Save { + offered: Vec, + selection: BTreeMap, + accounts: BTreeMap>, + failed: Vec, +} + +impl Save { + fn new() -> Self { + Self { + offered: vec!["steam".into()], + selection: map(&[("steam", "steam-1")]), + accounts: BTreeMap::from([("steam".into(), vec!["steam-1".into()])]), + failed: Vec::new(), + } + } + fn run(&self) -> Vec { + let original = [steam(), discord()]; + persona_assignments_for_save(&AssignmentsForSave { + original: &original, + offered_platform_ids: &self.offered, + selection: &self.selection, + accounts_by_platform: &self.accounts, + loading: false, + failed_platform_ids: &self.failed, + }) + } +} + +fn map(entries: &[(&str, &str)]) -> BTreeMap { + entries + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() +} + +#[test] +fn keeps_an_assignment_for_a_platform_the_wizard_is_not_offering() { + assert_eq!(Save::new().run(), [steam(), discord()]); +} + +#[test] +fn keeps_an_assignment_when_that_platforms_account_read_failed() { + let mut save = Save::new(); + save.offered = vec!["steam".into(), "discord".into()]; + save.selection = map(&[("steam", "steam-1"), ("discord", "discord-1")]); + save.accounts.insert("discord".into(), Vec::new()); + save.failed = vec!["discord".into()]; + assert_eq!(save.run(), [steam(), discord()]); +} + +#[test] +fn drops_an_assignment_a_successful_read_proved_is_gone() { + let mut save = Save::new(); + save.offered = vec!["steam".into(), "discord".into()]; + save.selection = map(&[("steam", "steam-1"), ("discord", "discord-1")]); + save.accounts.insert("discord".into(), Vec::new()); + assert_eq!(save.run(), [steam()]); +} + +#[test] +fn drops_a_cleared_platform_and_keeps_the_one_it_could_not_edit() { + let mut save = Save::new(); + save.selection = map(&[("steam", "")]); + assert_eq!(save.run(), [discord()]); +} + +#[test] +fn appends_a_newly_picked_platform_after_the_original_assignments() { + let mut save = Save::new(); + save.offered = vec!["steam".into(), "riot".into()]; + save.selection = map(&[("steam", "steam-1"), ("riot", "riot-1")]); + save.accounts.insert("riot".into(), vec!["riot-1".into()]); + let riot = PersonaAssignment { + platform_id: "riot".into(), + account_id: "riot-1".into(), + }; + assert_eq!(save.run(), [steam(), discord(), riot]); +} + +// ------------------------------------------------------------ cachedMapStore + +fn identity(_key: &str, value: &str) -> Option { + Some(value.to_string()) +} + +fn refuse_bad(_key: &str, value: &str) -> Option { + (value != "bad").then(|| value.to_string()) +} + +#[test] +fn map_store_accepts_valid_entries() { + fn non_empty(_key: &str, value: &str) -> Option { + (!value.is_empty()).then(|| value.to_string()) + } + let mut store = StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, non_empty); + store.set("a", "hello"); + assert_eq!(store.get("a"), "hello"); +} + +#[test] +fn map_store_get_returns_empty_for_unknown_keys() { + let store = StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, identity); + assert_eq!(store.get("nonexistent"), ""); +} + +#[test] +fn map_store_set_stores_and_retrieves_values() { + let mut store = StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, identity); + store.set("acc1", "blue"); + assert_eq!(store.get("acc1"), "blue"); +} + +#[test] +fn map_store_refused_value_removes_the_entry() { + let mut store = StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, refuse_bad); + store.set("acc1", "good"); + assert_eq!(store.get("acc1"), "good"); + store.set("acc1", "bad"); + assert_eq!(store.get("acc1"), ""); +} + +#[test] +fn set_many_writes_every_id_in_one_store_write() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_COLORS, json!({})); + stores.set_account_card_color("keep", "#000"); + let before = stores.storage().revision(CLIENT_STORE_ACCOUNT_CARD_COLORS); + let abc: Vec = ["a", "b", "c"].map(String::from).to_vec(); + stores.set_account_card_colors(&abc, "#f00"); + assert_eq!( + stores.storage().revision(CLIENT_STORE_ACCOUNT_CARD_COLORS), + before + 1 + ); + let read = |stores: &ClientStores, ids: &[&str]| -> Vec { + ids.iter() + .map(|id| stores.account_card_color(id).to_string()) + .collect() + }; + assert_eq!( + read(&stores, &["a", "b", "c", "keep"]), + ["#f00", "#f00", "#f00", "#000"] + ); + stores.set_account_card_colors(&abc[..2], "bad"); + assert_eq!(read(&stores, &["a", "b", "c"]), ["", "", "#f00"]); +} + +#[test] +fn map_store_remove_deletes_entries() { + let mut store = StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, identity); + store.set("acc1", "value"); + store.remove("acc1"); + assert_eq!(store.get("acc1"), ""); +} + +// ------------------------------------------------------------ accountCardColors + +#[test] +fn account_card_color_is_empty_for_an_unknown_account() { + assert_eq!( + stores_with(CLIENT_STORE_ACCOUNT_CARD_COLORS, Value::Null).account_card_color("unknown"), + "" + ); +} + +#[test] +fn account_card_color_stores_valid_hex_colors() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_COLORS, Value::Null); + for (id, color) in [("acc1", "#ff0000"), ("acc2", "#abc"), ("acc3", "#aabbccdd")] { + stores.set_account_card_color(id, color); + assert_eq!(stores.account_card_color(id), color); + } +} + +#[test] +fn account_card_color_rejects_invalid_colors() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_COLORS, Value::Null); + for (id, color) in [ + ("acc1", "red"), + ("acc2", "rgb(255,0,0)"), + ("acc3", "#xyz"), + ("acc4", "not-a-color"), + ] { + stores.set_account_card_color(id, color); + assert_eq!(stores.account_card_color(id), ""); + } +} + +#[test] +fn account_card_color_presets_have_the_expected_structure() { + assert!(!ACCOUNT_CARD_COLOR_PRESETS.is_empty()); + let none = ACCOUNT_CARD_COLOR_PRESETS + .iter() + .find(|preset| preset.id == "none") + .unwrap(); + assert_eq!(none.color, ""); +} + +// ------------------------------------------------------------ accountCardNotes + +#[test] +fn account_card_note_is_empty_for_an_unknown_account() { + assert_eq!( + stores_with(CLIENT_STORE_ACCOUNT_CARD_NOTES, Value::Null).account_card_note("unknown"), + "" + ); +} + +#[test] +fn account_card_note_is_trimmed() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_NOTES, Value::Null); + stores.set_account_card_note("acc1", " hello world "); + assert_eq!(stores.account_card_note("acc1"), "hello world"); +} + +#[test] +fn account_card_note_strips_control_characters() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_NOTES, Value::Null); + stores.set_account_card_note("acc1", "line1\u{0}line2\u{1}line3"); + assert_eq!(stores.account_card_note("acc1"), "line1 line2 line3"); +} + +#[test] +fn account_card_note_is_cut_at_180_characters() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_NOTES, Value::Null); + stores.set_account_card_note("acc1", &"a".repeat(300)); + assert_eq!(stores.account_card_note("acc1").len(), 180); +} + +#[test] +fn clearing_an_account_card_note_removes_it() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_NOTES, Value::Null); + stores.set_account_card_note("acc1", "some note"); + assert_eq!(stores.account_card_note("acc1"), "some note"); + stores.clear_account_card_note("acc1"); + assert_eq!(stores.account_card_note("acc1"), ""); +} + +// ------------------------------------------------------------ store wiring + +#[test] +fn a_refresh_rebuilds_the_typed_copy_and_a_local_write_does_not() { + let mut stores = stores_with("client.settings", json!({ "animations": "off" })); + assert_eq!(stores.settings().animations, AnimationsMode::Off); + assert!(stores.has_stored_settings()); + let mut next = stores.settings().clone(); + next.ui_scale_percent = 500; + stores.save_settings(&next); + assert_eq!(stores.settings().ui_scale_percent, 150); + stores.apply_snapshot(ClientStorageSnapshot { + manifest: Default::default(), + stores: BTreeMap::from([("client.settings".to_string(), json!({ "language": "ru" }))]), + }); + assert_eq!(stores.settings().language.code(), "ru"); + assert_eq!(stores.settings().animations, AnimationsMode::System); +} diff --git a/crates/accshift-app/tests/fixtures/legacy_web_storage/.gitattributes b/crates/accshift-app/tests/fixtures/legacy_web_storage/.gitattributes new file mode 100644 index 00000000..c82250de --- /dev/null +++ b/crates/accshift-app/tests/fixtures/legacy_web_storage/.gitattributes @@ -0,0 +1,3 @@ +# LevelDB folders written by WebView2: byte for byte, no eol conversion. +clean/** binary +kill/** binary diff --git a/crates/accshift-app/tests/fixtures/legacy_web_storage/chromium-proof.mjs b/crates/accshift-app/tests/fixtures/legacy_web_storage/chromium-proof.mjs new file mode 100644 index 00000000..ff719314 --- /dev/null +++ b/crates/accshift-app/tests/fixtures/legacy_web_storage/chromium-proof.mjs @@ -0,0 +1,281 @@ +// Real Chromium proof for src/legacy_web_storage: the parity-capture build of +// the Tauri app (WebView2, window never shown, profile and app dirs in a +// scratch folder) gets the 12 legacy localStorage keys over CDP, then is +// closed cleanly or killed. The LevelDB WebView2 leaves behind is copied here +// for tests/legacy_web_storage.rs (`real_chromium_*`). +// +// cargo build -p accshift-gui --features parity-capture +// node crates/accshift-app/tests/fixtures/legacy_web_storage/chromium-proof.mjs \ +// --scratch

[--out ] [--exe ] +// +// --out defaults to this folder. Writes /expected.json and, per case, +// // (the LevelDB files, LOCK and the text LOGs left out), and +// /run.json (timings, window samples, real-dir check). Cases: +// - clean: phase 1, 6 s for Chromium's commit delay, phase 2, 2 s, then the +// titlebar close (`close_window`): the app exits with its last window and +// WebView2 shuts down on its own. +// - kill: same phases, then `taskkill /T /F` 1 s after phase 2. +// Phase 1 sets the 12 keys; phase 2 overwrites one and removes another. +// The page origin is the devUrl, http://localhost:1420. + +import { execFileSync } from "node:child_process"; +import { cpSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + DEV_PORT, + defaultExe, + diffTrees, + launchApp, + portBusy, + realAppDirs, + sleep, + snapshotTree, + startVite, + windowWatch, +} from "../../../../accshift-egui/parity/harness.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +function args(argv) { + const o = { out: HERE, scratch: null, exe: null }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--out") o.out = argv[++i]; + else if (argv[i] === "--scratch") o.scratch = argv[++i]; + else if (argv[i] === "--exe") o.exe = argv[++i]; + else throw new Error(`unknown argument ${argv[i]}`); + } + if (!o.scratch) throw new Error("--scratch is required"); + return o; +} + +/** Deterministic values: one over 64 KB, Latin-1, non-Latin-1, pure ASCII. */ +function values() { + const avatars = {}; + for (let i = 0; i < 600; i++) { + const id = String(76561198000000000n + BigInt(i * 7919)); + avatars[id] = { + url: `https://avatars.steamstatic.com/${((i * 2654435761) >>> 0).toString(16).padStart(8, "0")}${"0".repeat(32)}_full.jpg`, + fetchedAt: 1_790_000_000_000 + i * 1000, + }; + } + const phase1 = { + // Pure ASCII. + accshift_settings: JSON.stringify({ themeId: "dark", language: "fr", animations: "full" }), + // Latin-1, not ASCII: Chromium stores it with the 0x01 format byte. + accshift_folders: JSON.stringify({ + version: 1, + folders: [{ id: "f1", name: "Dossier é à ç ñ ü" }], + itemOrder: {}, + }), + accshift_personas: JSON.stringify([{ id: "p1", name: "Persona", accent: "#8b5cf6" }]), + // Beyond Latin-1, astral plane included: 0x00 format byte (UTF-16LE). + accshift_account_card_notes: JSON.stringify({ + "steam:76561198000000001": "Compte principal ✓ 日本語 Ωμέγα 😀", + }), + accshift_account_card_colors: JSON.stringify({ "steam:76561198000000001": "violet" }), + accshift_account_default_game: JSON.stringify({ "steam:76561198000000001": 730 }), + accshift_folder_card_colors: JSON.stringify({ f1: "blue" }), + accshift_viewMode: "list", + // Over 64 KB. + accshift_avatars: JSON.stringify(avatars), + accshift_roblox_avatars: JSON.stringify({ + 1234567: { url: "https://tr.rbxcdn.com/x/150/150/AvatarHeadshot/Png" }, + }), + accshift_ban_check_state_v2: JSON.stringify({ lastCheck: 1_790_000_000_000, queue: [] }), + accshift_ban_info_cache_v1: JSON.stringify({ "76561198000000001": { vac: 0, game: 0 } }), + }; + const phase2 = { + set: { + accshift_ban_info_cache_v1: JSON.stringify({ + "76561198000000001": { vac: 1, game: 2, note: "réécrit ✓" }, + }), + }, + remove: ["accshift_ban_check_state_v2"], + }; + const final = { ...phase1, ...phase2.set }; + for (const k of phase2.remove) delete final[k]; + return { phase1, phase2, final }; +} + +function findLeveldb(root) { + const stack = [root]; + while (stack.length) { + const dir = stack.pop(); + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + if (!e.isDirectory()) continue; + const p = join(dir, e.name); + if (e.name === "leveldb" && dir.endsWith("Local Storage")) return p; + stack.push(p); + } + } + return null; +} + +/** WebView2 processes still using `profile` (their command line names it). */ +function webviewPids(profile) { + const ps = `Get-CimInstance Win32_Process -Filter "Name='msedgewebview2.exe'" | Where-Object { $_.CommandLine -like '*${profile.replace(/'/g, "''")}*' } | ForEach-Object { $_.ProcessId }`; + const out = execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", ps], { + encoding: "utf8", + windowsHide: true, + }); + return out.split(/\s+/).filter(Boolean).map(Number); +} + +async function waitGone(pid, profile, timeoutMs) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + let alive = true; + try { + process.kill(pid, 0); + } catch { + alive = false; + } + if (!alive && webviewPids(profile).length === 0) return Date.now() - start; + await sleep(250); + } + return null; +} + +async function runCase(name, mode, opts, vals, watch) { + const scratch = join(opts.scratch, name); + const app = await launchApp({ + exe: opts.exe, + scratch, + logFile: join(opts.scratch, `${name}-app.log`), + onStderr: watch.onLine, + windowMode: "hidden", + }); + const profile = join(scratch, "app", "webview"); + const info = { case: name, mode }; + let closed = false; + try { + const { js, call } = app.page; + info.origin = await js("location.origin"); + info.phase1Length = await call((v) => { + for (const [k, x] of Object.entries(v)) localStorage.setItem(k, x); + return Object.keys(v).filter((k) => localStorage.getItem(k) === v[k]).length; + }, vals.phase1); + await sleep(6000); + info.phase2Ok = await call((p) => { + for (const [k, x] of Object.entries(p.set)) localStorage.setItem(k, x); + for (const k of p.remove) localStorage.removeItem(k); + return ( + Object.entries(p.set).every(([k, x]) => localStorage.getItem(k) === x) && + p.remove.every((k) => localStorage.getItem(k) === null) + ); + }, vals.phase2); + if (mode === "clean") { + await sleep(2000); + // The titlebar's close button (`TitleBar.svelte`, `close_window`): + // the window closes, the app exits once its last window is gone and + // WebView2 shuts down on its own. `taskkill` without /F does not reach + // the hidden window. + await app.page.js(`void window.__TAURI_INTERNALS__.invoke("close_window"); true`); + app.page.close(); + info.exitAfterMs = await waitGone(app.child.pid, profile, 30_000); + info.exitCode = app.child.exitCode; + closed = info.exitAfterMs != null; + } else { + app.page.close(); + await sleep(1000); + await app.close(); + info.exitAfterMs = await waitGone(app.child.pid, profile, 15_000); + closed = true; + } + } finally { + if (!closed) { + info.forcedCleanup = webviewPids(profile); + await app.close(); + for (const pid of webviewPids(profile)) { + try { + execFileSync("taskkill", ["/PID", String(pid), "/F"], { + stdio: "ignore", + windowsHide: true, + }); + } catch { + /* gone */ + } + } + } + } + const leveldb = findLeveldb(profile); + if (!leveldb) throw new Error(`${name}: no Local Storage/leveldb under ${profile}`); + const dest = join(opts.out, name); + rmSync(dest, { recursive: true, force: true }); + mkdirSync(dest, { recursive: true }); + info.files = {}; + for (const f of readdirSync(leveldb)) { + if (["LOCK", "LOG", "LOG.old"].includes(f)) continue; + cpSync(join(leveldb, f), join(dest, f)); + info.files[f] = statSync(join(dest, f)).size; + } + return info; +} + +async function main() { + const opts = args(process.argv.slice(2)); + opts.out = resolve(opts.out); + opts.scratch = resolve(opts.scratch); + opts.exe = resolve(opts.exe ?? defaultExe()); + mkdirSync(opts.scratch, { recursive: true }); + const vals = values(); + const dirs = realAppDirs(); + const before = Object.fromEntries(dirs.map((d) => [d, snapshotTree(d)])); + const watch = windowWatch(); + // Other parity runs share port 1420: wait for it, never stop their server. + let vite = null; + for (const start = Date.now(); !vite;) { + if (!(await portBusy(DEV_PORT))) { + try { + vite = await startVite({ logFile: join(opts.scratch, "vite.log") }); + break; + } catch (err) { + if (!(await portBusy(DEV_PORT))) throw err; + } + } + if (Date.now() - start > 30 * 60_000) throw new Error(`port ${DEV_PORT} busy for 30 min`); + console.error(`port ${DEV_PORT} busy, retrying in 15 s`); + await sleep(15_000); + } + const cases = []; + try { + cases.push(await runCase("clean", "clean", opts, vals, watch)); + cases.push(await runCase("kill", "kill", opts, vals, watch)); + } finally { + await vite.close(); + } + await sleep(300); + const touched = dirs.flatMap((d) => + diffTrees(before[d], snapshotTree(d)).map((c) => `${d}: ${c}`), + ); + const win = watch.summary(); + writeFileSync( + join(opts.out, "expected.json"), + JSON.stringify( + { origin: "http://localhost:1420", phase1: vals.phase1, final: vals.final }, + null, + 2, + ) + "\n", + ); + const run = { + cases, + window: { samples: win.samples, hidden: win.hidden }, + realDirsTouched: touched, + port1420FreeAfter: !(await portBusy(DEV_PORT)), + }; + writeFileSync(join(opts.scratch, "run.json"), JSON.stringify(run, null, 2) + "\n"); + console.log(JSON.stringify(run, null, 2)); + if (!win.hidden || touched.length) process.exitCode = 1; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/000004.log b/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/000004.log new file mode 100644 index 00000000..53c8fd38 Binary files /dev/null and b/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/000004.log differ diff --git a/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/000005.ldb b/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/000005.ldb new file mode 100644 index 00000000..aca3cf8b Binary files /dev/null and b/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/000005.ldb differ diff --git a/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/CURRENT b/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/CURRENT new file mode 100644 index 00000000..7ed683d1 --- /dev/null +++ b/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/MANIFEST-000001 b/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/MANIFEST-000001 new file mode 100644 index 00000000..f599ca14 Binary files /dev/null and b/crates/accshift-app/tests/fixtures/legacy_web_storage/clean/MANIFEST-000001 differ diff --git a/crates/accshift-app/tests/fixtures/legacy_web_storage/expected.json b/crates/accshift-app/tests/fixtures/legacy_web_storage/expected.json new file mode 100644 index 00000000..10577bbd --- /dev/null +++ b/crates/accshift-app/tests/fixtures/legacy_web_storage/expected.json @@ -0,0 +1,30 @@ +{ + "origin": "http://localhost:1420", + "phase1": { + "accshift_settings": "{\"themeId\":\"dark\",\"language\":\"fr\",\"animations\":\"full\"}", + "accshift_folders": "{\"version\":1,\"folders\":[{\"id\":\"f1\",\"name\":\"Dossier é à ç ñ ü\"}],\"itemOrder\":{}}", + "accshift_personas": "[{\"id\":\"p1\",\"name\":\"Persona\",\"accent\":\"#8b5cf6\"}]", + "accshift_account_card_notes": "{\"steam:76561198000000001\":\"Compte principal ✓ 日本語 Ωμέγα 😀\"}", + "accshift_account_card_colors": "{\"steam:76561198000000001\":\"violet\"}", + "accshift_account_default_game": "{\"steam:76561198000000001\":730}", + "accshift_folder_card_colors": "{\"f1\":\"blue\"}", + "accshift_viewMode": "list", + "accshift_avatars": "{\"76561198000000000\":{\"url\":\"https://avatars.steamstatic.com/0000000000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000000000},\"76561198000007919\":{\"url\":\"https://avatars.steamstatic.com/9e3779b100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000001000},\"76561198000015838\":{\"url\":\"https://avatars.steamstatic.com/3c6ef36200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000002000},\"76561198000023757\":{\"url\":\"https://avatars.steamstatic.com/daa66d1300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000003000},\"76561198000031676\":{\"url\":\"https://avatars.steamstatic.com/78dde6c400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000004000},\"76561198000039595\":{\"url\":\"https://avatars.steamstatic.com/1715607500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000005000},\"76561198000047514\":{\"url\":\"https://avatars.steamstatic.com/b54cda2600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000006000},\"76561198000055433\":{\"url\":\"https://avatars.steamstatic.com/538453d700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000007000},\"76561198000063352\":{\"url\":\"https://avatars.steamstatic.com/f1bbcd8800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000008000},\"76561198000071271\":{\"url\":\"https://avatars.steamstatic.com/8ff3473900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000009000},\"76561198000079190\":{\"url\":\"https://avatars.steamstatic.com/2e2ac0ea00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000010000},\"76561198000087109\":{\"url\":\"https://avatars.steamstatic.com/cc623a9b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000011000},\"76561198000095028\":{\"url\":\"https://avatars.steamstatic.com/6a99b44c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000012000},\"76561198000102947\":{\"url\":\"https://avatars.steamstatic.com/08d12dfd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000013000},\"76561198000110866\":{\"url\":\"https://avatars.steamstatic.com/a708a7ae00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000014000},\"76561198000118785\":{\"url\":\"https://avatars.steamstatic.com/4540215f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000015000},\"76561198000126704\":{\"url\":\"https://avatars.steamstatic.com/e3779b1000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000016000},\"76561198000134623\":{\"url\":\"https://avatars.steamstatic.com/81af14c100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000017000},\"76561198000142542\":{\"url\":\"https://avatars.steamstatic.com/1fe68e7200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000018000},\"76561198000150461\":{\"url\":\"https://avatars.steamstatic.com/be1e082300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000019000},\"76561198000158380\":{\"url\":\"https://avatars.steamstatic.com/5c5581d400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000020000},\"76561198000166299\":{\"url\":\"https://avatars.steamstatic.com/fa8cfb8500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000021000},\"76561198000174218\":{\"url\":\"https://avatars.steamstatic.com/98c4753600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000022000},\"76561198000182137\":{\"url\":\"https://avatars.steamstatic.com/36fbeee700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000023000},\"76561198000190056\":{\"url\":\"https://avatars.steamstatic.com/d533689800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000024000},\"76561198000197975\":{\"url\":\"https://avatars.steamstatic.com/736ae24900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000025000},\"76561198000205894\":{\"url\":\"https://avatars.steamstatic.com/11a25bfa00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000026000},\"76561198000213813\":{\"url\":\"https://avatars.steamstatic.com/afd9d5ab00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000027000},\"76561198000221732\":{\"url\":\"https://avatars.steamstatic.com/4e114f5c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000028000},\"76561198000229651\":{\"url\":\"https://avatars.steamstatic.com/ec48c90d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000029000},\"76561198000237570\":{\"url\":\"https://avatars.steamstatic.com/8a8042be00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000030000},\"76561198000245489\":{\"url\":\"https://avatars.steamstatic.com/28b7bc6f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000031000},\"76561198000253408\":{\"url\":\"https://avatars.steamstatic.com/c6ef362000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000032000},\"76561198000261327\":{\"url\":\"https://avatars.steamstatic.com/6526afd100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000033000},\"76561198000269246\":{\"url\":\"https://avatars.steamstatic.com/035e298200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000034000},\"76561198000277165\":{\"url\":\"https://avatars.steamstatic.com/a195a33300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000035000},\"76561198000285084\":{\"url\":\"https://avatars.steamstatic.com/3fcd1ce400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000036000},\"76561198000293003\":{\"url\":\"https://avatars.steamstatic.com/de04969500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000037000},\"76561198000300922\":{\"url\":\"https://avatars.steamstatic.com/7c3c104600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000038000},\"76561198000308841\":{\"url\":\"https://avatars.steamstatic.com/1a7389f700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000039000},\"76561198000316760\":{\"url\":\"https://avatars.steamstatic.com/b8ab03a800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000040000},\"76561198000324679\":{\"url\":\"https://avatars.steamstatic.com/56e27d5900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000041000},\"76561198000332598\":{\"url\":\"https://avatars.steamstatic.com/f519f70a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000042000},\"76561198000340517\":{\"url\":\"https://avatars.steamstatic.com/935170bb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000043000},\"76561198000348436\":{\"url\":\"https://avatars.steamstatic.com/3188ea6c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000044000},\"76561198000356355\":{\"url\":\"https://avatars.steamstatic.com/cfc0641d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000045000},\"76561198000364274\":{\"url\":\"https://avatars.steamstatic.com/6df7ddce00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000046000},\"76561198000372193\":{\"url\":\"https://avatars.steamstatic.com/0c2f577f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000047000},\"76561198000380112\":{\"url\":\"https://avatars.steamstatic.com/aa66d13000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000048000},\"76561198000388031\":{\"url\":\"https://avatars.steamstatic.com/489e4ae100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000049000},\"76561198000395950\":{\"url\":\"https://avatars.steamstatic.com/e6d5c49200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000050000},\"76561198000403869\":{\"url\":\"https://avatars.steamstatic.com/850d3e4300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000051000},\"76561198000411788\":{\"url\":\"https://avatars.steamstatic.com/2344b7f400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000052000},\"76561198000419707\":{\"url\":\"https://avatars.steamstatic.com/c17c31a500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000053000},\"76561198000427626\":{\"url\":\"https://avatars.steamstatic.com/5fb3ab5600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000054000},\"76561198000435545\":{\"url\":\"https://avatars.steamstatic.com/fdeb250700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000055000},\"76561198000443464\":{\"url\":\"https://avatars.steamstatic.com/9c229eb800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000056000},\"76561198000451383\":{\"url\":\"https://avatars.steamstatic.com/3a5a186900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000057000},\"76561198000459302\":{\"url\":\"https://avatars.steamstatic.com/d891921a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000058000},\"76561198000467221\":{\"url\":\"https://avatars.steamstatic.com/76c90bcb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000059000},\"76561198000475140\":{\"url\":\"https://avatars.steamstatic.com/1500857c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000060000},\"76561198000483059\":{\"url\":\"https://avatars.steamstatic.com/b337ff2d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000061000},\"76561198000490978\":{\"url\":\"https://avatars.steamstatic.com/516f78de00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000062000},\"76561198000498897\":{\"url\":\"https://avatars.steamstatic.com/efa6f28f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000063000},\"76561198000506816\":{\"url\":\"https://avatars.steamstatic.com/8dde6c4000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000064000},\"76561198000514735\":{\"url\":\"https://avatars.steamstatic.com/2c15e5f100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000065000},\"76561198000522654\":{\"url\":\"https://avatars.steamstatic.com/ca4d5fa200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000066000},\"76561198000530573\":{\"url\":\"https://avatars.steamstatic.com/6884d95300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000067000},\"76561198000538492\":{\"url\":\"https://avatars.steamstatic.com/06bc530400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000068000},\"76561198000546411\":{\"url\":\"https://avatars.steamstatic.com/a4f3ccb500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000069000},\"76561198000554330\":{\"url\":\"https://avatars.steamstatic.com/432b466600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000070000},\"76561198000562249\":{\"url\":\"https://avatars.steamstatic.com/e162c01700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000071000},\"76561198000570168\":{\"url\":\"https://avatars.steamstatic.com/7f9a39c800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000072000},\"76561198000578087\":{\"url\":\"https://avatars.steamstatic.com/1dd1b37900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000073000},\"76561198000586006\":{\"url\":\"https://avatars.steamstatic.com/bc092d2a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000074000},\"76561198000593925\":{\"url\":\"https://avatars.steamstatic.com/5a40a6db00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000075000},\"76561198000601844\":{\"url\":\"https://avatars.steamstatic.com/f878208c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000076000},\"76561198000609763\":{\"url\":\"https://avatars.steamstatic.com/96af9a3d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000077000},\"76561198000617682\":{\"url\":\"https://avatars.steamstatic.com/34e713ee00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000078000},\"76561198000625601\":{\"url\":\"https://avatars.steamstatic.com/d31e8d9f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000079000},\"76561198000633520\":{\"url\":\"https://avatars.steamstatic.com/7156075000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000080000},\"76561198000641439\":{\"url\":\"https://avatars.steamstatic.com/0f8d810100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000081000},\"76561198000649358\":{\"url\":\"https://avatars.steamstatic.com/adc4fab200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000082000},\"76561198000657277\":{\"url\":\"https://avatars.steamstatic.com/4bfc746300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000083000},\"76561198000665196\":{\"url\":\"https://avatars.steamstatic.com/ea33ee1400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000084000},\"76561198000673115\":{\"url\":\"https://avatars.steamstatic.com/886b67c500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000085000},\"76561198000681034\":{\"url\":\"https://avatars.steamstatic.com/26a2e17600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000086000},\"76561198000688953\":{\"url\":\"https://avatars.steamstatic.com/c4da5b2700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000087000},\"76561198000696872\":{\"url\":\"https://avatars.steamstatic.com/6311d4d800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000088000},\"76561198000704791\":{\"url\":\"https://avatars.steamstatic.com/01494e8900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000089000},\"76561198000712710\":{\"url\":\"https://avatars.steamstatic.com/9f80c83a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000090000},\"76561198000720629\":{\"url\":\"https://avatars.steamstatic.com/3db841eb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000091000},\"76561198000728548\":{\"url\":\"https://avatars.steamstatic.com/dbefbb9c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000092000},\"76561198000736467\":{\"url\":\"https://avatars.steamstatic.com/7a27354d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000093000},\"76561198000744386\":{\"url\":\"https://avatars.steamstatic.com/185eaefe00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000094000},\"76561198000752305\":{\"url\":\"https://avatars.steamstatic.com/b69628af00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000095000},\"76561198000760224\":{\"url\":\"https://avatars.steamstatic.com/54cda26000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000096000},\"76561198000768143\":{\"url\":\"https://avatars.steamstatic.com/f3051c1100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000097000},\"76561198000776062\":{\"url\":\"https://avatars.steamstatic.com/913c95c200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000098000},\"76561198000783981\":{\"url\":\"https://avatars.steamstatic.com/2f740f7300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000099000},\"76561198000791900\":{\"url\":\"https://avatars.steamstatic.com/cdab892400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000100000},\"76561198000799819\":{\"url\":\"https://avatars.steamstatic.com/6be302d500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000101000},\"76561198000807738\":{\"url\":\"https://avatars.steamstatic.com/0a1a7c8600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000102000},\"76561198000815657\":{\"url\":\"https://avatars.steamstatic.com/a851f63700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000103000},\"76561198000823576\":{\"url\":\"https://avatars.steamstatic.com/46896fe800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000104000},\"76561198000831495\":{\"url\":\"https://avatars.steamstatic.com/e4c0e99900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000105000},\"76561198000839414\":{\"url\":\"https://avatars.steamstatic.com/82f8634a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000106000},\"76561198000847333\":{\"url\":\"https://avatars.steamstatic.com/212fdcfb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000107000},\"76561198000855252\":{\"url\":\"https://avatars.steamstatic.com/bf6756ac00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000108000},\"76561198000863171\":{\"url\":\"https://avatars.steamstatic.com/5d9ed05d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000109000},\"76561198000871090\":{\"url\":\"https://avatars.steamstatic.com/fbd64a0e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000110000},\"76561198000879009\":{\"url\":\"https://avatars.steamstatic.com/9a0dc3bf00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000111000},\"76561198000886928\":{\"url\":\"https://avatars.steamstatic.com/38453d7000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000112000},\"76561198000894847\":{\"url\":\"https://avatars.steamstatic.com/d67cb72100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000113000},\"76561198000902766\":{\"url\":\"https://avatars.steamstatic.com/74b430d200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000114000},\"76561198000910685\":{\"url\":\"https://avatars.steamstatic.com/12ebaa8300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000115000},\"76561198000918604\":{\"url\":\"https://avatars.steamstatic.com/b123243400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000116000},\"76561198000926523\":{\"url\":\"https://avatars.steamstatic.com/4f5a9de500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000117000},\"76561198000934442\":{\"url\":\"https://avatars.steamstatic.com/ed92179600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000118000},\"76561198000942361\":{\"url\":\"https://avatars.steamstatic.com/8bc9914700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000119000},\"76561198000950280\":{\"url\":\"https://avatars.steamstatic.com/2a010af800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000120000},\"76561198000958199\":{\"url\":\"https://avatars.steamstatic.com/c83884a900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000121000},\"76561198000966118\":{\"url\":\"https://avatars.steamstatic.com/666ffe5a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000122000},\"76561198000974037\":{\"url\":\"https://avatars.steamstatic.com/04a7780b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000123000},\"76561198000981956\":{\"url\":\"https://avatars.steamstatic.com/a2def1bc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000124000},\"76561198000989875\":{\"url\":\"https://avatars.steamstatic.com/41166b6d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000125000},\"76561198000997794\":{\"url\":\"https://avatars.steamstatic.com/df4de51e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000126000},\"76561198001005713\":{\"url\":\"https://avatars.steamstatic.com/7d855ecf00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000127000},\"76561198001013632\":{\"url\":\"https://avatars.steamstatic.com/1bbcd88000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000128000},\"76561198001021551\":{\"url\":\"https://avatars.steamstatic.com/b9f4523100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000129000},\"76561198001029470\":{\"url\":\"https://avatars.steamstatic.com/582bcbe200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000130000},\"76561198001037389\":{\"url\":\"https://avatars.steamstatic.com/f663459300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000131000},\"76561198001045308\":{\"url\":\"https://avatars.steamstatic.com/949abf4400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000132000},\"76561198001053227\":{\"url\":\"https://avatars.steamstatic.com/32d238f500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000133000},\"76561198001061146\":{\"url\":\"https://avatars.steamstatic.com/d109b2a600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000134000},\"76561198001069065\":{\"url\":\"https://avatars.steamstatic.com/6f412c5700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000135000},\"76561198001076984\":{\"url\":\"https://avatars.steamstatic.com/0d78a60800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000136000},\"76561198001084903\":{\"url\":\"https://avatars.steamstatic.com/abb01fb900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000137000},\"76561198001092822\":{\"url\":\"https://avatars.steamstatic.com/49e7996a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000138000},\"76561198001100741\":{\"url\":\"https://avatars.steamstatic.com/e81f131b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000139000},\"76561198001108660\":{\"url\":\"https://avatars.steamstatic.com/86568ccc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000140000},\"76561198001116579\":{\"url\":\"https://avatars.steamstatic.com/248e067d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000141000},\"76561198001124498\":{\"url\":\"https://avatars.steamstatic.com/c2c5802e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000142000},\"76561198001132417\":{\"url\":\"https://avatars.steamstatic.com/60fcf9df00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000143000},\"76561198001140336\":{\"url\":\"https://avatars.steamstatic.com/ff34739000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000144000},\"76561198001148255\":{\"url\":\"https://avatars.steamstatic.com/9d6bed4100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000145000},\"76561198001156174\":{\"url\":\"https://avatars.steamstatic.com/3ba366f200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000146000},\"76561198001164093\":{\"url\":\"https://avatars.steamstatic.com/d9dae0a300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000147000},\"76561198001172012\":{\"url\":\"https://avatars.steamstatic.com/78125a5400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000148000},\"76561198001179931\":{\"url\":\"https://avatars.steamstatic.com/1649d40500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000149000},\"76561198001187850\":{\"url\":\"https://avatars.steamstatic.com/b4814db600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000150000},\"76561198001195769\":{\"url\":\"https://avatars.steamstatic.com/52b8c76700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000151000},\"76561198001203688\":{\"url\":\"https://avatars.steamstatic.com/f0f0411800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000152000},\"76561198001211607\":{\"url\":\"https://avatars.steamstatic.com/8f27bac900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000153000},\"76561198001219526\":{\"url\":\"https://avatars.steamstatic.com/2d5f347a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000154000},\"76561198001227445\":{\"url\":\"https://avatars.steamstatic.com/cb96ae2b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000155000},\"76561198001235364\":{\"url\":\"https://avatars.steamstatic.com/69ce27dc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000156000},\"76561198001243283\":{\"url\":\"https://avatars.steamstatic.com/0805a18d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000157000},\"76561198001251202\":{\"url\":\"https://avatars.steamstatic.com/a63d1b3e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000158000},\"76561198001259121\":{\"url\":\"https://avatars.steamstatic.com/447494ef00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000159000},\"76561198001267040\":{\"url\":\"https://avatars.steamstatic.com/e2ac0ea000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000160000},\"76561198001274959\":{\"url\":\"https://avatars.steamstatic.com/80e3885100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000161000},\"76561198001282878\":{\"url\":\"https://avatars.steamstatic.com/1f1b020200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000162000},\"76561198001290797\":{\"url\":\"https://avatars.steamstatic.com/bd527bb300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000163000},\"76561198001298716\":{\"url\":\"https://avatars.steamstatic.com/5b89f56400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000164000},\"76561198001306635\":{\"url\":\"https://avatars.steamstatic.com/f9c16f1500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000165000},\"76561198001314554\":{\"url\":\"https://avatars.steamstatic.com/97f8e8c600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000166000},\"76561198001322473\":{\"url\":\"https://avatars.steamstatic.com/3630627700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000167000},\"76561198001330392\":{\"url\":\"https://avatars.steamstatic.com/d467dc2800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000168000},\"76561198001338311\":{\"url\":\"https://avatars.steamstatic.com/729f55d900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000169000},\"76561198001346230\":{\"url\":\"https://avatars.steamstatic.com/10d6cf8a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000170000},\"76561198001354149\":{\"url\":\"https://avatars.steamstatic.com/af0e493b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000171000},\"76561198001362068\":{\"url\":\"https://avatars.steamstatic.com/4d45c2ec00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000172000},\"76561198001369987\":{\"url\":\"https://avatars.steamstatic.com/eb7d3c9d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000173000},\"76561198001377906\":{\"url\":\"https://avatars.steamstatic.com/89b4b64e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000174000},\"76561198001385825\":{\"url\":\"https://avatars.steamstatic.com/27ec2fff00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000175000},\"76561198001393744\":{\"url\":\"https://avatars.steamstatic.com/c623a9b000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000176000},\"76561198001401663\":{\"url\":\"https://avatars.steamstatic.com/645b236100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000177000},\"76561198001409582\":{\"url\":\"https://avatars.steamstatic.com/02929d1200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000178000},\"76561198001417501\":{\"url\":\"https://avatars.steamstatic.com/a0ca16c300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000179000},\"76561198001425420\":{\"url\":\"https://avatars.steamstatic.com/3f01907400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000180000},\"76561198001433339\":{\"url\":\"https://avatars.steamstatic.com/dd390a2500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000181000},\"76561198001441258\":{\"url\":\"https://avatars.steamstatic.com/7b7083d600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000182000},\"76561198001449177\":{\"url\":\"https://avatars.steamstatic.com/19a7fd8700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000183000},\"76561198001457096\":{\"url\":\"https://avatars.steamstatic.com/b7df773800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000184000},\"76561198001465015\":{\"url\":\"https://avatars.steamstatic.com/5616f0e900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000185000},\"76561198001472934\":{\"url\":\"https://avatars.steamstatic.com/f44e6a9a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000186000},\"76561198001480853\":{\"url\":\"https://avatars.steamstatic.com/9285e44b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000187000},\"76561198001488772\":{\"url\":\"https://avatars.steamstatic.com/30bd5dfc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000188000},\"76561198001496691\":{\"url\":\"https://avatars.steamstatic.com/cef4d7ad00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000189000},\"76561198001504610\":{\"url\":\"https://avatars.steamstatic.com/6d2c515e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000190000},\"76561198001512529\":{\"url\":\"https://avatars.steamstatic.com/0b63cb0f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000191000},\"76561198001520448\":{\"url\":\"https://avatars.steamstatic.com/a99b44c000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000192000},\"76561198001528367\":{\"url\":\"https://avatars.steamstatic.com/47d2be7100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000193000},\"76561198001536286\":{\"url\":\"https://avatars.steamstatic.com/e60a382200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000194000},\"76561198001544205\":{\"url\":\"https://avatars.steamstatic.com/8441b1d300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000195000},\"76561198001552124\":{\"url\":\"https://avatars.steamstatic.com/22792b8400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000196000},\"76561198001560043\":{\"url\":\"https://avatars.steamstatic.com/c0b0a53500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000197000},\"76561198001567962\":{\"url\":\"https://avatars.steamstatic.com/5ee81ee600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000198000},\"76561198001575881\":{\"url\":\"https://avatars.steamstatic.com/fd1f989700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000199000},\"76561198001583800\":{\"url\":\"https://avatars.steamstatic.com/9b57124800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000200000},\"76561198001591719\":{\"url\":\"https://avatars.steamstatic.com/398e8bf900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000201000},\"76561198001599638\":{\"url\":\"https://avatars.steamstatic.com/d7c605aa00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000202000},\"76561198001607557\":{\"url\":\"https://avatars.steamstatic.com/75fd7f5b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000203000},\"76561198001615476\":{\"url\":\"https://avatars.steamstatic.com/1434f90c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000204000},\"76561198001623395\":{\"url\":\"https://avatars.steamstatic.com/b26c72bd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000205000},\"76561198001631314\":{\"url\":\"https://avatars.steamstatic.com/50a3ec6e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000206000},\"76561198001639233\":{\"url\":\"https://avatars.steamstatic.com/eedb661f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000207000},\"76561198001647152\":{\"url\":\"https://avatars.steamstatic.com/8d12dfd000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000208000},\"76561198001655071\":{\"url\":\"https://avatars.steamstatic.com/2b4a598100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000209000},\"76561198001662990\":{\"url\":\"https://avatars.steamstatic.com/c981d33200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000210000},\"76561198001670909\":{\"url\":\"https://avatars.steamstatic.com/67b94ce300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000211000},\"76561198001678828\":{\"url\":\"https://avatars.steamstatic.com/05f0c69400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000212000},\"76561198001686747\":{\"url\":\"https://avatars.steamstatic.com/a428404500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000213000},\"76561198001694666\":{\"url\":\"https://avatars.steamstatic.com/425fb9f600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000214000},\"76561198001702585\":{\"url\":\"https://avatars.steamstatic.com/e09733a700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000215000},\"76561198001710504\":{\"url\":\"https://avatars.steamstatic.com/7ecead5800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000216000},\"76561198001718423\":{\"url\":\"https://avatars.steamstatic.com/1d06270900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000217000},\"76561198001726342\":{\"url\":\"https://avatars.steamstatic.com/bb3da0ba00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000218000},\"76561198001734261\":{\"url\":\"https://avatars.steamstatic.com/59751a6b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000219000},\"76561198001742180\":{\"url\":\"https://avatars.steamstatic.com/f7ac941c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000220000},\"76561198001750099\":{\"url\":\"https://avatars.steamstatic.com/95e40dcd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000221000},\"76561198001758018\":{\"url\":\"https://avatars.steamstatic.com/341b877e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000222000},\"76561198001765937\":{\"url\":\"https://avatars.steamstatic.com/d253012f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000223000},\"76561198001773856\":{\"url\":\"https://avatars.steamstatic.com/708a7ae000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000224000},\"76561198001781775\":{\"url\":\"https://avatars.steamstatic.com/0ec1f49100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000225000},\"76561198001789694\":{\"url\":\"https://avatars.steamstatic.com/acf96e4200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000226000},\"76561198001797613\":{\"url\":\"https://avatars.steamstatic.com/4b30e7f300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000227000},\"76561198001805532\":{\"url\":\"https://avatars.steamstatic.com/e96861a400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000228000},\"76561198001813451\":{\"url\":\"https://avatars.steamstatic.com/879fdb5500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000229000},\"76561198001821370\":{\"url\":\"https://avatars.steamstatic.com/25d7550600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000230000},\"76561198001829289\":{\"url\":\"https://avatars.steamstatic.com/c40eceb700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000231000},\"76561198001837208\":{\"url\":\"https://avatars.steamstatic.com/6246486800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000232000},\"76561198001845127\":{\"url\":\"https://avatars.steamstatic.com/007dc21900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000233000},\"76561198001853046\":{\"url\":\"https://avatars.steamstatic.com/9eb53bca00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000234000},\"76561198001860965\":{\"url\":\"https://avatars.steamstatic.com/3cecb57b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000235000},\"76561198001868884\":{\"url\":\"https://avatars.steamstatic.com/db242f2c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000236000},\"76561198001876803\":{\"url\":\"https://avatars.steamstatic.com/795ba8dd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000237000},\"76561198001884722\":{\"url\":\"https://avatars.steamstatic.com/1793228e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000238000},\"76561198001892641\":{\"url\":\"https://avatars.steamstatic.com/b5ca9c3f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000239000},\"76561198001900560\":{\"url\":\"https://avatars.steamstatic.com/540215f000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000240000},\"76561198001908479\":{\"url\":\"https://avatars.steamstatic.com/f2398fa100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000241000},\"76561198001916398\":{\"url\":\"https://avatars.steamstatic.com/9071095200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000242000},\"76561198001924317\":{\"url\":\"https://avatars.steamstatic.com/2ea8830300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000243000},\"76561198001932236\":{\"url\":\"https://avatars.steamstatic.com/ccdffcb400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000244000},\"76561198001940155\":{\"url\":\"https://avatars.steamstatic.com/6b17766500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000245000},\"76561198001948074\":{\"url\":\"https://avatars.steamstatic.com/094ef01600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000246000},\"76561198001955993\":{\"url\":\"https://avatars.steamstatic.com/a78669c700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000247000},\"76561198001963912\":{\"url\":\"https://avatars.steamstatic.com/45bde37800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000248000},\"76561198001971831\":{\"url\":\"https://avatars.steamstatic.com/e3f55d2900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000249000},\"76561198001979750\":{\"url\":\"https://avatars.steamstatic.com/822cd6da00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000250000},\"76561198001987669\":{\"url\":\"https://avatars.steamstatic.com/2064508b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000251000},\"76561198001995588\":{\"url\":\"https://avatars.steamstatic.com/be9bca3c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000252000},\"76561198002003507\":{\"url\":\"https://avatars.steamstatic.com/5cd343ed00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000253000},\"76561198002011426\":{\"url\":\"https://avatars.steamstatic.com/fb0abd9e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000254000},\"76561198002019345\":{\"url\":\"https://avatars.steamstatic.com/9942374f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000255000},\"76561198002027264\":{\"url\":\"https://avatars.steamstatic.com/3779b10000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000256000},\"76561198002035183\":{\"url\":\"https://avatars.steamstatic.com/d5b12ab100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000257000},\"76561198002043102\":{\"url\":\"https://avatars.steamstatic.com/73e8a46200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000258000},\"76561198002051021\":{\"url\":\"https://avatars.steamstatic.com/12201e1300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000259000},\"76561198002058940\":{\"url\":\"https://avatars.steamstatic.com/b05797c400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000260000},\"76561198002066859\":{\"url\":\"https://avatars.steamstatic.com/4e8f117500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000261000},\"76561198002074778\":{\"url\":\"https://avatars.steamstatic.com/ecc68b2600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000262000},\"76561198002082697\":{\"url\":\"https://avatars.steamstatic.com/8afe04d700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000263000},\"76561198002090616\":{\"url\":\"https://avatars.steamstatic.com/29357e8800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000264000},\"76561198002098535\":{\"url\":\"https://avatars.steamstatic.com/c76cf83900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000265000},\"76561198002106454\":{\"url\":\"https://avatars.steamstatic.com/65a471ea00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000266000},\"76561198002114373\":{\"url\":\"https://avatars.steamstatic.com/03dbeb9b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000267000},\"76561198002122292\":{\"url\":\"https://avatars.steamstatic.com/a213654c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000268000},\"76561198002130211\":{\"url\":\"https://avatars.steamstatic.com/404adefd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000269000},\"76561198002138130\":{\"url\":\"https://avatars.steamstatic.com/de8258ae00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000270000},\"76561198002146049\":{\"url\":\"https://avatars.steamstatic.com/7cb9d25f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000271000},\"76561198002153968\":{\"url\":\"https://avatars.steamstatic.com/1af14c1000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000272000},\"76561198002161887\":{\"url\":\"https://avatars.steamstatic.com/b928c5c100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000273000},\"76561198002169806\":{\"url\":\"https://avatars.steamstatic.com/57603f7200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000274000},\"76561198002177725\":{\"url\":\"https://avatars.steamstatic.com/f597b92300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000275000},\"76561198002185644\":{\"url\":\"https://avatars.steamstatic.com/93cf32d400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000276000},\"76561198002193563\":{\"url\":\"https://avatars.steamstatic.com/3206ac8500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000277000},\"76561198002201482\":{\"url\":\"https://avatars.steamstatic.com/d03e263600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000278000},\"76561198002209401\":{\"url\":\"https://avatars.steamstatic.com/6e759fe700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000279000},\"76561198002217320\":{\"url\":\"https://avatars.steamstatic.com/0cad199800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000280000},\"76561198002225239\":{\"url\":\"https://avatars.steamstatic.com/aae4934900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000281000},\"76561198002233158\":{\"url\":\"https://avatars.steamstatic.com/491c0cfa00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000282000},\"76561198002241077\":{\"url\":\"https://avatars.steamstatic.com/e75386ab00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000283000},\"76561198002248996\":{\"url\":\"https://avatars.steamstatic.com/858b005c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000284000},\"76561198002256915\":{\"url\":\"https://avatars.steamstatic.com/23c27a0d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000285000},\"76561198002264834\":{\"url\":\"https://avatars.steamstatic.com/c1f9f3be00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000286000},\"76561198002272753\":{\"url\":\"https://avatars.steamstatic.com/60316d6f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000287000},\"76561198002280672\":{\"url\":\"https://avatars.steamstatic.com/fe68e72000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000288000},\"76561198002288591\":{\"url\":\"https://avatars.steamstatic.com/9ca060d100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000289000},\"76561198002296510\":{\"url\":\"https://avatars.steamstatic.com/3ad7da8200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000290000},\"76561198002304429\":{\"url\":\"https://avatars.steamstatic.com/d90f543300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000291000},\"76561198002312348\":{\"url\":\"https://avatars.steamstatic.com/7746cde400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000292000},\"76561198002320267\":{\"url\":\"https://avatars.steamstatic.com/157e479500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000293000},\"76561198002328186\":{\"url\":\"https://avatars.steamstatic.com/b3b5c14600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000294000},\"76561198002336105\":{\"url\":\"https://avatars.steamstatic.com/51ed3af700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000295000},\"76561198002344024\":{\"url\":\"https://avatars.steamstatic.com/f024b4a800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000296000},\"76561198002351943\":{\"url\":\"https://avatars.steamstatic.com/8e5c2e5900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000297000},\"76561198002359862\":{\"url\":\"https://avatars.steamstatic.com/2c93a80a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000298000},\"76561198002367781\":{\"url\":\"https://avatars.steamstatic.com/cacb21bb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000299000},\"76561198002375700\":{\"url\":\"https://avatars.steamstatic.com/69029b6c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000300000},\"76561198002383619\":{\"url\":\"https://avatars.steamstatic.com/073a151d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000301000},\"76561198002391538\":{\"url\":\"https://avatars.steamstatic.com/a5718ece00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000302000},\"76561198002399457\":{\"url\":\"https://avatars.steamstatic.com/43a9087f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000303000},\"76561198002407376\":{\"url\":\"https://avatars.steamstatic.com/e1e0823000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000304000},\"76561198002415295\":{\"url\":\"https://avatars.steamstatic.com/8017fbe100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000305000},\"76561198002423214\":{\"url\":\"https://avatars.steamstatic.com/1e4f759200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000306000},\"76561198002431133\":{\"url\":\"https://avatars.steamstatic.com/bc86ef4300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000307000},\"76561198002439052\":{\"url\":\"https://avatars.steamstatic.com/5abe68f400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000308000},\"76561198002446971\":{\"url\":\"https://avatars.steamstatic.com/f8f5e2a500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000309000},\"76561198002454890\":{\"url\":\"https://avatars.steamstatic.com/972d5c5600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000310000},\"76561198002462809\":{\"url\":\"https://avatars.steamstatic.com/3564d60700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000311000},\"76561198002470728\":{\"url\":\"https://avatars.steamstatic.com/d39c4fb800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000312000},\"76561198002478647\":{\"url\":\"https://avatars.steamstatic.com/71d3c96900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000313000},\"76561198002486566\":{\"url\":\"https://avatars.steamstatic.com/100b431a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000314000},\"76561198002494485\":{\"url\":\"https://avatars.steamstatic.com/ae42bccb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000315000},\"76561198002502404\":{\"url\":\"https://avatars.steamstatic.com/4c7a367c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000316000},\"76561198002510323\":{\"url\":\"https://avatars.steamstatic.com/eab1b02d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000317000},\"76561198002518242\":{\"url\":\"https://avatars.steamstatic.com/88e929de00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000318000},\"76561198002526161\":{\"url\":\"https://avatars.steamstatic.com/2720a38f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000319000},\"76561198002534080\":{\"url\":\"https://avatars.steamstatic.com/c5581d4000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000320000},\"76561198002541999\":{\"url\":\"https://avatars.steamstatic.com/638f96f100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000321000},\"76561198002549918\":{\"url\":\"https://avatars.steamstatic.com/01c710a200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000322000},\"76561198002557837\":{\"url\":\"https://avatars.steamstatic.com/9ffe8a5300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000323000},\"76561198002565756\":{\"url\":\"https://avatars.steamstatic.com/3e36040400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000324000},\"76561198002573675\":{\"url\":\"https://avatars.steamstatic.com/dc6d7db500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000325000},\"76561198002581594\":{\"url\":\"https://avatars.steamstatic.com/7aa4f76600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000326000},\"76561198002589513\":{\"url\":\"https://avatars.steamstatic.com/18dc711700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000327000},\"76561198002597432\":{\"url\":\"https://avatars.steamstatic.com/b713eac800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000328000},\"76561198002605351\":{\"url\":\"https://avatars.steamstatic.com/554b647900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000329000},\"76561198002613270\":{\"url\":\"https://avatars.steamstatic.com/f382de2a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000330000},\"76561198002621189\":{\"url\":\"https://avatars.steamstatic.com/91ba57db00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000331000},\"76561198002629108\":{\"url\":\"https://avatars.steamstatic.com/2ff1d18c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000332000},\"76561198002637027\":{\"url\":\"https://avatars.steamstatic.com/ce294b3d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000333000},\"76561198002644946\":{\"url\":\"https://avatars.steamstatic.com/6c60c4ee00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000334000},\"76561198002652865\":{\"url\":\"https://avatars.steamstatic.com/0a983e9f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000335000},\"76561198002660784\":{\"url\":\"https://avatars.steamstatic.com/a8cfb85000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000336000},\"76561198002668703\":{\"url\":\"https://avatars.steamstatic.com/4707320100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000337000},\"76561198002676622\":{\"url\":\"https://avatars.steamstatic.com/e53eabb200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000338000},\"76561198002684541\":{\"url\":\"https://avatars.steamstatic.com/8376256300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000339000},\"76561198002692460\":{\"url\":\"https://avatars.steamstatic.com/21ad9f1400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000340000},\"76561198002700379\":{\"url\":\"https://avatars.steamstatic.com/bfe518c500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000341000},\"76561198002708298\":{\"url\":\"https://avatars.steamstatic.com/5e1c927600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000342000},\"76561198002716217\":{\"url\":\"https://avatars.steamstatic.com/fc540c2700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000343000},\"76561198002724136\":{\"url\":\"https://avatars.steamstatic.com/9a8b85d800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000344000},\"76561198002732055\":{\"url\":\"https://avatars.steamstatic.com/38c2ff8900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000345000},\"76561198002739974\":{\"url\":\"https://avatars.steamstatic.com/d6fa793a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000346000},\"76561198002747893\":{\"url\":\"https://avatars.steamstatic.com/7531f2eb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000347000},\"76561198002755812\":{\"url\":\"https://avatars.steamstatic.com/13696c9c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000348000},\"76561198002763731\":{\"url\":\"https://avatars.steamstatic.com/b1a0e64d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000349000},\"76561198002771650\":{\"url\":\"https://avatars.steamstatic.com/4fd85ffe00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000350000},\"76561198002779569\":{\"url\":\"https://avatars.steamstatic.com/ee0fd9af00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000351000},\"76561198002787488\":{\"url\":\"https://avatars.steamstatic.com/8c47536000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000352000},\"76561198002795407\":{\"url\":\"https://avatars.steamstatic.com/2a7ecd1100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000353000},\"76561198002803326\":{\"url\":\"https://avatars.steamstatic.com/c8b646c200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000354000},\"76561198002811245\":{\"url\":\"https://avatars.steamstatic.com/66edc07300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000355000},\"76561198002819164\":{\"url\":\"https://avatars.steamstatic.com/05253a2400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000356000},\"76561198002827083\":{\"url\":\"https://avatars.steamstatic.com/a35cb3d500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000357000},\"76561198002835002\":{\"url\":\"https://avatars.steamstatic.com/41942d8600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000358000},\"76561198002842921\":{\"url\":\"https://avatars.steamstatic.com/dfcba73700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000359000},\"76561198002850840\":{\"url\":\"https://avatars.steamstatic.com/7e0320e800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000360000},\"76561198002858759\":{\"url\":\"https://avatars.steamstatic.com/1c3a9a9900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000361000},\"76561198002866678\":{\"url\":\"https://avatars.steamstatic.com/ba72144a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000362000},\"76561198002874597\":{\"url\":\"https://avatars.steamstatic.com/58a98dfb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000363000},\"76561198002882516\":{\"url\":\"https://avatars.steamstatic.com/f6e107ac00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000364000},\"76561198002890435\":{\"url\":\"https://avatars.steamstatic.com/9518815d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000365000},\"76561198002898354\":{\"url\":\"https://avatars.steamstatic.com/334ffb0e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000366000},\"76561198002906273\":{\"url\":\"https://avatars.steamstatic.com/d18774bf00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000367000},\"76561198002914192\":{\"url\":\"https://avatars.steamstatic.com/6fbeee7000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000368000},\"76561198002922111\":{\"url\":\"https://avatars.steamstatic.com/0df6682100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000369000},\"76561198002930030\":{\"url\":\"https://avatars.steamstatic.com/ac2de1d200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000370000},\"76561198002937949\":{\"url\":\"https://avatars.steamstatic.com/4a655b8300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000371000},\"76561198002945868\":{\"url\":\"https://avatars.steamstatic.com/e89cd53400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000372000},\"76561198002953787\":{\"url\":\"https://avatars.steamstatic.com/86d44ee500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000373000},\"76561198002961706\":{\"url\":\"https://avatars.steamstatic.com/250bc89600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000374000},\"76561198002969625\":{\"url\":\"https://avatars.steamstatic.com/c343424700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000375000},\"76561198002977544\":{\"url\":\"https://avatars.steamstatic.com/617abbf800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000376000},\"76561198002985463\":{\"url\":\"https://avatars.steamstatic.com/ffb235a900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000377000},\"76561198002993382\":{\"url\":\"https://avatars.steamstatic.com/9de9af5a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000378000},\"76561198003001301\":{\"url\":\"https://avatars.steamstatic.com/3c21290b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000379000},\"76561198003009220\":{\"url\":\"https://avatars.steamstatic.com/da58a2bc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000380000},\"76561198003017139\":{\"url\":\"https://avatars.steamstatic.com/78901c6d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000381000},\"76561198003025058\":{\"url\":\"https://avatars.steamstatic.com/16c7961e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000382000},\"76561198003032977\":{\"url\":\"https://avatars.steamstatic.com/b4ff0fcf00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000383000},\"76561198003040896\":{\"url\":\"https://avatars.steamstatic.com/5336898000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000384000},\"76561198003048815\":{\"url\":\"https://avatars.steamstatic.com/f16e033100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000385000},\"76561198003056734\":{\"url\":\"https://avatars.steamstatic.com/8fa57ce200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000386000},\"76561198003064653\":{\"url\":\"https://avatars.steamstatic.com/2ddcf69300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000387000},\"76561198003072572\":{\"url\":\"https://avatars.steamstatic.com/cc14704400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000388000},\"76561198003080491\":{\"url\":\"https://avatars.steamstatic.com/6a4be9f500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000389000},\"76561198003088410\":{\"url\":\"https://avatars.steamstatic.com/088363a600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000390000},\"76561198003096329\":{\"url\":\"https://avatars.steamstatic.com/a6badd5700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000391000},\"76561198003104248\":{\"url\":\"https://avatars.steamstatic.com/44f2570800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000392000},\"76561198003112167\":{\"url\":\"https://avatars.steamstatic.com/e329d0b900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000393000},\"76561198003120086\":{\"url\":\"https://avatars.steamstatic.com/81614a6a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000394000},\"76561198003128005\":{\"url\":\"https://avatars.steamstatic.com/1f98c41b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000395000},\"76561198003135924\":{\"url\":\"https://avatars.steamstatic.com/bdd03dcc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000396000},\"76561198003143843\":{\"url\":\"https://avatars.steamstatic.com/5c07b77d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000397000},\"76561198003151762\":{\"url\":\"https://avatars.steamstatic.com/fa3f312e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000398000},\"76561198003159681\":{\"url\":\"https://avatars.steamstatic.com/9876aadf00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000399000},\"76561198003167600\":{\"url\":\"https://avatars.steamstatic.com/36ae249000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000400000},\"76561198003175519\":{\"url\":\"https://avatars.steamstatic.com/d4e59e4100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000401000},\"76561198003183438\":{\"url\":\"https://avatars.steamstatic.com/731d17f200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000402000},\"76561198003191357\":{\"url\":\"https://avatars.steamstatic.com/115491a300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000403000},\"76561198003199276\":{\"url\":\"https://avatars.steamstatic.com/af8c0b5400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000404000},\"76561198003207195\":{\"url\":\"https://avatars.steamstatic.com/4dc3850500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000405000},\"76561198003215114\":{\"url\":\"https://avatars.steamstatic.com/ebfafeb600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000406000},\"76561198003223033\":{\"url\":\"https://avatars.steamstatic.com/8a32786700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000407000},\"76561198003230952\":{\"url\":\"https://avatars.steamstatic.com/2869f21800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000408000},\"76561198003238871\":{\"url\":\"https://avatars.steamstatic.com/c6a16bc900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000409000},\"76561198003246790\":{\"url\":\"https://avatars.steamstatic.com/64d8e57a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000410000},\"76561198003254709\":{\"url\":\"https://avatars.steamstatic.com/03105f2b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000411000},\"76561198003262628\":{\"url\":\"https://avatars.steamstatic.com/a147d8dc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000412000},\"76561198003270547\":{\"url\":\"https://avatars.steamstatic.com/3f7f528d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000413000},\"76561198003278466\":{\"url\":\"https://avatars.steamstatic.com/ddb6cc3e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000414000},\"76561198003286385\":{\"url\":\"https://avatars.steamstatic.com/7bee45ef00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000415000},\"76561198003294304\":{\"url\":\"https://avatars.steamstatic.com/1a25bfa000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000416000},\"76561198003302223\":{\"url\":\"https://avatars.steamstatic.com/b85d395100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000417000},\"76561198003310142\":{\"url\":\"https://avatars.steamstatic.com/5694b30200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000418000},\"76561198003318061\":{\"url\":\"https://avatars.steamstatic.com/f4cc2cb300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000419000},\"76561198003325980\":{\"url\":\"https://avatars.steamstatic.com/9303a66400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000420000},\"76561198003333899\":{\"url\":\"https://avatars.steamstatic.com/313b201500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000421000},\"76561198003341818\":{\"url\":\"https://avatars.steamstatic.com/cf7299c600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000422000},\"76561198003349737\":{\"url\":\"https://avatars.steamstatic.com/6daa137700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000423000},\"76561198003357656\":{\"url\":\"https://avatars.steamstatic.com/0be18d2800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000424000},\"76561198003365575\":{\"url\":\"https://avatars.steamstatic.com/aa1906d900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000425000},\"76561198003373494\":{\"url\":\"https://avatars.steamstatic.com/4850808a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000426000},\"76561198003381413\":{\"url\":\"https://avatars.steamstatic.com/e687fa3b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000427000},\"76561198003389332\":{\"url\":\"https://avatars.steamstatic.com/84bf73ec00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000428000},\"76561198003397251\":{\"url\":\"https://avatars.steamstatic.com/22f6ed9d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000429000},\"76561198003405170\":{\"url\":\"https://avatars.steamstatic.com/c12e674e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000430000},\"76561198003413089\":{\"url\":\"https://avatars.steamstatic.com/5f65e0ff00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000431000},\"76561198003421008\":{\"url\":\"https://avatars.steamstatic.com/fd9d5ab000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000432000},\"76561198003428927\":{\"url\":\"https://avatars.steamstatic.com/9bd4d46100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000433000},\"76561198003436846\":{\"url\":\"https://avatars.steamstatic.com/3a0c4e1200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000434000},\"76561198003444765\":{\"url\":\"https://avatars.steamstatic.com/d843c7c300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000435000},\"76561198003452684\":{\"url\":\"https://avatars.steamstatic.com/767b417400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000436000},\"76561198003460603\":{\"url\":\"https://avatars.steamstatic.com/14b2bb2500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000437000},\"76561198003468522\":{\"url\":\"https://avatars.steamstatic.com/b2ea34d600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000438000},\"76561198003476441\":{\"url\":\"https://avatars.steamstatic.com/5121ae8700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000439000},\"76561198003484360\":{\"url\":\"https://avatars.steamstatic.com/ef59283800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000440000},\"76561198003492279\":{\"url\":\"https://avatars.steamstatic.com/8d90a1e900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000441000},\"76561198003500198\":{\"url\":\"https://avatars.steamstatic.com/2bc81b9a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000442000},\"76561198003508117\":{\"url\":\"https://avatars.steamstatic.com/c9ff954b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000443000},\"76561198003516036\":{\"url\":\"https://avatars.steamstatic.com/68370efc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000444000},\"76561198003523955\":{\"url\":\"https://avatars.steamstatic.com/066e88ad00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000445000},\"76561198003531874\":{\"url\":\"https://avatars.steamstatic.com/a4a6025e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000446000},\"76561198003539793\":{\"url\":\"https://avatars.steamstatic.com/42dd7c0f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000447000},\"76561198003547712\":{\"url\":\"https://avatars.steamstatic.com/e114f5c000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000448000},\"76561198003555631\":{\"url\":\"https://avatars.steamstatic.com/7f4c6f7100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000449000},\"76561198003563550\":{\"url\":\"https://avatars.steamstatic.com/1d83e92200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000450000},\"76561198003571469\":{\"url\":\"https://avatars.steamstatic.com/bbbb62d300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000451000},\"76561198003579388\":{\"url\":\"https://avatars.steamstatic.com/59f2dc8400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000452000},\"76561198003587307\":{\"url\":\"https://avatars.steamstatic.com/f82a563500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000453000},\"76561198003595226\":{\"url\":\"https://avatars.steamstatic.com/9661cfe600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000454000},\"76561198003603145\":{\"url\":\"https://avatars.steamstatic.com/3499499700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000455000},\"76561198003611064\":{\"url\":\"https://avatars.steamstatic.com/d2d0c34800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000456000},\"76561198003618983\":{\"url\":\"https://avatars.steamstatic.com/71083cf900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000457000},\"76561198003626902\":{\"url\":\"https://avatars.steamstatic.com/0f3fb6aa00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000458000},\"76561198003634821\":{\"url\":\"https://avatars.steamstatic.com/ad77305b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000459000},\"76561198003642740\":{\"url\":\"https://avatars.steamstatic.com/4baeaa0c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000460000},\"76561198003650659\":{\"url\":\"https://avatars.steamstatic.com/e9e623bd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000461000},\"76561198003658578\":{\"url\":\"https://avatars.steamstatic.com/881d9d6e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000462000},\"76561198003666497\":{\"url\":\"https://avatars.steamstatic.com/2655171f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000463000},\"76561198003674416\":{\"url\":\"https://avatars.steamstatic.com/c48c90d000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000464000},\"76561198003682335\":{\"url\":\"https://avatars.steamstatic.com/62c40a8100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000465000},\"76561198003690254\":{\"url\":\"https://avatars.steamstatic.com/00fb843200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000466000},\"76561198003698173\":{\"url\":\"https://avatars.steamstatic.com/9f32fde300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000467000},\"76561198003706092\":{\"url\":\"https://avatars.steamstatic.com/3d6a779400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000468000},\"76561198003714011\":{\"url\":\"https://avatars.steamstatic.com/dba1f14500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000469000},\"76561198003721930\":{\"url\":\"https://avatars.steamstatic.com/79d96af600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000470000},\"76561198003729849\":{\"url\":\"https://avatars.steamstatic.com/1810e4a700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000471000},\"76561198003737768\":{\"url\":\"https://avatars.steamstatic.com/b6485e5800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000472000},\"76561198003745687\":{\"url\":\"https://avatars.steamstatic.com/547fd80900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000473000},\"76561198003753606\":{\"url\":\"https://avatars.steamstatic.com/f2b751ba00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000474000},\"76561198003761525\":{\"url\":\"https://avatars.steamstatic.com/90eecb6b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000475000},\"76561198003769444\":{\"url\":\"https://avatars.steamstatic.com/2f26451c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000476000},\"76561198003777363\":{\"url\":\"https://avatars.steamstatic.com/cd5dbecd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000477000},\"76561198003785282\":{\"url\":\"https://avatars.steamstatic.com/6b95387e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000478000},\"76561198003793201\":{\"url\":\"https://avatars.steamstatic.com/09ccb22f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000479000},\"76561198003801120\":{\"url\":\"https://avatars.steamstatic.com/a8042be000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000480000},\"76561198003809039\":{\"url\":\"https://avatars.steamstatic.com/463ba59100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000481000},\"76561198003816958\":{\"url\":\"https://avatars.steamstatic.com/e4731f4200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000482000},\"76561198003824877\":{\"url\":\"https://avatars.steamstatic.com/82aa98f300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000483000},\"76561198003832796\":{\"url\":\"https://avatars.steamstatic.com/20e212a400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000484000},\"76561198003840715\":{\"url\":\"https://avatars.steamstatic.com/bf198c5500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000485000},\"76561198003848634\":{\"url\":\"https://avatars.steamstatic.com/5d51060600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000486000},\"76561198003856553\":{\"url\":\"https://avatars.steamstatic.com/fb887fb700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000487000},\"76561198003864472\":{\"url\":\"https://avatars.steamstatic.com/99bff96800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000488000},\"76561198003872391\":{\"url\":\"https://avatars.steamstatic.com/37f7731900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000489000},\"76561198003880310\":{\"url\":\"https://avatars.steamstatic.com/d62eecca00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000490000},\"76561198003888229\":{\"url\":\"https://avatars.steamstatic.com/7466667b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000491000},\"76561198003896148\":{\"url\":\"https://avatars.steamstatic.com/129de02c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000492000},\"76561198003904067\":{\"url\":\"https://avatars.steamstatic.com/b0d559dd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000493000},\"76561198003911986\":{\"url\":\"https://avatars.steamstatic.com/4f0cd38e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000494000},\"76561198003919905\":{\"url\":\"https://avatars.steamstatic.com/ed444d3f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000495000},\"76561198003927824\":{\"url\":\"https://avatars.steamstatic.com/8b7bc6f000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000496000},\"76561198003935743\":{\"url\":\"https://avatars.steamstatic.com/29b340a100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000497000},\"76561198003943662\":{\"url\":\"https://avatars.steamstatic.com/c7eaba5200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000498000},\"76561198003951581\":{\"url\":\"https://avatars.steamstatic.com/6622340300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000499000},\"76561198003959500\":{\"url\":\"https://avatars.steamstatic.com/0459adb400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000500000},\"76561198003967419\":{\"url\":\"https://avatars.steamstatic.com/a291276500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000501000},\"76561198003975338\":{\"url\":\"https://avatars.steamstatic.com/40c8a11600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000502000},\"76561198003983257\":{\"url\":\"https://avatars.steamstatic.com/df001ac700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000503000},\"76561198003991176\":{\"url\":\"https://avatars.steamstatic.com/7d37947800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000504000},\"76561198003999095\":{\"url\":\"https://avatars.steamstatic.com/1b6f0e2900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000505000},\"76561198004007014\":{\"url\":\"https://avatars.steamstatic.com/b9a687da00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000506000},\"76561198004014933\":{\"url\":\"https://avatars.steamstatic.com/57de018b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000507000},\"76561198004022852\":{\"url\":\"https://avatars.steamstatic.com/f6157b3c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000508000},\"76561198004030771\":{\"url\":\"https://avatars.steamstatic.com/944cf4ed00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000509000},\"76561198004038690\":{\"url\":\"https://avatars.steamstatic.com/32846e9e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000510000},\"76561198004046609\":{\"url\":\"https://avatars.steamstatic.com/d0bbe84f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000511000},\"76561198004054528\":{\"url\":\"https://avatars.steamstatic.com/6ef3620000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000512000},\"76561198004062447\":{\"url\":\"https://avatars.steamstatic.com/0d2adbb100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000513000},\"76561198004070366\":{\"url\":\"https://avatars.steamstatic.com/ab62556200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000514000},\"76561198004078285\":{\"url\":\"https://avatars.steamstatic.com/4999cf1300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000515000},\"76561198004086204\":{\"url\":\"https://avatars.steamstatic.com/e7d148c400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000516000},\"76561198004094123\":{\"url\":\"https://avatars.steamstatic.com/8608c27500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000517000},\"76561198004102042\":{\"url\":\"https://avatars.steamstatic.com/24403c2600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000518000},\"76561198004109961\":{\"url\":\"https://avatars.steamstatic.com/c277b5d700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000519000},\"76561198004117880\":{\"url\":\"https://avatars.steamstatic.com/60af2f8800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000520000},\"76561198004125799\":{\"url\":\"https://avatars.steamstatic.com/fee6a93900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000521000},\"76561198004133718\":{\"url\":\"https://avatars.steamstatic.com/9d1e22ea00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000522000},\"76561198004141637\":{\"url\":\"https://avatars.steamstatic.com/3b559c9b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000523000},\"76561198004149556\":{\"url\":\"https://avatars.steamstatic.com/d98d164c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000524000},\"76561198004157475\":{\"url\":\"https://avatars.steamstatic.com/77c48ffd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000525000},\"76561198004165394\":{\"url\":\"https://avatars.steamstatic.com/15fc09ae00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000526000},\"76561198004173313\":{\"url\":\"https://avatars.steamstatic.com/b433835f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000527000},\"76561198004181232\":{\"url\":\"https://avatars.steamstatic.com/526afd1000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000528000},\"76561198004189151\":{\"url\":\"https://avatars.steamstatic.com/f0a276c100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000529000},\"76561198004197070\":{\"url\":\"https://avatars.steamstatic.com/8ed9f07200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000530000},\"76561198004204989\":{\"url\":\"https://avatars.steamstatic.com/2d116a2300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000531000},\"76561198004212908\":{\"url\":\"https://avatars.steamstatic.com/cb48e3d400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000532000},\"76561198004220827\":{\"url\":\"https://avatars.steamstatic.com/69805d8500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000533000},\"76561198004228746\":{\"url\":\"https://avatars.steamstatic.com/07b7d73600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000534000},\"76561198004236665\":{\"url\":\"https://avatars.steamstatic.com/a5ef50e700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000535000},\"76561198004244584\":{\"url\":\"https://avatars.steamstatic.com/4426ca9800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000536000},\"76561198004252503\":{\"url\":\"https://avatars.steamstatic.com/e25e444900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000537000},\"76561198004260422\":{\"url\":\"https://avatars.steamstatic.com/8095bdfa00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000538000},\"76561198004268341\":{\"url\":\"https://avatars.steamstatic.com/1ecd37ab00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000539000},\"76561198004276260\":{\"url\":\"https://avatars.steamstatic.com/bd04b15c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000540000},\"76561198004284179\":{\"url\":\"https://avatars.steamstatic.com/5b3c2b0d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000541000},\"76561198004292098\":{\"url\":\"https://avatars.steamstatic.com/f973a4be00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000542000},\"76561198004300017\":{\"url\":\"https://avatars.steamstatic.com/97ab1e6f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000543000},\"76561198004307936\":{\"url\":\"https://avatars.steamstatic.com/35e2982000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000544000},\"76561198004315855\":{\"url\":\"https://avatars.steamstatic.com/d41a11d100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000545000},\"76561198004323774\":{\"url\":\"https://avatars.steamstatic.com/72518b8200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000546000},\"76561198004331693\":{\"url\":\"https://avatars.steamstatic.com/1089053300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000547000},\"76561198004339612\":{\"url\":\"https://avatars.steamstatic.com/aec07ee400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000548000},\"76561198004347531\":{\"url\":\"https://avatars.steamstatic.com/4cf7f89500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000549000},\"76561198004355450\":{\"url\":\"https://avatars.steamstatic.com/eb2f724600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000550000},\"76561198004363369\":{\"url\":\"https://avatars.steamstatic.com/8966ebf700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000551000},\"76561198004371288\":{\"url\":\"https://avatars.steamstatic.com/279e65a800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000552000},\"76561198004379207\":{\"url\":\"https://avatars.steamstatic.com/c5d5df5900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000553000},\"76561198004387126\":{\"url\":\"https://avatars.steamstatic.com/640d590a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000554000},\"76561198004395045\":{\"url\":\"https://avatars.steamstatic.com/0244d2bb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000555000},\"76561198004402964\":{\"url\":\"https://avatars.steamstatic.com/a07c4c6c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000556000},\"76561198004410883\":{\"url\":\"https://avatars.steamstatic.com/3eb3c61d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000557000},\"76561198004418802\":{\"url\":\"https://avatars.steamstatic.com/dceb3fce00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000558000},\"76561198004426721\":{\"url\":\"https://avatars.steamstatic.com/7b22b97f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000559000},\"76561198004434640\":{\"url\":\"https://avatars.steamstatic.com/195a333000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000560000},\"76561198004442559\":{\"url\":\"https://avatars.steamstatic.com/b791ace100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000561000},\"76561198004450478\":{\"url\":\"https://avatars.steamstatic.com/55c9269200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000562000},\"76561198004458397\":{\"url\":\"https://avatars.steamstatic.com/f400a04300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000563000},\"76561198004466316\":{\"url\":\"https://avatars.steamstatic.com/923819f400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000564000},\"76561198004474235\":{\"url\":\"https://avatars.steamstatic.com/306f93a500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000565000},\"76561198004482154\":{\"url\":\"https://avatars.steamstatic.com/cea70d5600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000566000},\"76561198004490073\":{\"url\":\"https://avatars.steamstatic.com/6cde870700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000567000},\"76561198004497992\":{\"url\":\"https://avatars.steamstatic.com/0b1600b800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000568000},\"76561198004505911\":{\"url\":\"https://avatars.steamstatic.com/a94d7a6900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000569000},\"76561198004513830\":{\"url\":\"https://avatars.steamstatic.com/4784f41a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000570000},\"76561198004521749\":{\"url\":\"https://avatars.steamstatic.com/e5bc6dcb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000571000},\"76561198004529668\":{\"url\":\"https://avatars.steamstatic.com/83f3e77c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000572000},\"76561198004537587\":{\"url\":\"https://avatars.steamstatic.com/222b612d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000573000},\"76561198004545506\":{\"url\":\"https://avatars.steamstatic.com/c062dade00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000574000},\"76561198004553425\":{\"url\":\"https://avatars.steamstatic.com/5e9a548f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000575000},\"76561198004561344\":{\"url\":\"https://avatars.steamstatic.com/fcd1ce4000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000576000},\"76561198004569263\":{\"url\":\"https://avatars.steamstatic.com/9b0947f100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000577000},\"76561198004577182\":{\"url\":\"https://avatars.steamstatic.com/3940c1a200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000578000},\"76561198004585101\":{\"url\":\"https://avatars.steamstatic.com/d7783b5300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000579000},\"76561198004593020\":{\"url\":\"https://avatars.steamstatic.com/75afb50400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000580000},\"76561198004600939\":{\"url\":\"https://avatars.steamstatic.com/13e72eb500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000581000},\"76561198004608858\":{\"url\":\"https://avatars.steamstatic.com/b21ea86600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000582000},\"76561198004616777\":{\"url\":\"https://avatars.steamstatic.com/5056221700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000583000},\"76561198004624696\":{\"url\":\"https://avatars.steamstatic.com/ee8d9bc800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000584000},\"76561198004632615\":{\"url\":\"https://avatars.steamstatic.com/8cc5157900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000585000},\"76561198004640534\":{\"url\":\"https://avatars.steamstatic.com/2afc8f2a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000586000},\"76561198004648453\":{\"url\":\"https://avatars.steamstatic.com/c93408db00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000587000},\"76561198004656372\":{\"url\":\"https://avatars.steamstatic.com/676b828c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000588000},\"76561198004664291\":{\"url\":\"https://avatars.steamstatic.com/05a2fc3d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000589000},\"76561198004672210\":{\"url\":\"https://avatars.steamstatic.com/a3da75ee00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000590000},\"76561198004680129\":{\"url\":\"https://avatars.steamstatic.com/4211ef9f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000591000},\"76561198004688048\":{\"url\":\"https://avatars.steamstatic.com/e049695000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000592000},\"76561198004695967\":{\"url\":\"https://avatars.steamstatic.com/7e80e30100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000593000},\"76561198004703886\":{\"url\":\"https://avatars.steamstatic.com/1cb85cb200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000594000},\"76561198004711805\":{\"url\":\"https://avatars.steamstatic.com/baefd66300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000595000},\"76561198004719724\":{\"url\":\"https://avatars.steamstatic.com/5927501400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000596000},\"76561198004727643\":{\"url\":\"https://avatars.steamstatic.com/f75ec9c500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000597000},\"76561198004735562\":{\"url\":\"https://avatars.steamstatic.com/9596437600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000598000},\"76561198004743481\":{\"url\":\"https://avatars.steamstatic.com/33cdbd2700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000599000}}", + "accshift_roblox_avatars": "{\"1234567\":{\"url\":\"https://tr.rbxcdn.com/x/150/150/AvatarHeadshot/Png\"}}", + "accshift_ban_check_state_v2": "{\"lastCheck\":1790000000000,\"queue\":[]}", + "accshift_ban_info_cache_v1": "{\"76561198000000001\":{\"vac\":0,\"game\":0}}" + }, + "final": { + "accshift_settings": "{\"themeId\":\"dark\",\"language\":\"fr\",\"animations\":\"full\"}", + "accshift_folders": "{\"version\":1,\"folders\":[{\"id\":\"f1\",\"name\":\"Dossier é à ç ñ ü\"}],\"itemOrder\":{}}", + "accshift_personas": "[{\"id\":\"p1\",\"name\":\"Persona\",\"accent\":\"#8b5cf6\"}]", + "accshift_account_card_notes": "{\"steam:76561198000000001\":\"Compte principal ✓ 日本語 Ωμέγα 😀\"}", + "accshift_account_card_colors": "{\"steam:76561198000000001\":\"violet\"}", + "accshift_account_default_game": "{\"steam:76561198000000001\":730}", + "accshift_folder_card_colors": "{\"f1\":\"blue\"}", + "accshift_viewMode": "list", + "accshift_avatars": "{\"76561198000000000\":{\"url\":\"https://avatars.steamstatic.com/0000000000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000000000},\"76561198000007919\":{\"url\":\"https://avatars.steamstatic.com/9e3779b100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000001000},\"76561198000015838\":{\"url\":\"https://avatars.steamstatic.com/3c6ef36200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000002000},\"76561198000023757\":{\"url\":\"https://avatars.steamstatic.com/daa66d1300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000003000},\"76561198000031676\":{\"url\":\"https://avatars.steamstatic.com/78dde6c400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000004000},\"76561198000039595\":{\"url\":\"https://avatars.steamstatic.com/1715607500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000005000},\"76561198000047514\":{\"url\":\"https://avatars.steamstatic.com/b54cda2600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000006000},\"76561198000055433\":{\"url\":\"https://avatars.steamstatic.com/538453d700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000007000},\"76561198000063352\":{\"url\":\"https://avatars.steamstatic.com/f1bbcd8800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000008000},\"76561198000071271\":{\"url\":\"https://avatars.steamstatic.com/8ff3473900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000009000},\"76561198000079190\":{\"url\":\"https://avatars.steamstatic.com/2e2ac0ea00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000010000},\"76561198000087109\":{\"url\":\"https://avatars.steamstatic.com/cc623a9b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000011000},\"76561198000095028\":{\"url\":\"https://avatars.steamstatic.com/6a99b44c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000012000},\"76561198000102947\":{\"url\":\"https://avatars.steamstatic.com/08d12dfd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000013000},\"76561198000110866\":{\"url\":\"https://avatars.steamstatic.com/a708a7ae00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000014000},\"76561198000118785\":{\"url\":\"https://avatars.steamstatic.com/4540215f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000015000},\"76561198000126704\":{\"url\":\"https://avatars.steamstatic.com/e3779b1000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000016000},\"76561198000134623\":{\"url\":\"https://avatars.steamstatic.com/81af14c100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000017000},\"76561198000142542\":{\"url\":\"https://avatars.steamstatic.com/1fe68e7200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000018000},\"76561198000150461\":{\"url\":\"https://avatars.steamstatic.com/be1e082300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000019000},\"76561198000158380\":{\"url\":\"https://avatars.steamstatic.com/5c5581d400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000020000},\"76561198000166299\":{\"url\":\"https://avatars.steamstatic.com/fa8cfb8500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000021000},\"76561198000174218\":{\"url\":\"https://avatars.steamstatic.com/98c4753600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000022000},\"76561198000182137\":{\"url\":\"https://avatars.steamstatic.com/36fbeee700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000023000},\"76561198000190056\":{\"url\":\"https://avatars.steamstatic.com/d533689800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000024000},\"76561198000197975\":{\"url\":\"https://avatars.steamstatic.com/736ae24900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000025000},\"76561198000205894\":{\"url\":\"https://avatars.steamstatic.com/11a25bfa00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000026000},\"76561198000213813\":{\"url\":\"https://avatars.steamstatic.com/afd9d5ab00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000027000},\"76561198000221732\":{\"url\":\"https://avatars.steamstatic.com/4e114f5c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000028000},\"76561198000229651\":{\"url\":\"https://avatars.steamstatic.com/ec48c90d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000029000},\"76561198000237570\":{\"url\":\"https://avatars.steamstatic.com/8a8042be00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000030000},\"76561198000245489\":{\"url\":\"https://avatars.steamstatic.com/28b7bc6f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000031000},\"76561198000253408\":{\"url\":\"https://avatars.steamstatic.com/c6ef362000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000032000},\"76561198000261327\":{\"url\":\"https://avatars.steamstatic.com/6526afd100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000033000},\"76561198000269246\":{\"url\":\"https://avatars.steamstatic.com/035e298200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000034000},\"76561198000277165\":{\"url\":\"https://avatars.steamstatic.com/a195a33300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000035000},\"76561198000285084\":{\"url\":\"https://avatars.steamstatic.com/3fcd1ce400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000036000},\"76561198000293003\":{\"url\":\"https://avatars.steamstatic.com/de04969500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000037000},\"76561198000300922\":{\"url\":\"https://avatars.steamstatic.com/7c3c104600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000038000},\"76561198000308841\":{\"url\":\"https://avatars.steamstatic.com/1a7389f700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000039000},\"76561198000316760\":{\"url\":\"https://avatars.steamstatic.com/b8ab03a800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000040000},\"76561198000324679\":{\"url\":\"https://avatars.steamstatic.com/56e27d5900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000041000},\"76561198000332598\":{\"url\":\"https://avatars.steamstatic.com/f519f70a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000042000},\"76561198000340517\":{\"url\":\"https://avatars.steamstatic.com/935170bb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000043000},\"76561198000348436\":{\"url\":\"https://avatars.steamstatic.com/3188ea6c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000044000},\"76561198000356355\":{\"url\":\"https://avatars.steamstatic.com/cfc0641d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000045000},\"76561198000364274\":{\"url\":\"https://avatars.steamstatic.com/6df7ddce00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000046000},\"76561198000372193\":{\"url\":\"https://avatars.steamstatic.com/0c2f577f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000047000},\"76561198000380112\":{\"url\":\"https://avatars.steamstatic.com/aa66d13000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000048000},\"76561198000388031\":{\"url\":\"https://avatars.steamstatic.com/489e4ae100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000049000},\"76561198000395950\":{\"url\":\"https://avatars.steamstatic.com/e6d5c49200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000050000},\"76561198000403869\":{\"url\":\"https://avatars.steamstatic.com/850d3e4300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000051000},\"76561198000411788\":{\"url\":\"https://avatars.steamstatic.com/2344b7f400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000052000},\"76561198000419707\":{\"url\":\"https://avatars.steamstatic.com/c17c31a500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000053000},\"76561198000427626\":{\"url\":\"https://avatars.steamstatic.com/5fb3ab5600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000054000},\"76561198000435545\":{\"url\":\"https://avatars.steamstatic.com/fdeb250700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000055000},\"76561198000443464\":{\"url\":\"https://avatars.steamstatic.com/9c229eb800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000056000},\"76561198000451383\":{\"url\":\"https://avatars.steamstatic.com/3a5a186900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000057000},\"76561198000459302\":{\"url\":\"https://avatars.steamstatic.com/d891921a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000058000},\"76561198000467221\":{\"url\":\"https://avatars.steamstatic.com/76c90bcb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000059000},\"76561198000475140\":{\"url\":\"https://avatars.steamstatic.com/1500857c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000060000},\"76561198000483059\":{\"url\":\"https://avatars.steamstatic.com/b337ff2d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000061000},\"76561198000490978\":{\"url\":\"https://avatars.steamstatic.com/516f78de00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000062000},\"76561198000498897\":{\"url\":\"https://avatars.steamstatic.com/efa6f28f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000063000},\"76561198000506816\":{\"url\":\"https://avatars.steamstatic.com/8dde6c4000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000064000},\"76561198000514735\":{\"url\":\"https://avatars.steamstatic.com/2c15e5f100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000065000},\"76561198000522654\":{\"url\":\"https://avatars.steamstatic.com/ca4d5fa200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000066000},\"76561198000530573\":{\"url\":\"https://avatars.steamstatic.com/6884d95300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000067000},\"76561198000538492\":{\"url\":\"https://avatars.steamstatic.com/06bc530400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000068000},\"76561198000546411\":{\"url\":\"https://avatars.steamstatic.com/a4f3ccb500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000069000},\"76561198000554330\":{\"url\":\"https://avatars.steamstatic.com/432b466600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000070000},\"76561198000562249\":{\"url\":\"https://avatars.steamstatic.com/e162c01700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000071000},\"76561198000570168\":{\"url\":\"https://avatars.steamstatic.com/7f9a39c800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000072000},\"76561198000578087\":{\"url\":\"https://avatars.steamstatic.com/1dd1b37900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000073000},\"76561198000586006\":{\"url\":\"https://avatars.steamstatic.com/bc092d2a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000074000},\"76561198000593925\":{\"url\":\"https://avatars.steamstatic.com/5a40a6db00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000075000},\"76561198000601844\":{\"url\":\"https://avatars.steamstatic.com/f878208c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000076000},\"76561198000609763\":{\"url\":\"https://avatars.steamstatic.com/96af9a3d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000077000},\"76561198000617682\":{\"url\":\"https://avatars.steamstatic.com/34e713ee00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000078000},\"76561198000625601\":{\"url\":\"https://avatars.steamstatic.com/d31e8d9f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000079000},\"76561198000633520\":{\"url\":\"https://avatars.steamstatic.com/7156075000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000080000},\"76561198000641439\":{\"url\":\"https://avatars.steamstatic.com/0f8d810100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000081000},\"76561198000649358\":{\"url\":\"https://avatars.steamstatic.com/adc4fab200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000082000},\"76561198000657277\":{\"url\":\"https://avatars.steamstatic.com/4bfc746300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000083000},\"76561198000665196\":{\"url\":\"https://avatars.steamstatic.com/ea33ee1400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000084000},\"76561198000673115\":{\"url\":\"https://avatars.steamstatic.com/886b67c500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000085000},\"76561198000681034\":{\"url\":\"https://avatars.steamstatic.com/26a2e17600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000086000},\"76561198000688953\":{\"url\":\"https://avatars.steamstatic.com/c4da5b2700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000087000},\"76561198000696872\":{\"url\":\"https://avatars.steamstatic.com/6311d4d800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000088000},\"76561198000704791\":{\"url\":\"https://avatars.steamstatic.com/01494e8900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000089000},\"76561198000712710\":{\"url\":\"https://avatars.steamstatic.com/9f80c83a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000090000},\"76561198000720629\":{\"url\":\"https://avatars.steamstatic.com/3db841eb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000091000},\"76561198000728548\":{\"url\":\"https://avatars.steamstatic.com/dbefbb9c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000092000},\"76561198000736467\":{\"url\":\"https://avatars.steamstatic.com/7a27354d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000093000},\"76561198000744386\":{\"url\":\"https://avatars.steamstatic.com/185eaefe00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000094000},\"76561198000752305\":{\"url\":\"https://avatars.steamstatic.com/b69628af00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000095000},\"76561198000760224\":{\"url\":\"https://avatars.steamstatic.com/54cda26000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000096000},\"76561198000768143\":{\"url\":\"https://avatars.steamstatic.com/f3051c1100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000097000},\"76561198000776062\":{\"url\":\"https://avatars.steamstatic.com/913c95c200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000098000},\"76561198000783981\":{\"url\":\"https://avatars.steamstatic.com/2f740f7300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000099000},\"76561198000791900\":{\"url\":\"https://avatars.steamstatic.com/cdab892400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000100000},\"76561198000799819\":{\"url\":\"https://avatars.steamstatic.com/6be302d500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000101000},\"76561198000807738\":{\"url\":\"https://avatars.steamstatic.com/0a1a7c8600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000102000},\"76561198000815657\":{\"url\":\"https://avatars.steamstatic.com/a851f63700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000103000},\"76561198000823576\":{\"url\":\"https://avatars.steamstatic.com/46896fe800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000104000},\"76561198000831495\":{\"url\":\"https://avatars.steamstatic.com/e4c0e99900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000105000},\"76561198000839414\":{\"url\":\"https://avatars.steamstatic.com/82f8634a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000106000},\"76561198000847333\":{\"url\":\"https://avatars.steamstatic.com/212fdcfb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000107000},\"76561198000855252\":{\"url\":\"https://avatars.steamstatic.com/bf6756ac00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000108000},\"76561198000863171\":{\"url\":\"https://avatars.steamstatic.com/5d9ed05d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000109000},\"76561198000871090\":{\"url\":\"https://avatars.steamstatic.com/fbd64a0e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000110000},\"76561198000879009\":{\"url\":\"https://avatars.steamstatic.com/9a0dc3bf00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000111000},\"76561198000886928\":{\"url\":\"https://avatars.steamstatic.com/38453d7000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000112000},\"76561198000894847\":{\"url\":\"https://avatars.steamstatic.com/d67cb72100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000113000},\"76561198000902766\":{\"url\":\"https://avatars.steamstatic.com/74b430d200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000114000},\"76561198000910685\":{\"url\":\"https://avatars.steamstatic.com/12ebaa8300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000115000},\"76561198000918604\":{\"url\":\"https://avatars.steamstatic.com/b123243400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000116000},\"76561198000926523\":{\"url\":\"https://avatars.steamstatic.com/4f5a9de500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000117000},\"76561198000934442\":{\"url\":\"https://avatars.steamstatic.com/ed92179600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000118000},\"76561198000942361\":{\"url\":\"https://avatars.steamstatic.com/8bc9914700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000119000},\"76561198000950280\":{\"url\":\"https://avatars.steamstatic.com/2a010af800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000120000},\"76561198000958199\":{\"url\":\"https://avatars.steamstatic.com/c83884a900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000121000},\"76561198000966118\":{\"url\":\"https://avatars.steamstatic.com/666ffe5a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000122000},\"76561198000974037\":{\"url\":\"https://avatars.steamstatic.com/04a7780b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000123000},\"76561198000981956\":{\"url\":\"https://avatars.steamstatic.com/a2def1bc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000124000},\"76561198000989875\":{\"url\":\"https://avatars.steamstatic.com/41166b6d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000125000},\"76561198000997794\":{\"url\":\"https://avatars.steamstatic.com/df4de51e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000126000},\"76561198001005713\":{\"url\":\"https://avatars.steamstatic.com/7d855ecf00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000127000},\"76561198001013632\":{\"url\":\"https://avatars.steamstatic.com/1bbcd88000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000128000},\"76561198001021551\":{\"url\":\"https://avatars.steamstatic.com/b9f4523100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000129000},\"76561198001029470\":{\"url\":\"https://avatars.steamstatic.com/582bcbe200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000130000},\"76561198001037389\":{\"url\":\"https://avatars.steamstatic.com/f663459300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000131000},\"76561198001045308\":{\"url\":\"https://avatars.steamstatic.com/949abf4400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000132000},\"76561198001053227\":{\"url\":\"https://avatars.steamstatic.com/32d238f500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000133000},\"76561198001061146\":{\"url\":\"https://avatars.steamstatic.com/d109b2a600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000134000},\"76561198001069065\":{\"url\":\"https://avatars.steamstatic.com/6f412c5700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000135000},\"76561198001076984\":{\"url\":\"https://avatars.steamstatic.com/0d78a60800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000136000},\"76561198001084903\":{\"url\":\"https://avatars.steamstatic.com/abb01fb900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000137000},\"76561198001092822\":{\"url\":\"https://avatars.steamstatic.com/49e7996a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000138000},\"76561198001100741\":{\"url\":\"https://avatars.steamstatic.com/e81f131b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000139000},\"76561198001108660\":{\"url\":\"https://avatars.steamstatic.com/86568ccc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000140000},\"76561198001116579\":{\"url\":\"https://avatars.steamstatic.com/248e067d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000141000},\"76561198001124498\":{\"url\":\"https://avatars.steamstatic.com/c2c5802e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000142000},\"76561198001132417\":{\"url\":\"https://avatars.steamstatic.com/60fcf9df00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000143000},\"76561198001140336\":{\"url\":\"https://avatars.steamstatic.com/ff34739000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000144000},\"76561198001148255\":{\"url\":\"https://avatars.steamstatic.com/9d6bed4100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000145000},\"76561198001156174\":{\"url\":\"https://avatars.steamstatic.com/3ba366f200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000146000},\"76561198001164093\":{\"url\":\"https://avatars.steamstatic.com/d9dae0a300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000147000},\"76561198001172012\":{\"url\":\"https://avatars.steamstatic.com/78125a5400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000148000},\"76561198001179931\":{\"url\":\"https://avatars.steamstatic.com/1649d40500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000149000},\"76561198001187850\":{\"url\":\"https://avatars.steamstatic.com/b4814db600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000150000},\"76561198001195769\":{\"url\":\"https://avatars.steamstatic.com/52b8c76700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000151000},\"76561198001203688\":{\"url\":\"https://avatars.steamstatic.com/f0f0411800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000152000},\"76561198001211607\":{\"url\":\"https://avatars.steamstatic.com/8f27bac900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000153000},\"76561198001219526\":{\"url\":\"https://avatars.steamstatic.com/2d5f347a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000154000},\"76561198001227445\":{\"url\":\"https://avatars.steamstatic.com/cb96ae2b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000155000},\"76561198001235364\":{\"url\":\"https://avatars.steamstatic.com/69ce27dc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000156000},\"76561198001243283\":{\"url\":\"https://avatars.steamstatic.com/0805a18d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000157000},\"76561198001251202\":{\"url\":\"https://avatars.steamstatic.com/a63d1b3e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000158000},\"76561198001259121\":{\"url\":\"https://avatars.steamstatic.com/447494ef00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000159000},\"76561198001267040\":{\"url\":\"https://avatars.steamstatic.com/e2ac0ea000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000160000},\"76561198001274959\":{\"url\":\"https://avatars.steamstatic.com/80e3885100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000161000},\"76561198001282878\":{\"url\":\"https://avatars.steamstatic.com/1f1b020200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000162000},\"76561198001290797\":{\"url\":\"https://avatars.steamstatic.com/bd527bb300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000163000},\"76561198001298716\":{\"url\":\"https://avatars.steamstatic.com/5b89f56400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000164000},\"76561198001306635\":{\"url\":\"https://avatars.steamstatic.com/f9c16f1500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000165000},\"76561198001314554\":{\"url\":\"https://avatars.steamstatic.com/97f8e8c600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000166000},\"76561198001322473\":{\"url\":\"https://avatars.steamstatic.com/3630627700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000167000},\"76561198001330392\":{\"url\":\"https://avatars.steamstatic.com/d467dc2800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000168000},\"76561198001338311\":{\"url\":\"https://avatars.steamstatic.com/729f55d900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000169000},\"76561198001346230\":{\"url\":\"https://avatars.steamstatic.com/10d6cf8a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000170000},\"76561198001354149\":{\"url\":\"https://avatars.steamstatic.com/af0e493b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000171000},\"76561198001362068\":{\"url\":\"https://avatars.steamstatic.com/4d45c2ec00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000172000},\"76561198001369987\":{\"url\":\"https://avatars.steamstatic.com/eb7d3c9d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000173000},\"76561198001377906\":{\"url\":\"https://avatars.steamstatic.com/89b4b64e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000174000},\"76561198001385825\":{\"url\":\"https://avatars.steamstatic.com/27ec2fff00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000175000},\"76561198001393744\":{\"url\":\"https://avatars.steamstatic.com/c623a9b000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000176000},\"76561198001401663\":{\"url\":\"https://avatars.steamstatic.com/645b236100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000177000},\"76561198001409582\":{\"url\":\"https://avatars.steamstatic.com/02929d1200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000178000},\"76561198001417501\":{\"url\":\"https://avatars.steamstatic.com/a0ca16c300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000179000},\"76561198001425420\":{\"url\":\"https://avatars.steamstatic.com/3f01907400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000180000},\"76561198001433339\":{\"url\":\"https://avatars.steamstatic.com/dd390a2500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000181000},\"76561198001441258\":{\"url\":\"https://avatars.steamstatic.com/7b7083d600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000182000},\"76561198001449177\":{\"url\":\"https://avatars.steamstatic.com/19a7fd8700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000183000},\"76561198001457096\":{\"url\":\"https://avatars.steamstatic.com/b7df773800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000184000},\"76561198001465015\":{\"url\":\"https://avatars.steamstatic.com/5616f0e900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000185000},\"76561198001472934\":{\"url\":\"https://avatars.steamstatic.com/f44e6a9a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000186000},\"76561198001480853\":{\"url\":\"https://avatars.steamstatic.com/9285e44b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000187000},\"76561198001488772\":{\"url\":\"https://avatars.steamstatic.com/30bd5dfc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000188000},\"76561198001496691\":{\"url\":\"https://avatars.steamstatic.com/cef4d7ad00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000189000},\"76561198001504610\":{\"url\":\"https://avatars.steamstatic.com/6d2c515e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000190000},\"76561198001512529\":{\"url\":\"https://avatars.steamstatic.com/0b63cb0f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000191000},\"76561198001520448\":{\"url\":\"https://avatars.steamstatic.com/a99b44c000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000192000},\"76561198001528367\":{\"url\":\"https://avatars.steamstatic.com/47d2be7100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000193000},\"76561198001536286\":{\"url\":\"https://avatars.steamstatic.com/e60a382200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000194000},\"76561198001544205\":{\"url\":\"https://avatars.steamstatic.com/8441b1d300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000195000},\"76561198001552124\":{\"url\":\"https://avatars.steamstatic.com/22792b8400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000196000},\"76561198001560043\":{\"url\":\"https://avatars.steamstatic.com/c0b0a53500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000197000},\"76561198001567962\":{\"url\":\"https://avatars.steamstatic.com/5ee81ee600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000198000},\"76561198001575881\":{\"url\":\"https://avatars.steamstatic.com/fd1f989700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000199000},\"76561198001583800\":{\"url\":\"https://avatars.steamstatic.com/9b57124800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000200000},\"76561198001591719\":{\"url\":\"https://avatars.steamstatic.com/398e8bf900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000201000},\"76561198001599638\":{\"url\":\"https://avatars.steamstatic.com/d7c605aa00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000202000},\"76561198001607557\":{\"url\":\"https://avatars.steamstatic.com/75fd7f5b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000203000},\"76561198001615476\":{\"url\":\"https://avatars.steamstatic.com/1434f90c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000204000},\"76561198001623395\":{\"url\":\"https://avatars.steamstatic.com/b26c72bd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000205000},\"76561198001631314\":{\"url\":\"https://avatars.steamstatic.com/50a3ec6e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000206000},\"76561198001639233\":{\"url\":\"https://avatars.steamstatic.com/eedb661f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000207000},\"76561198001647152\":{\"url\":\"https://avatars.steamstatic.com/8d12dfd000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000208000},\"76561198001655071\":{\"url\":\"https://avatars.steamstatic.com/2b4a598100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000209000},\"76561198001662990\":{\"url\":\"https://avatars.steamstatic.com/c981d33200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000210000},\"76561198001670909\":{\"url\":\"https://avatars.steamstatic.com/67b94ce300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000211000},\"76561198001678828\":{\"url\":\"https://avatars.steamstatic.com/05f0c69400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000212000},\"76561198001686747\":{\"url\":\"https://avatars.steamstatic.com/a428404500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000213000},\"76561198001694666\":{\"url\":\"https://avatars.steamstatic.com/425fb9f600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000214000},\"76561198001702585\":{\"url\":\"https://avatars.steamstatic.com/e09733a700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000215000},\"76561198001710504\":{\"url\":\"https://avatars.steamstatic.com/7ecead5800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000216000},\"76561198001718423\":{\"url\":\"https://avatars.steamstatic.com/1d06270900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000217000},\"76561198001726342\":{\"url\":\"https://avatars.steamstatic.com/bb3da0ba00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000218000},\"76561198001734261\":{\"url\":\"https://avatars.steamstatic.com/59751a6b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000219000},\"76561198001742180\":{\"url\":\"https://avatars.steamstatic.com/f7ac941c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000220000},\"76561198001750099\":{\"url\":\"https://avatars.steamstatic.com/95e40dcd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000221000},\"76561198001758018\":{\"url\":\"https://avatars.steamstatic.com/341b877e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000222000},\"76561198001765937\":{\"url\":\"https://avatars.steamstatic.com/d253012f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000223000},\"76561198001773856\":{\"url\":\"https://avatars.steamstatic.com/708a7ae000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000224000},\"76561198001781775\":{\"url\":\"https://avatars.steamstatic.com/0ec1f49100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000225000},\"76561198001789694\":{\"url\":\"https://avatars.steamstatic.com/acf96e4200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000226000},\"76561198001797613\":{\"url\":\"https://avatars.steamstatic.com/4b30e7f300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000227000},\"76561198001805532\":{\"url\":\"https://avatars.steamstatic.com/e96861a400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000228000},\"76561198001813451\":{\"url\":\"https://avatars.steamstatic.com/879fdb5500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000229000},\"76561198001821370\":{\"url\":\"https://avatars.steamstatic.com/25d7550600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000230000},\"76561198001829289\":{\"url\":\"https://avatars.steamstatic.com/c40eceb700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000231000},\"76561198001837208\":{\"url\":\"https://avatars.steamstatic.com/6246486800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000232000},\"76561198001845127\":{\"url\":\"https://avatars.steamstatic.com/007dc21900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000233000},\"76561198001853046\":{\"url\":\"https://avatars.steamstatic.com/9eb53bca00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000234000},\"76561198001860965\":{\"url\":\"https://avatars.steamstatic.com/3cecb57b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000235000},\"76561198001868884\":{\"url\":\"https://avatars.steamstatic.com/db242f2c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000236000},\"76561198001876803\":{\"url\":\"https://avatars.steamstatic.com/795ba8dd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000237000},\"76561198001884722\":{\"url\":\"https://avatars.steamstatic.com/1793228e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000238000},\"76561198001892641\":{\"url\":\"https://avatars.steamstatic.com/b5ca9c3f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000239000},\"76561198001900560\":{\"url\":\"https://avatars.steamstatic.com/540215f000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000240000},\"76561198001908479\":{\"url\":\"https://avatars.steamstatic.com/f2398fa100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000241000},\"76561198001916398\":{\"url\":\"https://avatars.steamstatic.com/9071095200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000242000},\"76561198001924317\":{\"url\":\"https://avatars.steamstatic.com/2ea8830300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000243000},\"76561198001932236\":{\"url\":\"https://avatars.steamstatic.com/ccdffcb400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000244000},\"76561198001940155\":{\"url\":\"https://avatars.steamstatic.com/6b17766500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000245000},\"76561198001948074\":{\"url\":\"https://avatars.steamstatic.com/094ef01600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000246000},\"76561198001955993\":{\"url\":\"https://avatars.steamstatic.com/a78669c700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000247000},\"76561198001963912\":{\"url\":\"https://avatars.steamstatic.com/45bde37800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000248000},\"76561198001971831\":{\"url\":\"https://avatars.steamstatic.com/e3f55d2900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000249000},\"76561198001979750\":{\"url\":\"https://avatars.steamstatic.com/822cd6da00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000250000},\"76561198001987669\":{\"url\":\"https://avatars.steamstatic.com/2064508b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000251000},\"76561198001995588\":{\"url\":\"https://avatars.steamstatic.com/be9bca3c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000252000},\"76561198002003507\":{\"url\":\"https://avatars.steamstatic.com/5cd343ed00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000253000},\"76561198002011426\":{\"url\":\"https://avatars.steamstatic.com/fb0abd9e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000254000},\"76561198002019345\":{\"url\":\"https://avatars.steamstatic.com/9942374f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000255000},\"76561198002027264\":{\"url\":\"https://avatars.steamstatic.com/3779b10000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000256000},\"76561198002035183\":{\"url\":\"https://avatars.steamstatic.com/d5b12ab100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000257000},\"76561198002043102\":{\"url\":\"https://avatars.steamstatic.com/73e8a46200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000258000},\"76561198002051021\":{\"url\":\"https://avatars.steamstatic.com/12201e1300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000259000},\"76561198002058940\":{\"url\":\"https://avatars.steamstatic.com/b05797c400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000260000},\"76561198002066859\":{\"url\":\"https://avatars.steamstatic.com/4e8f117500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000261000},\"76561198002074778\":{\"url\":\"https://avatars.steamstatic.com/ecc68b2600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000262000},\"76561198002082697\":{\"url\":\"https://avatars.steamstatic.com/8afe04d700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000263000},\"76561198002090616\":{\"url\":\"https://avatars.steamstatic.com/29357e8800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000264000},\"76561198002098535\":{\"url\":\"https://avatars.steamstatic.com/c76cf83900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000265000},\"76561198002106454\":{\"url\":\"https://avatars.steamstatic.com/65a471ea00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000266000},\"76561198002114373\":{\"url\":\"https://avatars.steamstatic.com/03dbeb9b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000267000},\"76561198002122292\":{\"url\":\"https://avatars.steamstatic.com/a213654c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000268000},\"76561198002130211\":{\"url\":\"https://avatars.steamstatic.com/404adefd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000269000},\"76561198002138130\":{\"url\":\"https://avatars.steamstatic.com/de8258ae00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000270000},\"76561198002146049\":{\"url\":\"https://avatars.steamstatic.com/7cb9d25f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000271000},\"76561198002153968\":{\"url\":\"https://avatars.steamstatic.com/1af14c1000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000272000},\"76561198002161887\":{\"url\":\"https://avatars.steamstatic.com/b928c5c100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000273000},\"76561198002169806\":{\"url\":\"https://avatars.steamstatic.com/57603f7200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000274000},\"76561198002177725\":{\"url\":\"https://avatars.steamstatic.com/f597b92300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000275000},\"76561198002185644\":{\"url\":\"https://avatars.steamstatic.com/93cf32d400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000276000},\"76561198002193563\":{\"url\":\"https://avatars.steamstatic.com/3206ac8500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000277000},\"76561198002201482\":{\"url\":\"https://avatars.steamstatic.com/d03e263600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000278000},\"76561198002209401\":{\"url\":\"https://avatars.steamstatic.com/6e759fe700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000279000},\"76561198002217320\":{\"url\":\"https://avatars.steamstatic.com/0cad199800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000280000},\"76561198002225239\":{\"url\":\"https://avatars.steamstatic.com/aae4934900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000281000},\"76561198002233158\":{\"url\":\"https://avatars.steamstatic.com/491c0cfa00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000282000},\"76561198002241077\":{\"url\":\"https://avatars.steamstatic.com/e75386ab00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000283000},\"76561198002248996\":{\"url\":\"https://avatars.steamstatic.com/858b005c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000284000},\"76561198002256915\":{\"url\":\"https://avatars.steamstatic.com/23c27a0d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000285000},\"76561198002264834\":{\"url\":\"https://avatars.steamstatic.com/c1f9f3be00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000286000},\"76561198002272753\":{\"url\":\"https://avatars.steamstatic.com/60316d6f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000287000},\"76561198002280672\":{\"url\":\"https://avatars.steamstatic.com/fe68e72000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000288000},\"76561198002288591\":{\"url\":\"https://avatars.steamstatic.com/9ca060d100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000289000},\"76561198002296510\":{\"url\":\"https://avatars.steamstatic.com/3ad7da8200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000290000},\"76561198002304429\":{\"url\":\"https://avatars.steamstatic.com/d90f543300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000291000},\"76561198002312348\":{\"url\":\"https://avatars.steamstatic.com/7746cde400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000292000},\"76561198002320267\":{\"url\":\"https://avatars.steamstatic.com/157e479500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000293000},\"76561198002328186\":{\"url\":\"https://avatars.steamstatic.com/b3b5c14600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000294000},\"76561198002336105\":{\"url\":\"https://avatars.steamstatic.com/51ed3af700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000295000},\"76561198002344024\":{\"url\":\"https://avatars.steamstatic.com/f024b4a800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000296000},\"76561198002351943\":{\"url\":\"https://avatars.steamstatic.com/8e5c2e5900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000297000},\"76561198002359862\":{\"url\":\"https://avatars.steamstatic.com/2c93a80a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000298000},\"76561198002367781\":{\"url\":\"https://avatars.steamstatic.com/cacb21bb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000299000},\"76561198002375700\":{\"url\":\"https://avatars.steamstatic.com/69029b6c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000300000},\"76561198002383619\":{\"url\":\"https://avatars.steamstatic.com/073a151d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000301000},\"76561198002391538\":{\"url\":\"https://avatars.steamstatic.com/a5718ece00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000302000},\"76561198002399457\":{\"url\":\"https://avatars.steamstatic.com/43a9087f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000303000},\"76561198002407376\":{\"url\":\"https://avatars.steamstatic.com/e1e0823000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000304000},\"76561198002415295\":{\"url\":\"https://avatars.steamstatic.com/8017fbe100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000305000},\"76561198002423214\":{\"url\":\"https://avatars.steamstatic.com/1e4f759200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000306000},\"76561198002431133\":{\"url\":\"https://avatars.steamstatic.com/bc86ef4300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000307000},\"76561198002439052\":{\"url\":\"https://avatars.steamstatic.com/5abe68f400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000308000},\"76561198002446971\":{\"url\":\"https://avatars.steamstatic.com/f8f5e2a500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000309000},\"76561198002454890\":{\"url\":\"https://avatars.steamstatic.com/972d5c5600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000310000},\"76561198002462809\":{\"url\":\"https://avatars.steamstatic.com/3564d60700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000311000},\"76561198002470728\":{\"url\":\"https://avatars.steamstatic.com/d39c4fb800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000312000},\"76561198002478647\":{\"url\":\"https://avatars.steamstatic.com/71d3c96900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000313000},\"76561198002486566\":{\"url\":\"https://avatars.steamstatic.com/100b431a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000314000},\"76561198002494485\":{\"url\":\"https://avatars.steamstatic.com/ae42bccb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000315000},\"76561198002502404\":{\"url\":\"https://avatars.steamstatic.com/4c7a367c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000316000},\"76561198002510323\":{\"url\":\"https://avatars.steamstatic.com/eab1b02d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000317000},\"76561198002518242\":{\"url\":\"https://avatars.steamstatic.com/88e929de00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000318000},\"76561198002526161\":{\"url\":\"https://avatars.steamstatic.com/2720a38f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000319000},\"76561198002534080\":{\"url\":\"https://avatars.steamstatic.com/c5581d4000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000320000},\"76561198002541999\":{\"url\":\"https://avatars.steamstatic.com/638f96f100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000321000},\"76561198002549918\":{\"url\":\"https://avatars.steamstatic.com/01c710a200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000322000},\"76561198002557837\":{\"url\":\"https://avatars.steamstatic.com/9ffe8a5300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000323000},\"76561198002565756\":{\"url\":\"https://avatars.steamstatic.com/3e36040400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000324000},\"76561198002573675\":{\"url\":\"https://avatars.steamstatic.com/dc6d7db500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000325000},\"76561198002581594\":{\"url\":\"https://avatars.steamstatic.com/7aa4f76600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000326000},\"76561198002589513\":{\"url\":\"https://avatars.steamstatic.com/18dc711700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000327000},\"76561198002597432\":{\"url\":\"https://avatars.steamstatic.com/b713eac800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000328000},\"76561198002605351\":{\"url\":\"https://avatars.steamstatic.com/554b647900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000329000},\"76561198002613270\":{\"url\":\"https://avatars.steamstatic.com/f382de2a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000330000},\"76561198002621189\":{\"url\":\"https://avatars.steamstatic.com/91ba57db00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000331000},\"76561198002629108\":{\"url\":\"https://avatars.steamstatic.com/2ff1d18c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000332000},\"76561198002637027\":{\"url\":\"https://avatars.steamstatic.com/ce294b3d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000333000},\"76561198002644946\":{\"url\":\"https://avatars.steamstatic.com/6c60c4ee00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000334000},\"76561198002652865\":{\"url\":\"https://avatars.steamstatic.com/0a983e9f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000335000},\"76561198002660784\":{\"url\":\"https://avatars.steamstatic.com/a8cfb85000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000336000},\"76561198002668703\":{\"url\":\"https://avatars.steamstatic.com/4707320100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000337000},\"76561198002676622\":{\"url\":\"https://avatars.steamstatic.com/e53eabb200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000338000},\"76561198002684541\":{\"url\":\"https://avatars.steamstatic.com/8376256300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000339000},\"76561198002692460\":{\"url\":\"https://avatars.steamstatic.com/21ad9f1400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000340000},\"76561198002700379\":{\"url\":\"https://avatars.steamstatic.com/bfe518c500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000341000},\"76561198002708298\":{\"url\":\"https://avatars.steamstatic.com/5e1c927600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000342000},\"76561198002716217\":{\"url\":\"https://avatars.steamstatic.com/fc540c2700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000343000},\"76561198002724136\":{\"url\":\"https://avatars.steamstatic.com/9a8b85d800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000344000},\"76561198002732055\":{\"url\":\"https://avatars.steamstatic.com/38c2ff8900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000345000},\"76561198002739974\":{\"url\":\"https://avatars.steamstatic.com/d6fa793a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000346000},\"76561198002747893\":{\"url\":\"https://avatars.steamstatic.com/7531f2eb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000347000},\"76561198002755812\":{\"url\":\"https://avatars.steamstatic.com/13696c9c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000348000},\"76561198002763731\":{\"url\":\"https://avatars.steamstatic.com/b1a0e64d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000349000},\"76561198002771650\":{\"url\":\"https://avatars.steamstatic.com/4fd85ffe00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000350000},\"76561198002779569\":{\"url\":\"https://avatars.steamstatic.com/ee0fd9af00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000351000},\"76561198002787488\":{\"url\":\"https://avatars.steamstatic.com/8c47536000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000352000},\"76561198002795407\":{\"url\":\"https://avatars.steamstatic.com/2a7ecd1100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000353000},\"76561198002803326\":{\"url\":\"https://avatars.steamstatic.com/c8b646c200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000354000},\"76561198002811245\":{\"url\":\"https://avatars.steamstatic.com/66edc07300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000355000},\"76561198002819164\":{\"url\":\"https://avatars.steamstatic.com/05253a2400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000356000},\"76561198002827083\":{\"url\":\"https://avatars.steamstatic.com/a35cb3d500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000357000},\"76561198002835002\":{\"url\":\"https://avatars.steamstatic.com/41942d8600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000358000},\"76561198002842921\":{\"url\":\"https://avatars.steamstatic.com/dfcba73700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000359000},\"76561198002850840\":{\"url\":\"https://avatars.steamstatic.com/7e0320e800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000360000},\"76561198002858759\":{\"url\":\"https://avatars.steamstatic.com/1c3a9a9900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000361000},\"76561198002866678\":{\"url\":\"https://avatars.steamstatic.com/ba72144a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000362000},\"76561198002874597\":{\"url\":\"https://avatars.steamstatic.com/58a98dfb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000363000},\"76561198002882516\":{\"url\":\"https://avatars.steamstatic.com/f6e107ac00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000364000},\"76561198002890435\":{\"url\":\"https://avatars.steamstatic.com/9518815d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000365000},\"76561198002898354\":{\"url\":\"https://avatars.steamstatic.com/334ffb0e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000366000},\"76561198002906273\":{\"url\":\"https://avatars.steamstatic.com/d18774bf00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000367000},\"76561198002914192\":{\"url\":\"https://avatars.steamstatic.com/6fbeee7000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000368000},\"76561198002922111\":{\"url\":\"https://avatars.steamstatic.com/0df6682100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000369000},\"76561198002930030\":{\"url\":\"https://avatars.steamstatic.com/ac2de1d200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000370000},\"76561198002937949\":{\"url\":\"https://avatars.steamstatic.com/4a655b8300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000371000},\"76561198002945868\":{\"url\":\"https://avatars.steamstatic.com/e89cd53400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000372000},\"76561198002953787\":{\"url\":\"https://avatars.steamstatic.com/86d44ee500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000373000},\"76561198002961706\":{\"url\":\"https://avatars.steamstatic.com/250bc89600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000374000},\"76561198002969625\":{\"url\":\"https://avatars.steamstatic.com/c343424700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000375000},\"76561198002977544\":{\"url\":\"https://avatars.steamstatic.com/617abbf800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000376000},\"76561198002985463\":{\"url\":\"https://avatars.steamstatic.com/ffb235a900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000377000},\"76561198002993382\":{\"url\":\"https://avatars.steamstatic.com/9de9af5a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000378000},\"76561198003001301\":{\"url\":\"https://avatars.steamstatic.com/3c21290b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000379000},\"76561198003009220\":{\"url\":\"https://avatars.steamstatic.com/da58a2bc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000380000},\"76561198003017139\":{\"url\":\"https://avatars.steamstatic.com/78901c6d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000381000},\"76561198003025058\":{\"url\":\"https://avatars.steamstatic.com/16c7961e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000382000},\"76561198003032977\":{\"url\":\"https://avatars.steamstatic.com/b4ff0fcf00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000383000},\"76561198003040896\":{\"url\":\"https://avatars.steamstatic.com/5336898000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000384000},\"76561198003048815\":{\"url\":\"https://avatars.steamstatic.com/f16e033100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000385000},\"76561198003056734\":{\"url\":\"https://avatars.steamstatic.com/8fa57ce200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000386000},\"76561198003064653\":{\"url\":\"https://avatars.steamstatic.com/2ddcf69300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000387000},\"76561198003072572\":{\"url\":\"https://avatars.steamstatic.com/cc14704400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000388000},\"76561198003080491\":{\"url\":\"https://avatars.steamstatic.com/6a4be9f500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000389000},\"76561198003088410\":{\"url\":\"https://avatars.steamstatic.com/088363a600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000390000},\"76561198003096329\":{\"url\":\"https://avatars.steamstatic.com/a6badd5700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000391000},\"76561198003104248\":{\"url\":\"https://avatars.steamstatic.com/44f2570800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000392000},\"76561198003112167\":{\"url\":\"https://avatars.steamstatic.com/e329d0b900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000393000},\"76561198003120086\":{\"url\":\"https://avatars.steamstatic.com/81614a6a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000394000},\"76561198003128005\":{\"url\":\"https://avatars.steamstatic.com/1f98c41b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000395000},\"76561198003135924\":{\"url\":\"https://avatars.steamstatic.com/bdd03dcc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000396000},\"76561198003143843\":{\"url\":\"https://avatars.steamstatic.com/5c07b77d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000397000},\"76561198003151762\":{\"url\":\"https://avatars.steamstatic.com/fa3f312e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000398000},\"76561198003159681\":{\"url\":\"https://avatars.steamstatic.com/9876aadf00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000399000},\"76561198003167600\":{\"url\":\"https://avatars.steamstatic.com/36ae249000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000400000},\"76561198003175519\":{\"url\":\"https://avatars.steamstatic.com/d4e59e4100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000401000},\"76561198003183438\":{\"url\":\"https://avatars.steamstatic.com/731d17f200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000402000},\"76561198003191357\":{\"url\":\"https://avatars.steamstatic.com/115491a300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000403000},\"76561198003199276\":{\"url\":\"https://avatars.steamstatic.com/af8c0b5400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000404000},\"76561198003207195\":{\"url\":\"https://avatars.steamstatic.com/4dc3850500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000405000},\"76561198003215114\":{\"url\":\"https://avatars.steamstatic.com/ebfafeb600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000406000},\"76561198003223033\":{\"url\":\"https://avatars.steamstatic.com/8a32786700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000407000},\"76561198003230952\":{\"url\":\"https://avatars.steamstatic.com/2869f21800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000408000},\"76561198003238871\":{\"url\":\"https://avatars.steamstatic.com/c6a16bc900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000409000},\"76561198003246790\":{\"url\":\"https://avatars.steamstatic.com/64d8e57a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000410000},\"76561198003254709\":{\"url\":\"https://avatars.steamstatic.com/03105f2b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000411000},\"76561198003262628\":{\"url\":\"https://avatars.steamstatic.com/a147d8dc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000412000},\"76561198003270547\":{\"url\":\"https://avatars.steamstatic.com/3f7f528d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000413000},\"76561198003278466\":{\"url\":\"https://avatars.steamstatic.com/ddb6cc3e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000414000},\"76561198003286385\":{\"url\":\"https://avatars.steamstatic.com/7bee45ef00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000415000},\"76561198003294304\":{\"url\":\"https://avatars.steamstatic.com/1a25bfa000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000416000},\"76561198003302223\":{\"url\":\"https://avatars.steamstatic.com/b85d395100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000417000},\"76561198003310142\":{\"url\":\"https://avatars.steamstatic.com/5694b30200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000418000},\"76561198003318061\":{\"url\":\"https://avatars.steamstatic.com/f4cc2cb300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000419000},\"76561198003325980\":{\"url\":\"https://avatars.steamstatic.com/9303a66400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000420000},\"76561198003333899\":{\"url\":\"https://avatars.steamstatic.com/313b201500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000421000},\"76561198003341818\":{\"url\":\"https://avatars.steamstatic.com/cf7299c600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000422000},\"76561198003349737\":{\"url\":\"https://avatars.steamstatic.com/6daa137700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000423000},\"76561198003357656\":{\"url\":\"https://avatars.steamstatic.com/0be18d2800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000424000},\"76561198003365575\":{\"url\":\"https://avatars.steamstatic.com/aa1906d900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000425000},\"76561198003373494\":{\"url\":\"https://avatars.steamstatic.com/4850808a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000426000},\"76561198003381413\":{\"url\":\"https://avatars.steamstatic.com/e687fa3b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000427000},\"76561198003389332\":{\"url\":\"https://avatars.steamstatic.com/84bf73ec00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000428000},\"76561198003397251\":{\"url\":\"https://avatars.steamstatic.com/22f6ed9d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000429000},\"76561198003405170\":{\"url\":\"https://avatars.steamstatic.com/c12e674e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000430000},\"76561198003413089\":{\"url\":\"https://avatars.steamstatic.com/5f65e0ff00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000431000},\"76561198003421008\":{\"url\":\"https://avatars.steamstatic.com/fd9d5ab000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000432000},\"76561198003428927\":{\"url\":\"https://avatars.steamstatic.com/9bd4d46100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000433000},\"76561198003436846\":{\"url\":\"https://avatars.steamstatic.com/3a0c4e1200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000434000},\"76561198003444765\":{\"url\":\"https://avatars.steamstatic.com/d843c7c300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000435000},\"76561198003452684\":{\"url\":\"https://avatars.steamstatic.com/767b417400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000436000},\"76561198003460603\":{\"url\":\"https://avatars.steamstatic.com/14b2bb2500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000437000},\"76561198003468522\":{\"url\":\"https://avatars.steamstatic.com/b2ea34d600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000438000},\"76561198003476441\":{\"url\":\"https://avatars.steamstatic.com/5121ae8700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000439000},\"76561198003484360\":{\"url\":\"https://avatars.steamstatic.com/ef59283800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000440000},\"76561198003492279\":{\"url\":\"https://avatars.steamstatic.com/8d90a1e900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000441000},\"76561198003500198\":{\"url\":\"https://avatars.steamstatic.com/2bc81b9a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000442000},\"76561198003508117\":{\"url\":\"https://avatars.steamstatic.com/c9ff954b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000443000},\"76561198003516036\":{\"url\":\"https://avatars.steamstatic.com/68370efc00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000444000},\"76561198003523955\":{\"url\":\"https://avatars.steamstatic.com/066e88ad00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000445000},\"76561198003531874\":{\"url\":\"https://avatars.steamstatic.com/a4a6025e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000446000},\"76561198003539793\":{\"url\":\"https://avatars.steamstatic.com/42dd7c0f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000447000},\"76561198003547712\":{\"url\":\"https://avatars.steamstatic.com/e114f5c000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000448000},\"76561198003555631\":{\"url\":\"https://avatars.steamstatic.com/7f4c6f7100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000449000},\"76561198003563550\":{\"url\":\"https://avatars.steamstatic.com/1d83e92200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000450000},\"76561198003571469\":{\"url\":\"https://avatars.steamstatic.com/bbbb62d300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000451000},\"76561198003579388\":{\"url\":\"https://avatars.steamstatic.com/59f2dc8400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000452000},\"76561198003587307\":{\"url\":\"https://avatars.steamstatic.com/f82a563500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000453000},\"76561198003595226\":{\"url\":\"https://avatars.steamstatic.com/9661cfe600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000454000},\"76561198003603145\":{\"url\":\"https://avatars.steamstatic.com/3499499700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000455000},\"76561198003611064\":{\"url\":\"https://avatars.steamstatic.com/d2d0c34800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000456000},\"76561198003618983\":{\"url\":\"https://avatars.steamstatic.com/71083cf900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000457000},\"76561198003626902\":{\"url\":\"https://avatars.steamstatic.com/0f3fb6aa00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000458000},\"76561198003634821\":{\"url\":\"https://avatars.steamstatic.com/ad77305b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000459000},\"76561198003642740\":{\"url\":\"https://avatars.steamstatic.com/4baeaa0c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000460000},\"76561198003650659\":{\"url\":\"https://avatars.steamstatic.com/e9e623bd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000461000},\"76561198003658578\":{\"url\":\"https://avatars.steamstatic.com/881d9d6e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000462000},\"76561198003666497\":{\"url\":\"https://avatars.steamstatic.com/2655171f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000463000},\"76561198003674416\":{\"url\":\"https://avatars.steamstatic.com/c48c90d000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000464000},\"76561198003682335\":{\"url\":\"https://avatars.steamstatic.com/62c40a8100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000465000},\"76561198003690254\":{\"url\":\"https://avatars.steamstatic.com/00fb843200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000466000},\"76561198003698173\":{\"url\":\"https://avatars.steamstatic.com/9f32fde300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000467000},\"76561198003706092\":{\"url\":\"https://avatars.steamstatic.com/3d6a779400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000468000},\"76561198003714011\":{\"url\":\"https://avatars.steamstatic.com/dba1f14500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000469000},\"76561198003721930\":{\"url\":\"https://avatars.steamstatic.com/79d96af600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000470000},\"76561198003729849\":{\"url\":\"https://avatars.steamstatic.com/1810e4a700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000471000},\"76561198003737768\":{\"url\":\"https://avatars.steamstatic.com/b6485e5800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000472000},\"76561198003745687\":{\"url\":\"https://avatars.steamstatic.com/547fd80900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000473000},\"76561198003753606\":{\"url\":\"https://avatars.steamstatic.com/f2b751ba00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000474000},\"76561198003761525\":{\"url\":\"https://avatars.steamstatic.com/90eecb6b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000475000},\"76561198003769444\":{\"url\":\"https://avatars.steamstatic.com/2f26451c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000476000},\"76561198003777363\":{\"url\":\"https://avatars.steamstatic.com/cd5dbecd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000477000},\"76561198003785282\":{\"url\":\"https://avatars.steamstatic.com/6b95387e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000478000},\"76561198003793201\":{\"url\":\"https://avatars.steamstatic.com/09ccb22f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000479000},\"76561198003801120\":{\"url\":\"https://avatars.steamstatic.com/a8042be000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000480000},\"76561198003809039\":{\"url\":\"https://avatars.steamstatic.com/463ba59100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000481000},\"76561198003816958\":{\"url\":\"https://avatars.steamstatic.com/e4731f4200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000482000},\"76561198003824877\":{\"url\":\"https://avatars.steamstatic.com/82aa98f300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000483000},\"76561198003832796\":{\"url\":\"https://avatars.steamstatic.com/20e212a400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000484000},\"76561198003840715\":{\"url\":\"https://avatars.steamstatic.com/bf198c5500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000485000},\"76561198003848634\":{\"url\":\"https://avatars.steamstatic.com/5d51060600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000486000},\"76561198003856553\":{\"url\":\"https://avatars.steamstatic.com/fb887fb700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000487000},\"76561198003864472\":{\"url\":\"https://avatars.steamstatic.com/99bff96800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000488000},\"76561198003872391\":{\"url\":\"https://avatars.steamstatic.com/37f7731900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000489000},\"76561198003880310\":{\"url\":\"https://avatars.steamstatic.com/d62eecca00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000490000},\"76561198003888229\":{\"url\":\"https://avatars.steamstatic.com/7466667b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000491000},\"76561198003896148\":{\"url\":\"https://avatars.steamstatic.com/129de02c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000492000},\"76561198003904067\":{\"url\":\"https://avatars.steamstatic.com/b0d559dd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000493000},\"76561198003911986\":{\"url\":\"https://avatars.steamstatic.com/4f0cd38e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000494000},\"76561198003919905\":{\"url\":\"https://avatars.steamstatic.com/ed444d3f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000495000},\"76561198003927824\":{\"url\":\"https://avatars.steamstatic.com/8b7bc6f000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000496000},\"76561198003935743\":{\"url\":\"https://avatars.steamstatic.com/29b340a100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000497000},\"76561198003943662\":{\"url\":\"https://avatars.steamstatic.com/c7eaba5200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000498000},\"76561198003951581\":{\"url\":\"https://avatars.steamstatic.com/6622340300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000499000},\"76561198003959500\":{\"url\":\"https://avatars.steamstatic.com/0459adb400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000500000},\"76561198003967419\":{\"url\":\"https://avatars.steamstatic.com/a291276500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000501000},\"76561198003975338\":{\"url\":\"https://avatars.steamstatic.com/40c8a11600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000502000},\"76561198003983257\":{\"url\":\"https://avatars.steamstatic.com/df001ac700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000503000},\"76561198003991176\":{\"url\":\"https://avatars.steamstatic.com/7d37947800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000504000},\"76561198003999095\":{\"url\":\"https://avatars.steamstatic.com/1b6f0e2900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000505000},\"76561198004007014\":{\"url\":\"https://avatars.steamstatic.com/b9a687da00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000506000},\"76561198004014933\":{\"url\":\"https://avatars.steamstatic.com/57de018b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000507000},\"76561198004022852\":{\"url\":\"https://avatars.steamstatic.com/f6157b3c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000508000},\"76561198004030771\":{\"url\":\"https://avatars.steamstatic.com/944cf4ed00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000509000},\"76561198004038690\":{\"url\":\"https://avatars.steamstatic.com/32846e9e00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000510000},\"76561198004046609\":{\"url\":\"https://avatars.steamstatic.com/d0bbe84f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000511000},\"76561198004054528\":{\"url\":\"https://avatars.steamstatic.com/6ef3620000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000512000},\"76561198004062447\":{\"url\":\"https://avatars.steamstatic.com/0d2adbb100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000513000},\"76561198004070366\":{\"url\":\"https://avatars.steamstatic.com/ab62556200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000514000},\"76561198004078285\":{\"url\":\"https://avatars.steamstatic.com/4999cf1300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000515000},\"76561198004086204\":{\"url\":\"https://avatars.steamstatic.com/e7d148c400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000516000},\"76561198004094123\":{\"url\":\"https://avatars.steamstatic.com/8608c27500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000517000},\"76561198004102042\":{\"url\":\"https://avatars.steamstatic.com/24403c2600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000518000},\"76561198004109961\":{\"url\":\"https://avatars.steamstatic.com/c277b5d700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000519000},\"76561198004117880\":{\"url\":\"https://avatars.steamstatic.com/60af2f8800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000520000},\"76561198004125799\":{\"url\":\"https://avatars.steamstatic.com/fee6a93900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000521000},\"76561198004133718\":{\"url\":\"https://avatars.steamstatic.com/9d1e22ea00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000522000},\"76561198004141637\":{\"url\":\"https://avatars.steamstatic.com/3b559c9b00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000523000},\"76561198004149556\":{\"url\":\"https://avatars.steamstatic.com/d98d164c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000524000},\"76561198004157475\":{\"url\":\"https://avatars.steamstatic.com/77c48ffd00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000525000},\"76561198004165394\":{\"url\":\"https://avatars.steamstatic.com/15fc09ae00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000526000},\"76561198004173313\":{\"url\":\"https://avatars.steamstatic.com/b433835f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000527000},\"76561198004181232\":{\"url\":\"https://avatars.steamstatic.com/526afd1000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000528000},\"76561198004189151\":{\"url\":\"https://avatars.steamstatic.com/f0a276c100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000529000},\"76561198004197070\":{\"url\":\"https://avatars.steamstatic.com/8ed9f07200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000530000},\"76561198004204989\":{\"url\":\"https://avatars.steamstatic.com/2d116a2300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000531000},\"76561198004212908\":{\"url\":\"https://avatars.steamstatic.com/cb48e3d400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000532000},\"76561198004220827\":{\"url\":\"https://avatars.steamstatic.com/69805d8500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000533000},\"76561198004228746\":{\"url\":\"https://avatars.steamstatic.com/07b7d73600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000534000},\"76561198004236665\":{\"url\":\"https://avatars.steamstatic.com/a5ef50e700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000535000},\"76561198004244584\":{\"url\":\"https://avatars.steamstatic.com/4426ca9800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000536000},\"76561198004252503\":{\"url\":\"https://avatars.steamstatic.com/e25e444900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000537000},\"76561198004260422\":{\"url\":\"https://avatars.steamstatic.com/8095bdfa00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000538000},\"76561198004268341\":{\"url\":\"https://avatars.steamstatic.com/1ecd37ab00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000539000},\"76561198004276260\":{\"url\":\"https://avatars.steamstatic.com/bd04b15c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000540000},\"76561198004284179\":{\"url\":\"https://avatars.steamstatic.com/5b3c2b0d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000541000},\"76561198004292098\":{\"url\":\"https://avatars.steamstatic.com/f973a4be00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000542000},\"76561198004300017\":{\"url\":\"https://avatars.steamstatic.com/97ab1e6f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000543000},\"76561198004307936\":{\"url\":\"https://avatars.steamstatic.com/35e2982000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000544000},\"76561198004315855\":{\"url\":\"https://avatars.steamstatic.com/d41a11d100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000545000},\"76561198004323774\":{\"url\":\"https://avatars.steamstatic.com/72518b8200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000546000},\"76561198004331693\":{\"url\":\"https://avatars.steamstatic.com/1089053300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000547000},\"76561198004339612\":{\"url\":\"https://avatars.steamstatic.com/aec07ee400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000548000},\"76561198004347531\":{\"url\":\"https://avatars.steamstatic.com/4cf7f89500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000549000},\"76561198004355450\":{\"url\":\"https://avatars.steamstatic.com/eb2f724600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000550000},\"76561198004363369\":{\"url\":\"https://avatars.steamstatic.com/8966ebf700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000551000},\"76561198004371288\":{\"url\":\"https://avatars.steamstatic.com/279e65a800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000552000},\"76561198004379207\":{\"url\":\"https://avatars.steamstatic.com/c5d5df5900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000553000},\"76561198004387126\":{\"url\":\"https://avatars.steamstatic.com/640d590a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000554000},\"76561198004395045\":{\"url\":\"https://avatars.steamstatic.com/0244d2bb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000555000},\"76561198004402964\":{\"url\":\"https://avatars.steamstatic.com/a07c4c6c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000556000},\"76561198004410883\":{\"url\":\"https://avatars.steamstatic.com/3eb3c61d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000557000},\"76561198004418802\":{\"url\":\"https://avatars.steamstatic.com/dceb3fce00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000558000},\"76561198004426721\":{\"url\":\"https://avatars.steamstatic.com/7b22b97f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000559000},\"76561198004434640\":{\"url\":\"https://avatars.steamstatic.com/195a333000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000560000},\"76561198004442559\":{\"url\":\"https://avatars.steamstatic.com/b791ace100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000561000},\"76561198004450478\":{\"url\":\"https://avatars.steamstatic.com/55c9269200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000562000},\"76561198004458397\":{\"url\":\"https://avatars.steamstatic.com/f400a04300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000563000},\"76561198004466316\":{\"url\":\"https://avatars.steamstatic.com/923819f400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000564000},\"76561198004474235\":{\"url\":\"https://avatars.steamstatic.com/306f93a500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000565000},\"76561198004482154\":{\"url\":\"https://avatars.steamstatic.com/cea70d5600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000566000},\"76561198004490073\":{\"url\":\"https://avatars.steamstatic.com/6cde870700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000567000},\"76561198004497992\":{\"url\":\"https://avatars.steamstatic.com/0b1600b800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000568000},\"76561198004505911\":{\"url\":\"https://avatars.steamstatic.com/a94d7a6900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000569000},\"76561198004513830\":{\"url\":\"https://avatars.steamstatic.com/4784f41a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000570000},\"76561198004521749\":{\"url\":\"https://avatars.steamstatic.com/e5bc6dcb00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000571000},\"76561198004529668\":{\"url\":\"https://avatars.steamstatic.com/83f3e77c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000572000},\"76561198004537587\":{\"url\":\"https://avatars.steamstatic.com/222b612d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000573000},\"76561198004545506\":{\"url\":\"https://avatars.steamstatic.com/c062dade00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000574000},\"76561198004553425\":{\"url\":\"https://avatars.steamstatic.com/5e9a548f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000575000},\"76561198004561344\":{\"url\":\"https://avatars.steamstatic.com/fcd1ce4000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000576000},\"76561198004569263\":{\"url\":\"https://avatars.steamstatic.com/9b0947f100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000577000},\"76561198004577182\":{\"url\":\"https://avatars.steamstatic.com/3940c1a200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000578000},\"76561198004585101\":{\"url\":\"https://avatars.steamstatic.com/d7783b5300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000579000},\"76561198004593020\":{\"url\":\"https://avatars.steamstatic.com/75afb50400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000580000},\"76561198004600939\":{\"url\":\"https://avatars.steamstatic.com/13e72eb500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000581000},\"76561198004608858\":{\"url\":\"https://avatars.steamstatic.com/b21ea86600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000582000},\"76561198004616777\":{\"url\":\"https://avatars.steamstatic.com/5056221700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000583000},\"76561198004624696\":{\"url\":\"https://avatars.steamstatic.com/ee8d9bc800000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000584000},\"76561198004632615\":{\"url\":\"https://avatars.steamstatic.com/8cc5157900000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000585000},\"76561198004640534\":{\"url\":\"https://avatars.steamstatic.com/2afc8f2a00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000586000},\"76561198004648453\":{\"url\":\"https://avatars.steamstatic.com/c93408db00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000587000},\"76561198004656372\":{\"url\":\"https://avatars.steamstatic.com/676b828c00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000588000},\"76561198004664291\":{\"url\":\"https://avatars.steamstatic.com/05a2fc3d00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000589000},\"76561198004672210\":{\"url\":\"https://avatars.steamstatic.com/a3da75ee00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000590000},\"76561198004680129\":{\"url\":\"https://avatars.steamstatic.com/4211ef9f00000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000591000},\"76561198004688048\":{\"url\":\"https://avatars.steamstatic.com/e049695000000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000592000},\"76561198004695967\":{\"url\":\"https://avatars.steamstatic.com/7e80e30100000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000593000},\"76561198004703886\":{\"url\":\"https://avatars.steamstatic.com/1cb85cb200000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000594000},\"76561198004711805\":{\"url\":\"https://avatars.steamstatic.com/baefd66300000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000595000},\"76561198004719724\":{\"url\":\"https://avatars.steamstatic.com/5927501400000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000596000},\"76561198004727643\":{\"url\":\"https://avatars.steamstatic.com/f75ec9c500000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000597000},\"76561198004735562\":{\"url\":\"https://avatars.steamstatic.com/9596437600000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000598000},\"76561198004743481\":{\"url\":\"https://avatars.steamstatic.com/33cdbd2700000000000000000000000000000000_full.jpg\",\"fetchedAt\":1790000599000}}", + "accshift_roblox_avatars": "{\"1234567\":{\"url\":\"https://tr.rbxcdn.com/x/150/150/AvatarHeadshot/Png\"}}", + "accshift_ban_info_cache_v1": "{\"76561198000000001\":{\"vac\":1,\"game\":2,\"note\":\"réécrit ✓\"}}" + } +} diff --git a/crates/accshift-app/tests/fixtures/legacy_web_storage/kill/000003.log b/crates/accshift-app/tests/fixtures/legacy_web_storage/kill/000003.log new file mode 100644 index 00000000..0ace9470 Binary files /dev/null and b/crates/accshift-app/tests/fixtures/legacy_web_storage/kill/000003.log differ diff --git a/crates/accshift-app/tests/fixtures/legacy_web_storage/kill/CURRENT b/crates/accshift-app/tests/fixtures/legacy_web_storage/kill/CURRENT new file mode 100644 index 00000000..7ed683d1 --- /dev/null +++ b/crates/accshift-app/tests/fixtures/legacy_web_storage/kill/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/crates/accshift-app/tests/fixtures/legacy_web_storage/kill/MANIFEST-000001 b/crates/accshift-app/tests/fixtures/legacy_web_storage/kill/MANIFEST-000001 new file mode 100644 index 00000000..18e5cab7 Binary files /dev/null and b/crates/accshift-app/tests/fixtures/legacy_web_storage/kill/MANIFEST-000001 differ diff --git a/crates/accshift-app/tests/i18n.rs b/crates/accshift-app/tests/i18n.rs new file mode 100644 index 00000000..3de574c4 --- /dev/null +++ b/crates/accshift-app/tests/i18n.rs @@ -0,0 +1,232 @@ +//! The i18n tests of the former TS frontend +//! (`src/lib/i18n/{index,messages}.test.ts`) against the embedded +//! dictionaries. + +use accshift_app::i18n::{ + boot_locale, detect_preferred_locale, dictionary, format_template, is_locale, normalize_locale, + translate, translate_with, Locale, TranslationValue, DEFAULT_LOCALE, LANGUAGE_OPTIONS, LOCALES, +}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +// ------------------------------------------------------------ isLocale, normalizeLocale + +#[test] +fn is_locale_accepts_en() { + assert!(is_locale(Some(&json!("en")))); +} + +#[test] +fn is_locale_accepts_fr() { + assert!(is_locale(Some(&json!("fr")))); +} + +#[test] +fn is_locale_rejects_de() { + assert!(!is_locale(Some(&json!("de")))); +} + +#[test] +fn is_locale_rejects_null() { + assert!(!is_locale(Some(&Value::Null))); + assert!(!is_locale(None)); +} + +#[test] +fn is_locale_rejects_a_number() { + assert!(!is_locale(Some(&json!(42)))); +} + +#[test] +fn normalize_locale_passes_a_valid_locale_through() { + assert_eq!(normalize_locale(Some(&json!("fr"))), Locale::Fr); +} + +#[test] +fn normalize_locale_defaults_an_invalid_one_to_en() { + assert_eq!(normalize_locale(Some(&json!("xx"))), Locale::En); +} + +#[test] +fn normalize_locale_defaults_null_to_en() { + assert_eq!(normalize_locale(Some(&Value::Null)), Locale::En); +} + +// ------------------------------------------------------------ translate + +#[test] +fn translates_a_known_key_in_en() { + let result = translate(Locale::En, "common.close"); + assert!(!result.is_empty()); + assert_ne!(result, "common.close"); +} + +#[test] +fn interpolates_parameters() { + let result = translate_with( + Locale::En, + "time.minutesAgo", + &[("count", TranslationValue::from(5))], + ); + assert!(result.contains('5'), "{result}"); +} + +#[test] +fn serves_fr_strings() { + assert_eq!(translate(Locale::Fr, "common.close"), "Fermer"); +} + +#[test] +fn an_unknown_key_renders_as_itself() { + assert_eq!(translate(Locale::Fr, "no.such.key"), "no.such.key"); +} + +#[test] +fn format_template_empties_missing_params_and_renders_values() { + assert_eq!( + format_template( + "{a} {b} {missing}", + Some(&[ + ("a", TranslationValue::from("x")), + ("b", TranslationValue::from(true)) + ]) + ), + "x true " + ); + assert_eq!(format_template("{a}", None), "{a}"); +} + +// ------------------------------------------------------------ detectPreferredLocale + +#[test] +fn detect_returns_an_exact_match() { + assert_eq!(detect_preferred_locale(&["fr"]), Locale::Fr); +} + +#[test] +fn detect_falls_back_from_a_regional_tag_to_its_base() { + assert_eq!(detect_preferred_locale(&["fr-CA"]), Locale::Fr); +} + +#[test] +fn detect_defaults_unsupported_locales_to_en() { + assert_eq!(detect_preferred_locale(&["de-DE"]), Locale::En); +} + +#[test] +fn detect_prefers_the_regional_dictionary_when_both_ship() { + assert_eq!(detect_preferred_locale(&["pt-BR"]), Locale::PtBr); +} + +#[test] +fn detect_maps_a_regional_tag_without_a_dictionary_to_its_base() { + assert_eq!(detect_preferred_locale(&["pt-PT"]), Locale::Pt); + assert_eq!(detect_preferred_locale(&["es-419"]), Locale::Es); + assert_eq!(detect_preferred_locale(&["zh-CN"]), Locale::Zh); +} + +#[test] +fn detect_defaults_to_en_with_no_languages() { + assert_eq!(detect_preferred_locale::<&str>(&[]), Locale::En); +} + +#[test] +fn detect_skips_an_unsupported_entry_and_matches_a_later_one() { + assert_eq!(detect_preferred_locale(&["de-DE", "en-GB"]), Locale::En); +} + +#[test] +fn boot_locale_reads_stored_settings_then_the_os() { + let stored = json!({ "language": "ru" }); + assert_eq!(boot_locale(Some(&stored), &["fr-FR"]), Locale::Ru); + assert_eq!( + boot_locale(Some(&json!({ "language": "xx" })), &["fr-FR"]), + Locale::En + ); + assert_eq!(boot_locale(None, &["fr-FR"]), Locale::Fr); + assert_eq!(boot_locale(Some(&json!({})), &["zh-CN"]), Locale::Zh); +} + +// ------------------------------------------------------------ messages + +fn placeholders(value: &str) -> Vec { + let mut found = Vec::new(); + let mut rest = value; + while let Some(start) = rest.find('{') { + let after = &rest[start + 1..]; + let len = after + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(after.len()); + if len > 0 && after[len..].starts_with('}') { + found.push(format!("{{{}}}", &after[..len])); + } + rest = after; + } + found.sort(); + found +} + +fn keys(locale: Locale) -> BTreeSet<&'static str> { + dictionary(locale).keys().map(String::as_str).collect() +} + +#[test] +fn has_a_dictionary_for_every_shipped_locale() { + let shipped: BTreeSet<&str> = LANGUAGE_OPTIONS + .iter() + .map(|option| option.code.code()) + .collect(); + let registered: BTreeSet<&str> = LOCALES.iter().map(|locale| locale.code()).collect(); + assert_eq!(registered, shipped); + assert_eq!(DEFAULT_LOCALE, Locale::En); +} + +#[test] +fn every_locale_covers_exactly_the_en_key_set() { + let en = keys(Locale::En); + for locale in LOCALES { + assert_eq!(keys(locale), en, "{}", locale.code()); + } +} + +#[test] +fn every_locale_keeps_the_en_placeholders() { + let en = dictionary(Locale::En); + for locale in LOCALES { + let dict = dictionary(locale); + for (key, value) in en { + assert_eq!( + placeholders(dict.get(key).map_or("", String::as_str)), + placeholders(value), + "placeholders in {}:{key}", + locale.code() + ); + } + } +} + +#[test] +fn no_locale_has_an_em_or_en_dash() { + for locale in LOCALES { + for (key, value) in dictionary(locale) { + assert!( + !value.contains(['\u{2013}', '\u{2014}']), + "dash in {}:{key}", + locale.code() + ); + } + } +} + +#[test] +fn no_locale_has_an_empty_string() { + for locale in LOCALES { + for (key, value) in dictionary(locale) { + assert!( + !value.trim().is_empty(), + "empty message for {}:{key}", + locale.code() + ); + } + } +} diff --git a/crates/accshift-app/tests/legacy_web_storage.rs b/crates/accshift-app/tests/legacy_web_storage.rs new file mode 100644 index 00000000..485bbf1e --- /dev/null +++ b/crates/accshift-app/tests/legacy_web_storage.rs @@ -0,0 +1,959 @@ +//! `legacy_web_storage`: the Chromium LevelDB and both WebKit sqlite layouts +//! built in temp folders and read back, the migration into the stores end to +//! end, two LevelDB folders written by real WebView2 +//! (`tests/fixtures/legacy_web_storage/chromium-proof.mjs`), and a read-only +//! pass over this machine's real WebView2 profile that prints key names and +//! value lengths only. + +#[path = "../src/legacy_web_storage/webkit_sqlite.rs"] +#[allow(dead_code)] +mod webkit_sqlite; + +use accshift_app::legacy_web_storage::{self, webkit_paths, LegacyRead}; +use accshift_app::storage::{ + initialize, LegacyLocalStorage, CLIENT_STORE_FOLDERS, CLIENT_STORE_IDS, CLIENT_STORE_SETTINGS, + CLIENT_STORE_VIEW_MODE, LEGACY_LOCAL_STORAGE_KEYS, +}; +use accshift_app::types::{ClientStorageSnapshot, StorageManifest}; +use rusqlite::Connection; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +// ------------------------------------------------------------------ helpers + +/// A scratch folder removed on drop. +struct Scratch(PathBuf); + +impl Scratch { + fn new(tag: &str) -> Self { + let path = std::env::temp_dir().join(format!( + // Short: the WebKit unified layout under it nears MAX_PATH. + "alws-{tag}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn wanted() -> Vec<&'static str> { + LEGACY_LOCAL_STORAGE_KEYS.iter().map(|(_, k)| *k).collect() +} + +fn map(entries: &[(&str, &str)]) -> LegacyLocalStorage { + entries + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn empty_snapshot() -> ClientStorageSnapshot { + ClientStorageSnapshot { + manifest: StorageManifest { + schema_version: 1, + stores: BTreeMap::new(), + }, + stores: BTreeMap::new(), + } +} + +fn full_snapshot() -> ClientStorageSnapshot { + let mut snapshot = empty_snapshot(); + for id in CLIENT_STORE_IDS { + snapshot.stores.insert(id.to_string(), json!({})); + } + snapshot +} + +const SETTINGS: &str = r#"{"themeId":"dark","language":"fr","note":"Réglages ✓ 😀"}"#; +const FOLDERS: &str = r#"{"version":1,"folders":[{"id":"f1","name":"Main"}]}"#; + +// --------------------------------------------------------------- cheap check + +#[test] +fn needs_read_only_when_a_store_is_missing_or_null() { + assert!(legacy_web_storage::needs_read(&empty_snapshot())); + let full = full_snapshot(); + assert!(!legacy_web_storage::needs_read(&full)); + + let mut one_null = full.clone(); + one_null + .stores + .insert(CLIENT_STORE_VIEW_MODE.to_string(), Value::Null); + assert!(legacy_web_storage::needs_read(&one_null)); + + let mut one_missing = full.clone(); + one_missing.stores.remove(CLIENT_STORE_FOLDERS); + assert!(legacy_web_storage::needs_read(&one_missing)); +} + +#[test] +fn needs_read_cost() { + let full = full_snapshot(); + let partial = { + let mut s = full.clone(); + s.stores.remove(CLIENT_STORE_IDS[11]); + s + }; + let runs = 100_000u32; + let start = Instant::now(); + let mut hits = 0u32; + for _ in 0..runs { + hits += u32::from(legacy_web_storage::needs_read(std::hint::black_box(&full))); + hits += u32::from(legacy_web_storage::needs_read(std::hint::black_box( + &partial, + ))); + } + let per_call = start.elapsed() / (runs * 2); + assert_eq!(hits, runs); + println!("needs_read: {per_call:?} per call (12 stores, debug build)"); +} + +// ------------------------------------------------------------- boot step + +/// The app folders under a scratch folder. +struct Dirs(PathBuf); + +impl accshift_core::AppContext for Dirs { + fn app_config_dir(&self) -> Result { + Ok(self.0.join("config")) + } + fn app_data_dir(&self) -> Result { + Ok(self.0.join("data")) + } + fn app_local_data_dir(&self) -> Result { + Ok(self.0.join("local")) + } + fn app_cache_dir(&self) -> Result { + Ok(self.0.join("cache")) + } +} + +#[test] +fn the_boot_step_reads_while_a_store_is_missing_on_disk() { + let scratch = Scratch::new("boot"); + let dirs = Dirs(scratch.0.clone()); + let mut reads = 0; + let mut step = || { + legacy_web_storage::read_for_boot_with(&dirs, || { + reads += 1; + LegacyRead { + storage: map(&[("accshift_settings", SETTINGS)]), + notes: Vec::new(), + } + }) + }; + let first = step().expect("no store on disk yet"); + assert_eq!(first.storage, map(&[("accshift_settings", SETTINGS)])); + for id in CLIENT_STORE_IDS { + accshift_core::storage::save_client_store(&dirs, id, &json!({})).unwrap(); + } + assert!(step().is_none()); + assert_eq!(reads, 1); +} + +// ---------------------------------------------------------- WebKit (sqlite) + +/// An `ItemTable` the way WebKit writes it: text keys, UTF-16LE blob values. +fn write_item_table(path: &Path, rows: &[(&str, Vec)]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let connection = Connection::open(path).unwrap(); + connection + .execute_batch( + "CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, \ + value BLOB NOT NULL ON CONFLICT FAIL)", + ) + .unwrap(); + for (key, value) in rows { + connection + .execute( + "INSERT INTO ItemTable VALUES (?, ?)", + rusqlite::params![key, value], + ) + .unwrap(); + } +} + +fn utf16(text: &str) -> Vec { + text.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +fn webkit_rows() -> Vec<(&'static str, Vec)> { + vec![ + ("accshift_settings", utf16(SETTINGS)), + ("accshift_viewMode", utf16("list")), + // Not a legacy key: ignored. + ("accshift_boot_theme", utf16(r#"{"colorScheme":"dark"}"#)), + // Odd byte count: skipped with a reason. + ("accshift_avatars", vec![b'{', 0, b'}']), + ] +} + +fn read_candidate(candidates: &[PathBuf]) -> (PathBuf, LegacyLocalStorage, Vec) { + let path = candidates + .iter() + .find(|p| p.is_file()) + .expect("a candidate exists") + .clone(); + let rows = webkit_sqlite::read_item_table(&path, &wanted()).unwrap(); + (path, rows.items.into_iter().collect(), rows.skipped) +} + +fn expected_webkit() -> LegacyLocalStorage { + map(&[ + ("accshift_settings", SETTINGS), + ("accshift_viewMode", "list"), + ]) +} + +#[test] +fn webkit_linux_old_layout() { + let scratch = Scratch::new("linux-old"); + let base = scratch.0.join("com.accshift.desktop"); + let db = base + .join("localstorage") + .join("tauri_localhost_0.localstorage"); + write_item_table(&db, &webkit_rows()); + let before = std::fs::read(&db).unwrap(); + + let candidates = webkit_paths::linux_candidates(&base); + assert_eq!(candidates, std::slice::from_ref(&db)); + let (path, storage, skipped) = read_candidate(&candidates); + assert_eq!(path, db); + assert_eq!(storage, expected_webkit()); + assert_eq!(skipped.len(), 1, "{skipped:?}"); + assert!(skipped[0].starts_with("skipped accshift_avatars: odd UTF-16 length")); + assert_eq!(std::fs::read(&db).unwrap(), before, "original untouched"); +} + +#[test] +fn webkit_linux_unified_layout_wins_over_the_old_one() { + let scratch = Scratch::new("linux-unified"); + let base = scratch.0.join("com.accshift.desktop"); + let salt = [7u8, 1, 2, 3, 4, 5, 6, 9]; + std::fs::create_dir_all(base.join("storage")).unwrap(); + std::fs::write(base.join("storage").join("salt"), salt).unwrap(); + let folder = webkit_paths::origin_folder_name("tauri://localhost", &salt); + let unified = base + .join("storage") + .join(&folder) + .join(&folder) + .join("LocalStorage") + .join("localstorage.sqlite3"); + write_item_table(&unified, &webkit_rows()); + let old = base + .join("localstorage") + .join("tauri_localhost_0.localstorage"); + write_item_table(&old, &[("accshift_settings", utf16("{}"))]); + + let candidates = webkit_paths::linux_candidates(&base); + assert_eq!(candidates, [unified.clone(), old]); + let (path, storage, _) = read_candidate(&candidates); + assert_eq!(path, unified); + assert_eq!(storage, expected_webkit()); +} + +#[test] +fn webkit_macos_both_layouts() { + let scratch = Scratch::new("macos"); + let home = scratch.0.join("home"); + let website_data = home.join("Library/WebKit/com.accshift.desktop/WebsiteData"); + + // Old layout alone. + let old = website_data + .join("LocalStorage") + .join("tauri_localhost_0.localstorage"); + write_item_table(&old, &webkit_rows()); + let candidates = webkit_paths::macos_candidates(&home, "com.accshift.desktop"); + assert_eq!(candidates, std::slice::from_ref(&old)); + assert_eq!(read_candidate(&candidates).1, expected_webkit()); + + // Then the unified layout, which WebKit reads first. + let salt = [0xa5u8; 8]; + std::fs::create_dir_all(website_data.join("Default")).unwrap(); + std::fs::write(website_data.join("Default").join("salt"), salt).unwrap(); + let folder = webkit_paths::origin_folder_name("tauri://localhost", &salt); + let unified = website_data + .join("Default") + .join(&folder) + .join(&folder) + .join("LocalStorage/localstorage.sqlite3"); + write_item_table(&unified, &[("accshift_folders", utf16(FOLDERS))]); + let candidates = webkit_paths::macos_candidates(&home, "com.accshift.desktop"); + assert_eq!(candidates, [unified.clone(), old]); + let (path, storage, _) = read_candidate(&candidates); + assert_eq!(path, unified); + assert_eq!(storage, map(&[("accshift_folders", FOLDERS)])); +} + +#[test] +fn webkit_missing_or_broken_files_give_a_reason() { + let scratch = Scratch::new("webkit-broken"); + let missing = scratch.0.join("nothing.localstorage"); + assert!(webkit_sqlite::read_item_table(&missing, &wanted()).is_err()); + + let garbage = scratch.0.join("garbage.localstorage"); + std::fs::write(&garbage, b"not a database at all, just bytes").unwrap(); + let error = webkit_sqlite::read_item_table(&garbage, &wanted()).unwrap_err(); + assert!(!error.is_empty()); + + let no_table = scratch.0.join("empty.localstorage"); + Connection::open(&no_table) + .unwrap() + .execute_batch("CREATE TABLE Other (x)") + .unwrap(); + let error = webkit_sqlite::read_item_table(&no_table, &wanted()).unwrap_err(); + assert!(error.starts_with("no ItemTable"), "{error}"); +} + +/// The merge `read_webkit_candidates` does, through the library on Linux and +/// macOS. +#[cfg(not(windows))] +#[test] +fn webkit_candidates_merge_newest_first() { + let scratch = Scratch::new("webkit-merge"); + let new = scratch.0.join("new.sqlite3"); + let old = scratch.0.join("old.localstorage"); + write_item_table(&new, &[("accshift_settings", utf16(SETTINGS))]); + write_item_table( + &old, + &[ + ("accshift_settings", utf16("{}")), + ("accshift_folders", utf16(FOLDERS)), + ], + ); + let read = legacy_web_storage::read_webkit_candidates(&[scratch.0.join("absent"), new, old]); + assert_eq!( + read.storage, + map(&[ + ("accshift_settings", SETTINGS), + ("accshift_folders", FOLDERS) + ]) + ); + assert!(read.notes[0].starts_with("no file at")); +} + +// ------------------------------------------------------ migration end to end + +fn assert_migrates(legacy: &LegacyLocalStorage) { + let backend = accshift_app::mock::MockBackend::load( + &accshift_app::mock::MockBackend::data_dir( + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap(), + ), + "empty", + None, + ) + .unwrap(); + let (storage, report) = initialize(&backend, Some(empty_snapshot()), Some(legacy), false); + assert!(report.failed_migrations.is_empty(), "{report:?}"); + let mut expected: Vec<&str> = Vec::new(); + for (id, key) in LEGACY_LOCAL_STORAGE_KEYS { + if legacy.contains_key(key) { + expected.push(id); + } + } + assert_eq!(report.migrated, expected); + if let Some(raw) = legacy.get("accshift_settings") { + let value: Value = serde_json::from_str(raw).unwrap(); + assert_eq!(storage.get(CLIENT_STORE_SETTINGS), Some(&value)); + } + if let Some(raw) = legacy.get("accshift_viewMode") { + assert_eq!(storage.get(CLIENT_STORE_VIEW_MODE), Some(&json!(raw))); + } + assert!(!storage.has_unsaved()); +} + +#[test] +fn webkit_read_migrates_into_the_mock_backend() { + let scratch = Scratch::new("webkit-e2e"); + let base = scratch.0.join("com.accshift.desktop"); + let db = base + .join("localstorage") + .join("tauri_localhost_0.localstorage"); + write_item_table(&db, &webkit_rows()); + let (_, storage, _) = read_candidate(&webkit_paths::linux_candidates(&base)); + assert_migrates(&storage); +} + +// ------------------------------------------------------- Chromium (LevelDB) + +#[cfg(windows)] +mod chromium { + use super::*; + use accshift_app::legacy_web_storage::chromium::{ + decode_string, encode_string, item_key, read_leveldb, read_leveldb_origins, split_item_key, + }; + use accshift_app::real::{RealBackend, BUNDLE_IDENTIFIER}; + use rusty_leveldb::compressor::{CompressorId, SnappyCompressor}; + use rusty_leveldb::env::Env; + use rusty_leveldb::{LdbIterator, MemEnv, Options, DB}; + use std::io::Read; + use std::rc::Rc; + use std::sync::Arc; + + const HTTP: &str = "http://tauri.localhost"; + const HTTPS: &str = "https://tauri.localhost"; + + #[test] + fn decodes_both_formats() { + assert_eq!(decode_string(&[]).unwrap(), (String::new(), false)); + assert_eq!( + decode_string(&encode_string("café", false)).unwrap(), + ("café".to_string(), false) + ); + assert_eq!(encode_string("café", false)[0], 1, "Latin-1 fits"); + assert_eq!(encode_string("✓", false)[0], 0, "needs UTF-16"); + assert_eq!( + decode_string(&encode_string("Réglages ✓ 😀", false)).unwrap(), + ("Réglages ✓ 😀".to_string(), false) + ); + assert!(decode_string(&[0, 1]).is_err(), "odd UTF-16 length"); + assert!(decode_string(&[2, b'x']).is_err(), "unknown format"); + // A lone high surrogate: kept as U+FFFD, flagged. + assert_eq!( + decode_string(&[0, 0x3d, 0xd8]).unwrap(), + ("\u{fffd}".to_string(), true) + ); + let key = item_key(HTTP, "accshift_settings", false); + let (origin, script) = split_item_key(&key).unwrap(); + assert_eq!(origin, HTTP.as_bytes()); + assert_eq!(decode_string(script).unwrap().0, "accshift_settings"); + assert_eq!(split_item_key(b"VERSION"), None); + assert_eq!(split_item_key(b"META:http://tauri.localhost"), None); + } + + /// Writes `db` files from the in-memory env into `dir`. + fn dump(env: &Rc>, dir: &Path) -> Vec { + std::fs::create_dir_all(dir).unwrap(); + let mut names = Vec::new(); + for child in env.children(Path::new("db")).unwrap() { + let mut bytes = Vec::new(); + env.open_sequential_file(&Path::new("db").join(&child)) + .unwrap() + .read_to_end(&mut bytes) + .unwrap(); + std::fs::write(dir.join(&child), bytes).unwrap(); + names.push(child.to_string_lossy().into_owned()); + } + names.sort(); + names + } + + /// A Chromium-shaped localStorage LevelDB: Snappy tables, a compacted + /// `.ldb` and a live `.log`, two app origins, another origin, the + /// bookkeeping keys, both encodings, a deletion and an overwrite in the + /// log, one corrupt value. + fn build_fixture(dir: &Path) -> Vec { + let env: Rc> = Rc::new(Box::new(MemEnv::new())); + let options = Options { + env: env.clone(), + compressor: SnappyCompressor::ID, + ..Options::default() + }; + let mut db = DB::open("db", options).unwrap(); + db.put(b"VERSION", b"1").unwrap(); + db.put(format!("META:{HTTP}").as_bytes(), b"\x08\x01") + .unwrap(); + let put = |db: &mut DB, origin: &str, key: &str, value: &str, utf16: bool| { + db.put(&item_key(origin, key, utf16), &encode_string(value, utf16)) + .unwrap(); + }; + // Table phase. + put(&mut db, HTTP, "accshift_settings", SETTINGS, false); + put(&mut db, HTTP, "accshift_folders", "{}", false); + put(&mut db, HTTP, "accshift_viewMode", "grid", false); + put(&mut db, HTTP, "accshift_boot_theme", "{}", false); + put(&mut db, HTTPS, "accshift_settings", "{}", true); + put(&mut db, HTTPS, "accshift_personas", "[]", true); + put( + &mut db, + "http://localhost:1420", + "accshift_account_card_notes", + "{}", + false, + ); + let corrupt = item_key(HTTP, "accshift_avatars", false); + db.put(&corrupt, &[9, b'{', b'}']).unwrap(); + // A long value so Snappy has something to compress. + let long = format!("{{\"blob\":\"{}\"}}", "a".repeat(4000)); + put(&mut db, HTTP, "accshift_ban_info_cache_v1", &long, true); + db.compact_range(b"\x00", b"\xff").unwrap(); + // Log phase: an overwrite, a deletion and a new key. + put(&mut db, HTTP, "accshift_folders", FOLDERS, true); + db.delete(&item_key(HTTP, "accshift_viewMode", false)) + .unwrap(); + put( + &mut db, + HTTP, + "accshift_account_card_colors", + r#"{"a":"red"}"#, + false, + ); + db.flush().unwrap(); + // Dump before the drop: `Drop for DB` only flushes the log, but the + // files must show the log entries still unmerged. + let names = dump(&env, dir); + drop(db); + names + } + + fn expected_chromium() -> LegacyLocalStorage { + map(&[ + ("accshift_settings", SETTINGS), + ("accshift_folders", FOLDERS), + ("accshift_personas", "[]"), + ("accshift_account_card_colors", r#"{"a":"red"}"#), + ( + "accshift_ban_info_cache_v1", + &format!("{{\"blob\":\"{}\"}}", "a".repeat(4000)), + ), + ]) + } + + /// Writes [`build_fixture`] where the egui probe looks for a v0.x + /// profile (`window/probe.rs`): + /// `ACCSHIFT_LEGACY_FIXTURE=/webview cargo test --test + /// legacy_web_storage -- --ignored write_legacy_fixture_for_the_probe`. + #[test] + #[ignore] + fn write_legacy_fixture_for_the_probe() { + let root = std::env::var_os("ACCSHIFT_LEGACY_FIXTURE") + .map(PathBuf::from) + .expect("ACCSHIFT_LEGACY_FIXTURE=/webview"); + build_fixture(&legacy_web_storage::webview2_leveldb_dir( + &root, + BUNDLE_IDENTIFIER, + )); + } + + #[test] + fn reads_a_chromium_leveldb_copy() { + let scratch = Scratch::new("chromium"); + let dir = scratch.0.join("leveldb"); + let names = build_fixture(&dir); + assert!(names.iter().any(|n| n.ends_with(".ldb")), "{names:?}"); + assert!(names.iter().any(|n| n.ends_with(".log")), "{names:?}"); + assert!( + names.iter().any(|n| n.starts_with("MANIFEST-")), + "{names:?}" + ); + // The log really holds the late keys: the table alone does not. + let log = names.iter().find(|n| n.ends_with(".log")).unwrap(); + let log_bytes = std::fs::read(dir.join(log)).unwrap(); + assert!(log_bytes + .windows(28) + .any(|w| w == b"accshift_account_card_colors")); + let before: Vec<(String, Vec)> = names + .iter() + .map(|n| (n.clone(), std::fs::read(dir.join(n)).unwrap())) + .collect(); + + let read = read_leveldb(&dir); + assert_eq!(read.storage, expected_chromium(), "{:?}", read.notes); + assert!( + read.notes + .iter() + .any(|n| n.contains("skipped accshift_avatars: unknown format byte 9")), + "{:?}", + read.notes + ); + + let mut after: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + after.sort(); + assert_eq!(after, names, "no file added to the folder"); + for (name, bytes) in before { + assert_eq!(std::fs::read(dir.join(&name)).unwrap(), bytes, "{name}"); + } + } + + #[test] + fn missing_empty_and_corrupt_folders_give_a_reason() { + let scratch = Scratch::new("chromium-broken"); + let read = read_leveldb(&scratch.0.join("absent")); + assert!(read.storage.is_empty()); + assert!(read.notes[0].starts_with("no WebView2 localStorage folder")); + + let empty = scratch.0.join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + let read = read_leveldb(&empty); + assert!(read.storage.is_empty()); + assert!(read.notes[0].ends_with("empty folder"), "{:?}", read.notes); + + let corrupt = scratch.0.join("corrupt"); + std::fs::create_dir_all(&corrupt).unwrap(); + std::fs::write(corrupt.join("CURRENT"), b"MANIFEST-000009\n").unwrap(); + std::fs::write(corrupt.join("MANIFEST-000009"), b"garbage garbage").unwrap(); + std::fs::write(corrupt.join("000010.ldb"), [0u8; 64]).unwrap(); + let read = read_leveldb(&corrupt); + assert!(read.storage.is_empty()); + assert_eq!(read.notes.len(), 1, "{:?}", read.notes); + + // A truncated table under a valid manifest: no panic escapes. + let truncated = scratch.0.join("truncated"); + let names = build_fixture(&truncated); + let ldb = names.iter().find(|n| n.ends_with(".ldb")).unwrap(); + let bytes = std::fs::read(truncated.join(ldb)).unwrap(); + std::fs::write(truncated.join(ldb), &bytes[..bytes.len() / 2]).unwrap(); + // rusty-leveldb asserts on the bad block checksum; the panic is caught. + let read = read_leveldb(&truncated); + assert!(read.storage.is_empty()); + assert!( + read.notes[0].ends_with("the LevelDB reader panicked"), + "{:?}", + read.notes + ); + } + + #[test] + fn read_in_finds_the_webview2_folder() { + let scratch = Scratch::new("chromium-read-in"); + let dir = legacy_web_storage::webview2_leveldb_dir(&scratch.0, BUNDLE_IDENTIFIER); + assert!(dir.ends_with("com.accshift.desktop/EBWebView/Default/Local Storage/leveldb")); + build_fixture(&dir); + let read = legacy_web_storage::read_in(&scratch.0, BUNDLE_IDENTIFIER); + assert_eq!(read.storage, expected_chromium()); + } + + #[test] + fn chromium_read_migrates_into_the_mock_backend() { + let scratch = Scratch::new("chromium-e2e-mock"); + let dir = scratch.0.join("leveldb"); + build_fixture(&dir); + assert_migrates(&read_leveldb(&dir).storage); + } + + /// The same, written to disk by the real backend on a scratch folder and + /// read back from the files. + #[test] + fn chromium_read_migrates_into_real_store_files() { + struct Ctx(PathBuf); + impl accshift_core::AppContext for Ctx { + fn app_config_dir(&self) -> Result { + Ok(self.0.join("config")) + } + fn app_data_dir(&self) -> Result { + Ok(self.0.join("data")) + } + fn app_local_data_dir(&self) -> Result { + Ok(self.0.join("local")) + } + fn app_cache_dir(&self) -> Result { + Ok(self.0.join("cache")) + } + } + let scratch = Scratch::new("chromium-e2e-real"); + let dir = scratch.0.join("leveldb"); + build_fixture(&dir); + let legacy = read_leveldb(&dir).storage; + let backend = RealBackend::with_context(Arc::new(Ctx(scratch.0.join("app")))); + use accshift_app::Backend; + let boot = backend.load_client_storage_snapshot().unwrap(); + assert!(legacy_web_storage::needs_read(&boot)); + let (_, report) = initialize(&backend, Some(boot), Some(&legacy), false); + assert!(report.failed_migrations.is_empty(), "{report:?}"); + assert_eq!(report.migrated.len(), legacy.len()); + + let reloaded = backend.load_client_storage_snapshot().unwrap(); + let settings: Value = serde_json::from_str(SETTINGS).unwrap(); + assert_eq!(reloaded.stores.get(CLIENT_STORE_SETTINGS), Some(&settings)); + let folders: Value = serde_json::from_str(FOLDERS).unwrap(); + assert_eq!(reloaded.stores.get(CLIENT_STORE_FOLDERS), Some(&folders)); + // A second boot over the same files migrates nothing more. + let (_, again) = initialize(&backend, Some(reloaded), Some(&legacy), false); + assert!(again.migrated.is_empty()); + } + + /// Read-only pass over this machine's WebView2 profile. Prints key names, + /// value lengths and origins, never a value. Skipped when the folder is + /// missing. + #[test] + fn real_webview2_profile_read_only() { + let Some(local) = dirs::data_local_dir() else { + return; + }; + let dir = legacy_web_storage::webview2_leveldb_dir(&local, BUNDLE_IDENTIFIER); + if !dir.is_dir() { + println!("skipped: no {}", dir.display()); + return; + } + let listing = |dir: &Path| { + let mut names: Vec<(String, u64)> = std::fs::read_dir(dir) + .unwrap() + .flatten() + .map(|e| { + let len = e.metadata().map(|m| m.len()).unwrap_or(0); + (e.file_name().to_string_lossy().into_owned(), len) + }) + .collect(); + names.sort(); + names + }; + let before = listing(&dir); + + let start = Instant::now(); + let read: LegacyRead = legacy_web_storage::read_in(&local, BUNDLE_IDENTIFIER); + let elapsed = start.elapsed(); + + println!("profile files: {before:?}"); + println!("full read: {elapsed:?} (debug build)"); + println!("notes: {:?}", read.notes); + let mut found = 0; + for (_, key) in LEGACY_LOCAL_STORAGE_KEYS { + match read.storage.get(key) { + Some(value) => { + found += 1; + println!(" {key}: {} chars", value.chars().count()); + } + None => println!(" {key}: absent"), + } + } + println!("legacy keys found: {found}/12"); + + // Origins and script key names of the copy (no values). + let env: Rc> = Rc::new(Box::new(MemEnv::new())); + for (name, _) in &before { + if matches!(name.as_str(), "LOCK" | "LOG" | "LOG.old") { + continue; + } + let bytes = std::fs::read(dir.join(name)).unwrap(); + use std::io::Write; + env.open_writable_file(&Path::new("db").join(name)) + .unwrap() + .write_all(&bytes) + .unwrap(); + } + let mut db = DB::open( + "db", + Options { + env, + create_if_missing: false, + ..Options::default() + }, + ) + .unwrap(); + let mut origins: BTreeMap> = BTreeMap::new(); + let mut iter = db.new_iter().unwrap(); + while let Some((key, value)) = iter.next() { + match split_item_key(&key) { + // Key name, format byte and decoded length: proves the real + // Chromium encoding decodes, without showing a value. + Some((origin, script)) => origins + .entry(String::from_utf8_lossy(origin).into_owned()) + .or_default() + .push(format!( + "{} (format {:?}, {})", + decode_string(script) + .map(|(k, _)| k) + .unwrap_or_else(|e| format!("<{e}>")), + value.first(), + decode_string(&value) + .map(|(v, lossy)| format!("{} chars, lossy {lossy}", v.chars().count())) + .unwrap_or_else(|e| format!("<{e}>")) + )), + None => origins + .entry("".into()) + .or_default() + .push(String::from_utf8_lossy(&key).chars().take(40).collect()), + } + } + for (origin, keys) in &origins { + println!("origin {origin}: {keys:?}"); + } + + let after = listing(&dir); + let names = |l: &[(String, u64)]| l.iter().map(|(n, _)| n.clone()).collect::>(); + assert_eq!(names(&after), names(&before), "file set unchanged"); + } + + // ----------------------------------------- written by real Chromium + + /// The page origin of the parity build that wrote the fixtures: its + /// devUrl, not `http://tauri.localhost`. + const PROOF_ORIGIN: &str = "http://localhost:1420"; + + fn proof_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy_web_storage") + } + + /// `expected.json` from `chromium-proof.mjs`: the values set in phase 1 + /// and the final state after phase 2 (one overwrite, one removal). + fn proof_expected() -> (LegacyLocalStorage, LegacyLocalStorage) { + let raw = std::fs::read_to_string(proof_dir().join("expected.json")).unwrap(); + let value: Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(value["origin"], PROOF_ORIGIN); + let grab = |name: &str| -> LegacyLocalStorage { + value[name] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) + .collect() + }; + (grab("phase1"), grab("final")) + } + + /// Format byte of each script key's value for `origin`, read straight + /// from a copy of the folder. + fn value_formats(dir: &Path, origin: &str) -> BTreeMap { + let env: Rc> = Rc::new(Box::new(MemEnv::new())); + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let bytes = std::fs::read(entry.path()).unwrap(); + use std::io::Write; + env.open_writable_file(&Path::new("db").join(entry.file_name())) + .unwrap() + .write_all(&bytes) + .unwrap(); + } + let mut db = DB::open( + "db", + Options { + env, + create_if_missing: false, + ..Options::default() + }, + ) + .unwrap(); + let mut out = BTreeMap::new(); + let mut iter = db.new_iter().unwrap(); + while let Some((key, value)) = iter.next() { + if let Some((o, script)) = split_item_key(&key) { + if o == origin.as_bytes() { + out.insert(decode_string(script).unwrap().0, value[0]); + } + } + } + out + } + + /// Reads one case and counts the legacy keys whose state matches the + /// final one (value equal, or absent when removed). Prints names and + /// lengths only. + fn proof_case(case: &str) -> (usize, LegacyRead) { + let (phase1, last) = proof_expected(); + let dir = proof_dir().join(case); + let start = Instant::now(); + let read = read_leveldb_origins(&dir, &[PROOF_ORIGIN]); + println!( + "{case}: read in {:?}, notes {:?}", + start.elapsed(), + read.notes + ); + let mut equal = 0; + for (_, key) in LEGACY_LOCAL_STORAGE_KEYS { + let got = read.storage.get(key); + let state = if got == last.get(key) { + equal += 1; + "final" + } else if got == phase1.get(key) { + "phase 1" + } else { + "other" + }; + println!( + " {key}: {state}, {} chars", + got.map_or(0, |v| v.chars().count()) + ); + } + println!("chromium-proof {case}: {equal}/12 keys equal"); + (equal, read) + } + + #[test] + fn real_chromium_clean_close() { + let (phase1, last) = proof_expected(); + // The fixture covers the cases the brief names. + assert!(last["accshift_avatars"].len() > 64 * 1024); + assert!(last["accshift_account_card_notes"] + .chars() + .any(|c| c as u32 > 0xFF)); + assert!(last["accshift_viewMode"].is_ascii()); + assert!(!last.contains_key("accshift_ban_check_state_v2")); + assert_ne!( + phase1["accshift_ban_info_cache_v1"], + last["accshift_ban_info_cache_v1"] + ); + + let (equal, read) = proof_case("clean"); + assert_eq!(equal, 12); + assert_eq!(read.storage, last); + // Compacted on close: a table file, so the table path ran. + assert!(std::fs::read_dir(proof_dir().join("clean")) + .unwrap() + .flatten() + .any(|e| e.file_name().to_string_lossy().ends_with(".ldb"))); + // Chromium picked Latin-1 for the Latin-1 values and UTF-16 for the + // others, so both decoders ran on its bytes. + let formats = value_formats(&proof_dir().join("clean"), PROOF_ORIGIN); + assert_eq!(formats["accshift_viewMode"], 1); + assert_eq!(formats["accshift_folders"], 1); + assert_eq!(formats["accshift_account_card_notes"], 0); + assert_eq!(formats["accshift_ban_info_cache_v1"], 0); + // And the read migrates. + assert_migrates(&read.storage); + } + + /// Killed 1 s after phase 2: Chromium commits localStorage to LevelDB on a + /// delay, so phase 2 never reached the disk. The reader still returns a + /// consistent state, the one committed after phase 1, from a folder that + /// holds only the write-ahead log (no table file). + #[test] + fn real_chromium_kill() { + let (phase1, _) = proof_expected(); + let dir = proof_dir().join("kill"); + assert!(!std::fs::read_dir(&dir) + .unwrap() + .flatten() + .any(|e| e.file_name().to_string_lossy().ends_with(".ldb"))); + let (equal, read) = proof_case("kill"); + assert_eq!(equal, 10); + assert_eq!(read.storage, phase1); + } +} + +// ------------------------------------------------------------------ WebView2 caches + +#[test] +fn webview2_cache_prune_keeps_the_local_storage() { + let scratch = Scratch::new("prune"); + let web = scratch.0.join("EBWebView"); + let write = |rel: &str, len: usize| { + let path = web.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, vec![7u8; len]).unwrap(); + }; + write("Default/Code Cache/js/index", 1000); + write("Default/Code Cache/wasm/a", 24); + write("GrShaderCache/data_0", 500); + write("Default/Local Storage/leveldb/000003.log", 64); + write("Default/Preferences", 10); + + let (freed, failures) = legacy_web_storage::prune_webview2_caches(&scratch.0); + assert_eq!((freed, failures), (1524, Vec::::new())); + assert!(!web.join("Default").join("Code Cache").exists()); + assert!(!web.join("GrShaderCache").exists()); + assert!(web + .join("Default/Local Storage/leveldb/000003.log") + .is_file()); + assert!(web.join("Default/Preferences").is_file()); + + // Nothing left to prune, and no EBWebView at all, are both no-ops. + let again = legacy_web_storage::prune_webview2_caches(&scratch.0); + assert_eq!(again, (0, Vec::new())); + let none = legacy_web_storage::prune_webview2_caches(&scratch.0.join("absent")); + assert_eq!(none, (0, Vec::new())); +} diff --git a/crates/accshift-app/tests/lifecycle_flow.rs b/crates/accshift-app/tests/lifecycle_flow.rs new file mode 100644 index 00000000..5503c2ed --- /dev/null +++ b/crates/accshift-app/tests/lifecycle_flow.rs @@ -0,0 +1,940 @@ +//! The lifecycle state machines: boot and shutdown order (recorded by a +//! fake host), the update flow (`useAppUpdater.test.ts`), the external +//! refresh (`useAppLifecycle.test.ts`), the boot helpers, the UI scale and +//! the process control (a fake never relaunches or exits). + +mod support; + +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::lifecycle::app_shell::{ + first_run_platforms, initial_load, migration_toast, plan_external_refresh, semver_core, + settings_snapshot, ExternalRefreshGate, InitialLoad, RefreshStep, SHELL_INIT_ORDER, +}; +use accshift_app::lifecycle::boot::{ + log_app_event, mask_value, redact_object, remember_boot_locale, serialize_log_value, + track_update, truncate_log, BootMarks, CloseRequest, BOOT_FAILSAFE, +}; +use accshift_app::lifecycle::process::{self, ProcessControl}; +use accshift_app::lifecycle::ui_scale::{ + backdrop_available, clamp_ui_scale_percent, liquid_backdrop_active, UiScale, WheelDeltaMode, + PERSIST_DELAY, +}; +use accshift_app::lifecycle::update_flow::{ + classify_update_check_error, AppUpdater, LogLevel, PendingUpdate, UpdateCommand, + UpdateErrorCode, UpdateOutput, UpdateStage, UpdateState, +}; +use accshift_app::lifecycle::{ + BootOutcome, BootStep, Lifecycle, LifecycleHost, ShutdownStep, BOOT_ORDER, SHUTDOWN_ORDER, + UPDATE_CHECK_DELAY, +}; +use accshift_app::platforms::{PlatformRegistry, RuntimeOs}; +use accshift_app::settings::{sanitize_settings, AppSettings}; +use accshift_app::storage::{ + CLIENT_STORE_PERSONAS, CLIENT_STORE_SETTINGS, STORAGE_TARGET_APP_CONFIG_LOCAL, + STORAGE_TARGET_CUSTOM_THEMES, +}; +use accshift_app::stores::StoreContext; +use accshift_app::text::Text; +use serde_json::{json, Value}; +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// Boot and shutdown order + +#[derive(Default)] +struct RecordingHost { + calls: Vec, + forwarded: bool, + flush_error: Option, +} + +impl LifecycleHost for RecordingHost { + fn acquire_single_instance(&mut self) -> bool { + self.calls.push("single_instance".into()); + !self.forwarded + } + fn launch_url(&mut self, url: Option) { + self.calls.push(format!( + "launch_url:{}", + url.map(|u| u.to_string()).unwrap_or_default() + )); + } + fn read_legacy_storage(&mut self) { + self.calls.push("legacy_read".into()); + } + fn initialize_storage(&mut self) { + self.calls.push("storage_initialize".into()); + } + fn apply_theme(&mut self) { + self.calls.push("theme".into()); + } + fn start_telemetry(&mut self) { + self.calls.push("telemetry_start".into()); + } + fn begin_session(&mut self) { + self.calls.push("session_begin".into()); + } + fn spawn_maintenance(&mut self) { + self.calls.push("maintenance".into()); + } + fn register_schemes(&mut self) { + self.calls.push("schemes".into()); + } + fn arm_boot_failsafe(&mut self, after: Duration) { + self.calls.push(format!("failsafe:{}", after.as_millis())); + } + fn schedule_update_check(&mut self, after: Duration) { + self.calls.push(format!("updater:{}", after.as_millis())); + } + fn start_geometry_save(&mut self) { + self.calls.push("geometry_start".into()); + } + fn hide_window(&mut self) { + self.calls.push("hide".into()); + } + fn end_telemetry_session(&mut self) { + self.calls.push("telemetry_end".into()); + } + fn wait_geometry_save(&mut self, bound: Duration) { + self.calls + .push(format!("geometry_wait:{}", bound.as_secs())); + } + fn flush_saves(&mut self) -> Result<(), String> { + self.calls.push("flush".into()); + match &self.flush_error { + Some(error) => Err(error.clone()), + None => Ok(()), + } + } + fn release_single_instance(&mut self) { + self.calls.push("single_instance_release".into()); + } + fn exit(&mut self, code: i32) { + self.calls.push(format!("exit:{code}")); + } + fn log(&mut self, level: &str, source: &str, message: &str) { + self.calls.push(format!("log:{level}:{source}:{message}")); + } +} + +#[test] +fn boot_runs_every_step_in_the_documented_order() { + let mut host = RecordingHost::default(); + let mut lifecycle = Lifecycle::new(vec![ + "accshift.exe".into(), + "accshift://switch/steam/alice".into(), + ]); + assert_eq!(lifecycle.boot(&mut host), BootOutcome::Running); + // macOS gets the link from the OS, not the arguments. + let launch_url = if cfg!(any(windows, target_os = "linux")) { + "launch_url:accshift://switch/steam/alice" + } else { + "launch_url:" + }; + assert_eq!( + host.calls, + [ + "single_instance", + launch_url, + "legacy_read", + "storage_initialize", + "theme", + "telemetry_start", + "session_begin", + "maintenance", + "schemes", + "failsafe:5000", + "updater:3500", + ] + ); + assert_eq!(BOOT_ORDER.len(), 11); + assert_eq!(BOOT_ORDER[0], BootStep::SingleInstance); + assert_eq!(BOOT_ORDER[10], BootStep::UpdaterCheck); + assert_eq!(UPDATE_CHECK_DELAY, Duration::from_millis(3500)); + assert_eq!(BOOT_FAILSAFE, Duration::from_millis(5000)); + + // Boot runs once. + host.calls.clear(); + assert_eq!(lifecycle.boot(&mut host), BootOutcome::Running); + assert!(host.calls.is_empty()); +} + +#[test] +fn a_forwarded_launch_exits_before_touching_anything() { + let mut host = RecordingHost { + forwarded: true, + ..Default::default() + }; + let mut lifecycle = Lifecycle::new(vec!["accshift.exe".into()]); + assert_eq!(lifecycle.boot(&mut host), BootOutcome::Forwarded); + assert_eq!(host.calls, ["single_instance", "exit:0"]); +} + +#[test] +fn a_launch_without_a_link_has_no_launch_url() { + let mut host = RecordingHost::default(); + // Two arguments: the plugin only reads a lone URL. + Lifecycle::new(vec![ + "accshift.exe".into(), + "--minimized".into(), + "accshift://switch/steam/a".into(), + ]) + .boot(&mut host); + assert_eq!(host.calls[1], "launch_url:"); +} + +#[test] +fn shutdown_runs_once_in_the_documented_order() { + let mut host = RecordingHost::default(); + let mut lifecycle = Lifecycle::new(vec!["accshift.exe".into()]); + assert!(!lifecycle.is_closing()); + assert!(lifecycle.shutdown(&mut host)); + assert_eq!( + host.calls, + [ + "geometry_start", + "hide", + "telemetry_end", + "geometry_wait:5", + "flush", + "single_instance_release", + "exit:0", + ] + ); + assert_eq!(SHUTDOWN_ORDER[0], ShutdownStep::StartGeometrySave); + assert!(lifecycle.is_closing()); + host.calls.clear(); + assert!(!lifecycle.shutdown(&mut host)); + assert!(host.calls.is_empty()); +} + +#[test] +fn a_failed_flush_is_logged_and_the_close_goes_on() { + let mut host = RecordingHost { + flush_error: Some("disk full".into()), + ..Default::default() + }; + Lifecycle::new(Vec::new()).shutdown(&mut host); + assert_eq!( + &host.calls[4..], + [ + "flush", + "log:error:frontend.app:Failed to flush pending saves on close: disk full", + "single_instance_release", + "exit:0", + ] + ); +} + +// --------------------------------------------------------------------------- +// Update flow (useAppUpdater.test.ts) + +#[derive(Debug, Clone, PartialEq)] +struct FakeUpdate(&'static str); + +impl PendingUpdate for FakeUpdate { + fn version(&self) -> String { + self.0.to_string() + } +} + +const TARGET_NOT_FOUND: &str = + "the platform `linux-x86_64` was not found in the response `platforms` object"; +const TARGETS_NOT_FOUND: &str = "None of the fallback platforms `[\"darwin-aarch64-app\", \"darwin-aarch64\"]` were found in the response `platforms` object"; + +fn failed_check(error: &str) -> Vec> { + let mut updater = AppUpdater::::new(false); + assert_eq!(updater.start(), [UpdateOutput::Run(UpdateCommand::Check)]); + let outputs = updater.on_check_result(Err(error.into())); + assert_eq!(updater.state(), UpdateState::Idle); + outputs +} + +fn tracked( + outputs: &[UpdateOutput], +) -> Vec<(UpdateStage, Option, Option<&'static str>)> { + outputs + .iter() + .filter_map(|output| match output { + UpdateOutput::Track { + stage, + target_version, + error_code, + } => Some(( + *stage, + target_version.clone(), + error_code.map(UpdateErrorCode::as_str), + )), + _ => None, + }) + .collect() +} + +fn logged(outputs: &[UpdateOutput], level: LogLevel) -> usize { + outputs + .iter() + .filter(|output| matches!(output, UpdateOutput::Log { level: l, .. } if *l == level)) + .count() +} + +#[test] +fn a_missing_platform_entry_has_its_own_code_and_no_error_log() { + let outputs = failed_check(TARGET_NOT_FOUND); + assert_eq!( + tracked(&outputs), + [(UpdateStage::Failed, None, Some("update_target_missing"))] + ); + assert_eq!(logged(&outputs, LogLevel::Error), 0); + assert_eq!(logged(&outputs, LogLevel::Info), 1); +} + +#[test] +fn an_unreadable_manifest_is_told_apart_from_a_dead_endpoint() { + let outputs = failed_check("Could not fetch a valid release JSON from the remote"); + assert_eq!( + tracked(&outputs), + [(UpdateStage::Failed, None, Some("update_manifest_invalid"))] + ); +} + +#[test] +fn a_network_failure_is_still_check_failed() { + let outputs = failed_check("error sending request for url (https://github.com)"); + assert_eq!( + tracked(&outputs), + [(UpdateStage::Failed, None, Some("check_failed"))] + ); + assert_eq!(logged(&outputs, LogLevel::Error), 1); +} + +#[test] +fn no_update_stays_quiet() { + let mut updater = AppUpdater::::new(false); + updater.start(); + assert!(updater.on_check_result(Ok(None)).is_empty()); + assert_eq!(updater.state(), UpdateState::Idle); +} + +#[test] +fn the_check_errors_classify_as_the_ts_tests() { + let code = |text: Option<&str>| classify_update_check_error(text).as_str(); + assert_eq!(code(Some(TARGET_NOT_FOUND)), "update_target_missing"); + assert_eq!(code(Some(TARGETS_NOT_FOUND)), "update_target_missing"); + assert_eq!( + code(Some("missing field `version` at line 1 column 42")), + "update_manifest_invalid" + ); + assert_eq!( + code(Some( + "The signature ...= could not be decoded, please check if it is a valid base64 string." + )), + "update_manifest_invalid" + ); + assert_eq!(code(Some("operation timed out")), "check_failed"); + assert_eq!(code(None), "check_failed"); + // The rest of MANIFEST_INVALID_PATTERNS, case-insensitive. + for text in [ + "INVALID UPDATER BINARY FORMAT", + "expected value at line 1 column 1", + "invalid type: integer `1`, expected a string", + "trailing characters at line 1 column 9", + "unexpected character 'x' while parsing Minor version number", + ] { + assert_eq!(code(Some(text)), "update_manifest_invalid", "{text}"); + } +} + +#[test] +fn the_whole_flow_downloads_then_applies() { + let mut updater = AppUpdater::::new(false); + assert_eq!(updater.cta_label(), None); + assert_eq!(updater.cta_title(), Text::key("update.restartToApply")); + updater.start(); + // A second start while checking does nothing. + assert!(updater.start().is_empty()); + let outputs = updater.on_check_result(Ok(Some(FakeUpdate("1.2.0")))); + assert_eq!( + outputs, + [ + UpdateOutput::Track { + stage: UpdateStage::Available, + target_version: Some("1.2.0".into()), + error_code: None, + }, + UpdateOutput::Run(UpdateCommand::Download(FakeUpdate("1.2.0"))), + ] + ); + assert_eq!(updater.state(), UpdateState::Downloading); + // Not ready yet: apply does nothing. + assert!(updater.apply().is_empty()); + + let outputs = updater.on_download_result(Ok(vec![1, 2, 3])); + assert_eq!( + outputs[1], + UpdateOutput::Toast(Text::with( + "update.readyToastVersion", + [("version", "1.2.0".to_string())] + )) + ); + assert_eq!(updater.state(), UpdateState::Ready); + assert_eq!(updater.cta_label(), Some(Text::key("update.ctaAvailable"))); + assert_eq!( + updater.cta_title(), + Text::with( + "update.restartToApplyVersion", + [("version", "1.2.0".to_string())] + ) + ); + assert!(!updater.cta_disabled()); + + let outputs = updater.apply(); + assert_eq!( + outputs, + [UpdateOutput::Run(UpdateCommand::Install { + update: FakeUpdate("1.2.0"), + bytes: vec![1, 2, 3], + })] + ); + assert!(updater.cta_disabled()); + assert_eq!(updater.cta_label(), Some(Text::key("update.ctaInstalling"))); + assert!(updater.apply().is_empty()); + + let outputs = updater.on_install_result(Ok(())); + assert_eq!( + tracked(&outputs), + [(UpdateStage::Applied, Some("1.2.0".into()), None)] + ); + assert_eq!(outputs[1], UpdateOutput::Run(UpdateCommand::Relaunch)); +} + +#[test] +fn a_failed_download_resets_and_tracks_the_version() { + let mut updater = AppUpdater::::new(false); + updater.start(); + updater.on_check_result(Ok(Some(FakeUpdate("1.2.0")))); + let outputs = updater.on_download_result(Err("`Download request failed`".into())); + assert_eq!( + tracked(&outputs), + [( + UpdateStage::Failed, + Some("1.2.0".into()), + Some("download_failed") + )] + ); + assert_eq!(updater.state(), UpdateState::Idle); + assert_eq!(updater.version(), ""); + // The flow can start again. + assert_eq!(updater.start(), [UpdateOutput::Run(UpdateCommand::Check)]); +} + +#[test] +fn a_failed_install_or_relaunch_toasts_and_checks_again() { + for relaunch in [false, true] { + let mut updater = AppUpdater::::new(false); + updater.start(); + updater.on_check_result(Ok(Some(FakeUpdate("1.2.0")))); + updater.on_download_result(Ok(Vec::new())); + updater.apply(); + let outputs = if relaunch { + updater.on_install_result(Ok(())); + updater.on_relaunch_failed("spawn failed".into()) + } else { + updater.on_install_result(Err("flush failed".into())) + }; + let code = if relaunch { + "relaunch_failed" + } else { + "install_failed" + }; + assert_eq!( + tracked(&outputs), + [(UpdateStage::Failed, Some("1.2.0".into()), Some(code))] + ); + assert!( + matches!(&outputs[0], UpdateOutput::Log { level: LogLevel::Error, message } if message.starts_with("Failed to restart for update: ")) + ); + assert_eq!( + &outputs[2..], + [ + UpdateOutput::Toast(Text::key("update.restartFailed")), + UpdateOutput::Run(UpdateCommand::Check), + ] + ); + assert_eq!(updater.state(), UpdateState::Checking); + } +} + +#[test] +fn a_dev_build_never_checks() { + let mut updater = AppUpdater::::new(true); + assert!(updater.start().is_empty()); + assert_eq!(updater.state(), UpdateState::Idle); +} + +#[test] +fn update_telemetry_reaches_the_backend() { + let backend = support::scripted("demo"); + track_update(&backend, "failed", None, Some("check_failed")); + assert_eq!(backend.calls("telemetry_track_update").len(), 1); + let call = &backend.calls("telemetry_track_update")[0]; + assert!( + call.contains("failed") && call.contains("check_failed"), + "{call}" + ); +} + +// --------------------------------------------------------------------------- +// App shell (useAppLifecycle.test.ts) + +fn settings(patch: Value) -> AppSettings { + sanitize_settings(&patch, &StoreContext::shipped()) +} + +fn refresh(changed: &[&str], enabled: &[&str], active_tab: &str) -> Vec { + let changed: Vec = changed.iter().map(|c| c.to_string()).collect(); + let settings = settings(json!({ + "enabledPlatforms": enabled, + "defaultPlatformId": enabled[0], + })); + plan_external_refresh( + &changed, + &settings, + active_tab, + &PlatformRegistry::shipped(), + RuntimeOs::Windows, + false, + ) +} + +#[test] +fn the_personas_list_reloads_when_another_process_rewrote_it() { + let steps = refresh(&[CLIENT_STORE_PERSONAS], &["steam"], "steam"); + assert_eq!( + steps + .iter() + .filter(|step| **step == RefreshStep::RefreshPersonas) + .count(), + 1 + ); +} + +#[test] +fn unrelated_stores_leave_the_personas_alone() { + let steps = refresh(&["something-else"], &["steam"], "steam"); + assert!(!steps.contains(&RefreshStep::RefreshPersonas)); + // Only the log line. + assert_eq!(steps.len(), 1); + assert_eq!( + steps[0], + RefreshStep::Log { + details: r#"{"changed":["something-else"],"activeTab":"steam"}"#.into() + } + ); +} + +/// The steps the TS test records as `calls`. +fn recorded_calls(steps: &[RefreshStep]) -> Vec { + steps + .iter() + .filter_map(|step| match step { + RefreshStep::ClearLoaderForPlatformChange => Some("clear".to_string()), + RefreshStep::SetActiveTab(tab) => Some(format!("tab:{tab}")), + RefreshStep::LoadAccounts { .. } => Some("load".to_string()), + _ => None, + }) + .collect() +} + +#[test] +fn an_external_change_that_disabled_the_tab_moves_off_it_and_reloads() { + let steps = refresh(&[CLIENT_STORE_SETTINGS], &["riot"], "steam"); + assert_eq!(recorded_calls(&steps), ["clear", "tab:riot", "load"]); + assert!(steps.contains(&RefreshStep::LoadAccounts { + force: true, + warnings: false + })); + assert!(steps.contains(&RefreshStep::ReplaceHistory("riot".into()))); +} + +#[test] +fn the_grid_is_left_alone_while_the_tab_stays_enabled() { + let steps = refresh(&[CLIENT_STORE_SETTINGS], &["steam", "riot"], "steam"); + assert!(recorded_calls(&steps).is_empty()); +} + +#[test] +fn app_config_and_platform_stores_reload_the_accounts() { + let steps = refresh(&[STORAGE_TARGET_APP_CONFIG_LOCAL], &["steam"], "steam"); + assert!(matches!( + steps.last(), + Some(RefreshStep::LoadAccounts { force: true, .. }) + )); + let steps = refresh(&[STORAGE_TARGET_CUSTOM_THEMES], &["steam"], "steam"); + assert_eq!(steps[1], RefreshStep::LoadCustomThemes); + assert_eq!(steps[2], RefreshStep::RefreshSettings); + assert!(refresh(&[], &["steam"], "steam").is_empty()); +} + +#[test] +fn the_shell_init_helpers_match_the_ts() { + assert_eq!(semver_core("1.0.5"), "1.0.5"); + assert_eq!(semver_core("v1.2.3-beta.1+build"), "1.2.3"); + assert_eq!(semver_core("12.34.56.78"), "12.34.56"); + assert_eq!(semver_core("dev"), "dev"); + assert_eq!(semver_core("1.2"), "1.2"); + + assert_eq!( + migration_toast("migrated"), + Some(Toast::new( + Text::key("toast.legacyConfigMigrated"), + ToastKind::Default + )) + ); + assert_eq!( + migration_toast("error:denied").map(|toast| toast.text), + Some(Text::key("toast.legacyConfigMigrationFailed")) + ); + assert_eq!(migration_toast("none"), None); + + let registry = PlatformRegistry::shipped(); + let detected = first_run_platforms( + &["riot".to_string(), "nope".to_string(), "steam".to_string()], + ®istry, + RuntimeOs::Windows, + ); + assert_eq!(detected.detected, ["riot", "steam"]); + assert_eq!( + detected.enable, + Some(( + vec!["riot".to_string(), "steam".to_string()], + "riot".to_string() + )) + ); + assert_eq!( + first_run_platforms(&[], ®istry, RuntimeOs::Windows).enable, + None + ); + + assert_eq!( + initial_load(®istry, "steam", RuntimeOs::Windows), + InitialLoad::LoadAccounts + ); + assert_eq!( + initial_load(®istry, "steam", RuntimeOs::Unknown), + InitialLoad::RefreshItems + ); + assert_eq!(SHELL_INIT_ORDER.len(), 10); + + let snapshot = settings_snapshot(&settings(json!({"enabledPlatforms": ["steam"]}))); + assert_eq!(snapshot.enabled_platforms, ["steam"]); + assert_eq!(snapshot.ui_language, "en"); + assert!(!snapshot.streamer_mode.is_empty()); + assert!(!snapshot.animations.is_empty()); + + let mut gate = ExternalRefreshGate::default(); + assert!(gate.begin()); + assert!(!gate.begin()); + gate.end(); + assert!(gate.begin()); +} + +// --------------------------------------------------------------------------- +// Boot helpers + +#[test] +fn boot_marks_keep_the_first_write_and_round_to_a_tenth() { + let origin = Instant::now(); + let mut marks = BootMarks::new(origin); + marks.mark("app-start", origin + Duration::from_micros(12_345)); + marks.mark("app-start", origin + Duration::from_millis(50)); + assert_eq!(marks.to_value(), json!({"app-start": 12.3})); + assert_eq!( + marks.since("app-start", origin + Duration::from_micros(22_345)), + Some(10.0) + ); + assert_eq!(marks.since("missing", origin), None); +} + +#[test] +fn the_boot_locale_is_written_only_when_it_changed() { + assert_eq!(remember_boot_locale(Some("fr"), "fr"), None); + assert_eq!( + remember_boot_locale(Some("en"), "fr").as_deref(), + Some("fr") + ); + assert_eq!(remember_boot_locale(None, "en").as_deref(), Some("en")); +} + +#[test] +fn log_details_are_redacted_as_app_logger() { + assert_eq!(mask_value(&json!("ab")), json!("***")); + assert_eq!(mask_value(&json!("alice")), json!("al***")); + assert_eq!(mask_value(&json!(["a", "b"])), json!("[2 items]")); + assert_eq!(mask_value(&json!(76561198000000000u64)), json!("***")); + + let details = json!({ + "Username": "alice", + "steamId": "76561198000000000", + "nested": {"email": "a@b.c", "keep": 1}, + "list": [{"username": "kept in arrays"}], + "platform": "steam", + }); + let redacted = redact_object(details.as_object().unwrap()); + assert_eq!( + Value::Object(redacted), + json!({ + "Username": "al***", + "steamId": "76***", + "nested": {"email": "a@***", "keep": 1}, + "list": [{"username": "kept in arrays"}], + "platform": "steam", + }) + ); + assert_eq!(serialize_log_value(&json!("plain text")), "plain text"); + assert_eq!(serialize_log_value(&json!(42)), "42"); + // `typeof [] === "object"`: a top-level array goes through redactObject. + assert_eq!( + serialize_log_value(&json!([{"uuid": "abcdef"}, "x"])), + r#"{"0":{"uuid":"ab***"},"1":"x"}"# + ); + + let backend = support::scripted("demo"); + log_app_event( + &backend, + "info", + "frontend.test", + "hello", + Some(&json!({"userId": "1234"})), + ); + log_app_event( + &backend, + "info", + "frontend.test", + "no details", + Some(&Value::Null), + ); + let calls = backend.calls("log_app_event"); + assert_eq!(calls.len(), 2); + assert!(calls[0].contains("12***"), "{}", calls[0]); + assert!(calls[1].contains("None"), "{}", calls[1]); +} + +#[test] +fn log_lines_are_capped_in_utf16_units() { + let long = "é".repeat(600); + let (message, details) = truncate_log(&long, Some(&"x".repeat(20_000))); + assert_eq!(message.chars().count(), 512); + assert_eq!(details.unwrap().len(), 16_384); + let (short, none) = truncate_log("ok", None); + assert_eq!((short.as_str(), none), ("ok", None)); +} + +#[test] +fn the_close_request_runs_once() { + let mut close = CloseRequest::default(); + assert!(close.request()); + assert!(close.is_closing()); + assert!(!close.request()); +} + +// --------------------------------------------------------------------------- +// UI scale and backdrop + +#[test] +fn the_ui_scale_rounds_to_the_step_and_clamps() { + assert_eq!(clamp_ui_scale_percent(100.0), 100); + assert_eq!(clamp_ui_scale_percent(102.4), 100); + assert_eq!(clamp_ui_scale_percent(102.5), 105); + assert_eq!(clamp_ui_scale_percent(10.0), 75); + assert_eq!(clamp_ui_scale_percent(400.0), 150); +} + +#[test] +fn the_ui_scale_follows_shortcuts_and_the_ctrl_wheel_then_saves_late() { + let now = Instant::now(); + let mut scale = UiScale::new(); + assert_eq!(scale.zoom_in(100, now), Some(105)); + assert_eq!(scale.zoom_out(100, now), Some(95)); + assert_eq!(scale.reset(100, now), None); + assert_eq!(scale.zoom_in(150, now), None); + + // Pixels accumulate up to 80 before a step; up is zoom in. + assert_eq!( + scale.wheel(true, -50.0, WheelDeltaMode::Pixel, 800.0, 100, now), + None + ); + assert_eq!( + scale.wheel(true, -50.0, WheelDeltaMode::Pixel, 800.0, 100, now), + Some(105) + ); + // Lines count 16 px, pages the page height. + assert_eq!( + scale.wheel(true, 5.0, WheelDeltaMode::Line, 800.0, 100, now), + Some(95) + ); + assert_eq!( + scale.wheel(true, -0.1, WheelDeltaMode::Page, 800.0, 100, now), + Some(105) + ); + // Without ctrl the wheel scrolls and the sum resets. + assert_eq!( + scale.wheel(true, -50.0, WheelDeltaMode::Pixel, 800.0, 100, now), + None + ); + assert_eq!( + scale.wheel(false, -50.0, WheelDeltaMode::Pixel, 800.0, 100, now), + None + ); + assert_eq!( + scale.wheel(true, -50.0, WheelDeltaMode::Pixel, 800.0, 100, now), + None + ); + + let start = Instant::now(); + let mut scale = UiScale::new(); + scale.set(100, 120.0, start); + assert_eq!(scale.next_persist(), Some(start + PERSIST_DELAY)); + assert_eq!(scale.take_due(start + Duration::from_millis(100)), None); + assert_eq!(scale.take_due(start + PERSIST_DELAY), Some(120)); + assert_eq!(scale.take_due(start + PERSIST_DELAY), None); + + scale.set(120, 130.0, start); + assert_eq!(scale.destroy(125), Some(125)); + assert_eq!(scale.destroy(125), None); +} + +#[test] +fn the_backdrop_decisions_match_use_theme_application() { + assert!(liquid_backdrop_active(true, RuntimeOs::Windows)); + assert!(!liquid_backdrop_active(true, RuntimeOs::Macos)); + assert!(!liquid_backdrop_active(false, RuntimeOs::Windows)); + assert!(!backdrop_available(RuntimeOs::Linux, false, true)); + assert!(backdrop_available(RuntimeOs::Windows, false, false)); + assert!(!backdrop_available(RuntimeOs::Windows, true, false)); + assert!(backdrop_available(RuntimeOs::Windows, true, true)); +} + +// --------------------------------------------------------------------------- +// Process control + +#[derive(Default)] +struct FakeProcess { + calls: RefCell>, + fail_spawn: bool, +} + +impl ProcessControl for FakeProcess { + fn spawn(&self, program: &Path, args: &[OsString]) -> std::io::Result<()> { + self.calls.borrow_mut().push(format!( + "spawn:{}:{}", + program.display(), + args.iter() + .map(|a| a.to_string_lossy().to_string()) + .collect::>() + .join(",") + )); + if self.fail_spawn { + Err(std::io::Error::other("denied")) + } else { + Ok(()) + } + } + + fn exit(&self, code: i32) { + self.calls.borrow_mut().push(format!("exit:{code}")); + } +} + +#[test] +fn relaunch_releases_spawns_with_the_same_arguments_then_exits() { + let control = FakeProcess::default(); + let args: Vec = vec!["accshift.exe".into(), "--flag".into(), "x y".into()]; + let before = RefCell::new(false); + let error = process::restart(&control, Ok(PathBuf::from("app-binary")), &args, || { + *before.borrow_mut() = true + }); + assert!(error.is_none()); + assert!(*before.borrow()); + assert_eq!( + *control.calls.borrow(), + ["spawn:app-binary:--flag,x y", "exit:0"] + ); + + // A failed spawn is reported and the process still exits. + let failing = FakeProcess { + fail_spawn: true, + ..Default::default() + }; + let error = process::restart(&failing, Ok(PathBuf::from("app-binary")), &args, || {}); + assert_eq!(error.unwrap().to_string(), "denied"); + assert_eq!(failing.calls.borrow().last().unwrap(), "exit:0"); + + // No binary: no spawn, exit anyway. + let control = FakeProcess::default(); + process::restart(&control, Err(std::io::Error::other("gone")), &args, || {}); + assert_eq!(*control.calls.borrow(), ["exit:0"]); + + let control = FakeProcess::default(); + process::exit(&control, 3, || {}); + assert_eq!(*control.calls.borrow(), ["exit:3"]); + + // The real binary resolves to this test executable. + let binary = process::current_binary().unwrap(); + assert!(binary.is_file()); + assert_eq!(process::relaunch_program(&binary), binary); +} + +#[test] +fn the_boot_payload_is_fetched_cached_and_fills_the_registry() { + use accshift_app::lifecycle::boot::BootPayloadCache; + let backend = support::scripted("demo"); + let mut registry = PlatformRegistry::shipped(); + let mut cache = BootPayloadCache::default(); + assert!(cache.get().is_none()); + let runtime_os = cache + .fetch(&backend, &mut registry) + .unwrap() + .runtime_os + .clone(); + assert!(!runtime_os.is_empty()); + assert_eq!(cache.get().unwrap().runtime_os, runtime_os); + assert_eq!(backend.calls("get_boot_payload").len(), 1); + // Every loaded user platform is in the registry afterwards. + for descriptor in &cache.get().unwrap().user_platforms.loaded { + let id = serde_json::to_value(descriptor).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + assert!(registry.definition(&id).is_some(), "{id}"); + } + // A failing call keeps the earlier answer. + backend.push("get_boot_payload", Err("boom")); + assert!(cache.fetch(&backend, &mut registry).is_err()); + assert!(cache.get().is_some()); +} + +#[test] +fn the_other_telemetry_calls_are_fire_and_forget() { + use accshift_app::lifecycle::boot::{ + track_account_add_cancelled, track_account_add_started, track_account_added, + track_operation_failed, + }; + let backend = support::scripted("demo"); + track_account_add_started(&backend, "steam"); + track_account_add_cancelled(&backend, "steam"); + track_account_added(&backend, "riot"); + track_operation_failed(&backend, "switch", "timeout", Some("steam")); + assert_eq!( + backend.calls("telemetry_track_account_add_started").len(), + 1 + ); + assert_eq!( + backend.calls("telemetry_track_account_add_cancelled").len(), + 1 + ); + assert_eq!(backend.calls("telemetry_track_account_added").len(), 1); + assert_eq!(backend.calls("telemetry_track_operation_failed").len(), 1); +} diff --git a/crates/accshift-app/tests/lifecycle_links.rs b/crates/accshift-app/tests/lifecycle_links.rs new file mode 100644 index 00000000..450a4fc3 --- /dev/null +++ b/crates/accshift-app/tests/lifecycle_links.rs @@ -0,0 +1,834 @@ +//! Deep links (`deepLinkUrl.test.ts`, `useDeepLink.test.ts`, the boot.rs +//! tests, the plugin's argument and registry handling) and the single +//! instance service, run with a test identifier only. + +use accshift_app::lifecycle::deep_link::{ + decode_uri_component, deep_link_log_shape, find_account, handle_cli_arguments, is_registered, + on_open_url, parse_deep_link, registered_command, registration_entries, + schemes_need_registration, second_launch, wire_scheme_registration, DeepLinkAction, + DeepLinkAnswer, DeepLinkController, DeepLinkSwitchRequest, DeepLinkView, RegistryValue, + SchemeRegistry, BOOT_TIMEOUT, SCHEMES, +}; +use accshift_app::lifecycle::single_instance::{ + self, dbus_name_and_path, macos_socket_path, parse_windows_payload, windows_names, + windows_payload, Launch, +}; +use accshift_app::platforms::{PlatformAccount, PlatformRegistry, RuntimeOs}; +use accshift_app::settings::{sanitize_settings, AppSettings}; +use accshift_app::stores::StoreContext; +use accshift_app::text::Text; +use serde_json::json; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; +use url::Url; + +// --------------------------------------------------------------------------- +// parseDeepLink (deepLinkUrl.test.ts) + +fn parsed(url: &str) -> Option<(String, String)> { + parse_deep_link(url).map(|r| (r.platform_id, r.account_ref)) +} + +fn account_ref(url: &str) -> Option { + parse_deep_link(url).map(|r| r.account_ref) +} + +#[test] +fn parses_a_switch_link() { + assert_eq!( + parse_deep_link("accshift://switch/steam/76561198000000001"), + Some(DeepLinkSwitchRequest { + platform_id: "steam".into(), + account_ref: "76561198000000001".into() + }) + ); +} + +#[test] +fn is_case_insensitive_on_the_scheme_action_and_platform() { + assert_eq!( + parsed("ACCSHIFT://Switch/Steam/bob"), + Some(("steam".into(), "bob".into())) + ); +} + +#[test] +fn keeps_the_account_ref_case_as_given() { + assert_eq!( + account_ref("accshift://switch/steam/BobTheBuilder").as_deref(), + Some("BobTheBuilder") + ); +} + +#[test] +fn decodes_percent_encoded_segments() { + assert_eq!( + account_ref("accshift://switch/riot/main%20smurf").as_deref(), + Some("main smurf") + ); + assert_eq!( + account_ref("accshift://switch/battle-net/user%40example.com").as_deref(), + Some("user@example.com") + ); +} + +#[test] +fn tolerates_trailing_slash_query_and_fragment() { + for url in [ + "accshift://switch/steam/bob/", + "accshift://switch/steam/bob?source=streamdeck", + "accshift://switch/steam/bob#x", + ] { + assert_eq!(account_ref(url).as_deref(), Some("bob"), "{url}"); + } +} + +#[test] +fn survives_invalid_percent_encoding() { + assert_eq!( + account_ref("accshift://switch/steam/100%").as_deref(), + Some("100%") + ); + assert_eq!(decode_uri_component("100%"), None); + assert_eq!(decode_uri_component("%E9"), None); + assert_eq!( + decode_uri_component("%C3%A9t%C3%A9").as_deref(), + Some("été") + ); +} + +#[test] +fn rejects_other_schemes() { + assert_eq!(parse_deep_link("https://switch/steam/bob"), None); + assert_eq!(parse_deep_link("steam://switch/steam/bob"), None); +} + +#[test] +fn rejects_unknown_actions() { + assert_eq!(parse_deep_link("accshift://launch/steam/bob"), None); +} + +#[test] +fn rejects_missing_or_extra_segments() { + assert_eq!(parse_deep_link("accshift://switch"), None); + assert_eq!(parse_deep_link("accshift://switch/steam"), None); + assert_eq!(parse_deep_link("accshift://switch/steam/bob/extra"), None); +} + +#[test] +fn rejects_empty_and_whitespace_only_refs() { + assert_eq!(parse_deep_link("accshift://switch/steam/%20"), None); + assert_eq!(parse_deep_link(""), None); +} + +#[test] +fn rejects_urls_above_the_maximum_accepted_length() { + let long = format!("accshift://switch/steam/{}", "a".repeat(2049)); + assert_eq!(parse_deep_link(&long), None); + let fits = format!("accshift://switch/steam/{}", "a".repeat(2048 - 24)); + assert!(parse_deep_link(&fits).is_some()); +} + +// --------------------------------------------------------------------------- +// boot.rs: log shape and registration decision (boot/tests.rs, windows_tests.rs) + +#[test] +fn the_log_shape_hides_the_account_and_extras() { + let shape = |url: &str| deep_link_log_shape(&Url::parse(url).unwrap()); + assert_eq!( + shape("accshift://switch/battle-net/user%40example.com"), + "accshift://switch/battle-net/" + ); + assert_eq!( + shape("accshift://switch/steam/login?x=1"), + "accshift://switch/steam/<+extra>" + ); + assert_eq!(shape("accshift://switch"), "accshift://switch"); + assert_eq!( + shape(&format!("accshift://switch/{}/a", "p".repeat(40))), + format!("accshift://switch/{}/", "p".repeat(32)) + ); +} + +#[test] +fn registration_runs_unless_every_scheme_points_here() { + let schemes = vec!["accshift".to_string()]; + assert!(!schemes_need_registration(&schemes, &[true])); + assert!(schemes_need_registration(&schemes, &[false])); + // A failed check arrives as false: fail open. + assert!(schemes_need_registration(&schemes, &[])); + let two = vec!["accshift".to_string(), "other".to_string()]; + assert!(schemes_need_registration(&two, &[true, false])); + assert!(!schemes_need_registration(&two, &[true, true])); +} + +// --------------------------------------------------------------------------- +// The plugin: launch arguments, second launch, open-url, registry + +#[test] +fn only_a_lone_configured_url_argument_is_a_launch_link() { + let lone = handle_cli_arguments(&["accshift.exe", "accshift://switch/steam/a"], SCHEMES); + if cfg!(any(windows, target_os = "linux")) { + assert_eq!(lone.unwrap().as_str(), "accshift://switch/steam/a"); + } else { + assert!(lone.is_none()); + } + assert!(handle_cli_arguments(&["accshift.exe"], SCHEMES).is_none()); + assert!(handle_cli_arguments(&["accshift.exe", "https://example.com"], SCHEMES).is_none()); + assert!(handle_cli_arguments(&["accshift.exe", "not a url"], SCHEMES).is_none()); + assert!(handle_cli_arguments( + &["accshift.exe", "--flag", "accshift://switch/steam/a"], + SCHEMES + ) + .is_none()); +} + +#[test] +fn a_second_launch_logs_forwards_its_link_and_shows_after_boot() { + let launch = second_launch(&["C:\\accshift.exe", "accshift://switch/steam/bob"], true); + assert_eq!(launch.log_source, "backend.single-instance"); + assert_eq!( + launch.log_message, + "Second instance launch redirected to the running app" + ); + assert!(launch.show_window); + assert_eq!( + launch.url.is_some(), + cfg!(any(windows, target_os = "linux")) + ); + assert!(!second_launch(&["C:\\accshift.exe"], false).show_window); + + let urls = [ + Url::parse("accshift://switch/steam/bob").unwrap(), + Url::parse("accshift://switch/riot/x?y").unwrap(), + ]; + let effects = on_open_url(&urls, false); + assert_eq!( + effects.log_details, + "accshift://switch/steam/ accshift://switch/riot/<+extra>" + ); + assert!(effects.track); + assert!(!effects.show_window); +} + +#[derive(Default)] +struct FakeRegistry { + hkcu: BTreeMap<(String, String), String>, + fail_writes: bool, +} + +impl SchemeRegistry for FakeRegistry { + fn set_string(&mut self, key: &str, name: &str, value: &str) -> Result<(), String> { + if self.fail_writes { + return Err("access denied".into()); + } + self.hkcu + .insert((key.to_string(), name.to_string()), value.to_string()); + Ok(()) + } + + fn classes_root_default(&self, key: &str) -> Result, String> { + Ok(self + .hkcu + .get(&(format!("Software\\Classes\\{key}"), String::new())) + .cloned()) + } +} + +const TEST_EXE: &str = "C:\\Program Files\\Accshift\\accshift.exe"; + +#[test] +fn the_registry_entries_are_the_plugin_ones() { + assert_eq!( + registration_entries("accshift", "com.accshift.desktop", TEST_EXE), + [ + RegistryValue { + key: "Software\\Classes\\accshift".into(), + name: String::new(), + value: "URL:com.accshift.desktop protocol".into(), + }, + RegistryValue { + key: "Software\\Classes\\accshift".into(), + name: "URL Protocol".into(), + value: String::new(), + }, + RegistryValue { + key: "Software\\Classes\\accshift\\DefaultIcon".into(), + name: String::new(), + value: format!("{TEST_EXE},0"), + }, + RegistryValue { + key: "Software\\Classes\\accshift\\shell\\open\\command".into(), + name: String::new(), + value: format!("\"{TEST_EXE}\" \"%1\""), + }, + ] + ); + assert_eq!(registered_command("a.exe"), "\"a.exe\" \"%1\""); +} + +#[test] +fn registration_is_skipped_when_it_already_points_here() { + let mut registry = FakeRegistry::default(); + assert!(!is_registered(®istry, "accshift", TEST_EXE).unwrap()); + assert_eq!( + wire_scheme_registration(&mut registry, SCHEMES, "com.accshift.m5test", TEST_EXE), + Ok(true) + ); + assert!(is_registered(®istry, "accshift", TEST_EXE).unwrap()); + assert_eq!(registry.hkcu.len(), 4); + assert_eq!( + wire_scheme_registration(&mut registry, SCHEMES, "com.accshift.m5test", TEST_EXE), + Ok(false) + ); + // Another install path takes the scheme back. + assert_eq!( + wire_scheme_registration( + &mut registry, + SCHEMES, + "com.accshift.m5test", + "D:\\other.exe" + ), + Ok(true) + ); + assert!(is_registered(®istry, "accshift", "D:\\other.exe").unwrap()); + + let mut denied = FakeRegistry { + fail_writes: true, + ..Default::default() + }; + let failure = wire_scheme_registration(&mut denied, SCHEMES, "com.accshift.m5test", TEST_EXE) + .unwrap_err(); + assert_eq!(failure.source, "backend.deep-link"); + assert_eq!(failure.message, "Failed to register accshift:// scheme"); + assert_eq!(failure.details, "access denied"); +} + +/// The real registry, with a throwaway scheme that is never `accshift`, +/// removed at the end. +#[cfg(windows)] +#[test] +fn the_windows_registry_round_trips_a_test_scheme() { + use accshift_app::lifecycle::deep_link::{register_all, WindowsRegistry}; + let scheme = format!("accshift-m5test-{}", std::process::id()); + assert_ne!(scheme, "accshift"); + let exe = "C:\\m5test\\accshift-m5test.exe"; + let mut registry = WindowsRegistry; + struct Cleanup(String); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = windows_registry::CURRENT_USER + .remove_tree(format!("Software\\Classes\\{}", self.0)); + } + } + let _cleanup = Cleanup(scheme.clone()); + assert!(!is_registered(®istry, &scheme, exe).unwrap()); + register_all( + &mut registry, + &[scheme.as_str()], + "com.accshift.m5test", + exe, + ) + .unwrap(); + assert!(is_registered(®istry, &scheme, exe).unwrap()); + assert!(!is_registered(®istry, &scheme, "C:\\elsewhere.exe").unwrap()); + let key = windows_registry::CURRENT_USER + .open(format!("Software\\Classes\\{scheme}")) + .unwrap(); + assert_eq!( + key.get_string("").unwrap(), + "URL:com.accshift.m5test protocol" + ); + assert_eq!(key.get_string("URL Protocol").unwrap(), ""); +} + +// --------------------------------------------------------------------------- +// findAccount and the controller (useDeepLink.test.ts) + +fn account(id: &str, username: &str, display_name: &str) -> PlatformAccount { + PlatformAccount { + id: id.into(), + display_name: display_name.into(), + username: username.into(), + last_login_at_sec: None, + } +} + +#[test] +fn find_account_prefers_the_id_then_a_unique_name() { + let accounts = [ + account("account-1", "alice", "Alice"), + account("account-2", "bob", "Shared"), + account("account-3", "carol", "Shared"), + ]; + assert_eq!( + find_account(&accounts, " ACCOUNT-1 ").unwrap().id, + "account-1" + ); + assert_eq!(find_account(&accounts, "Bob").unwrap().id, "account-2"); + assert_eq!(find_account(&accounts, "alice").unwrap().id, "account-1"); + assert!(find_account(&accounts, "shared").is_none()); + assert!(find_account(&accounts, "nobody").is_none()); +} + +fn settings(enabled: bool) -> AppSettings { + sanitize_settings( + &json!({"deepLinksEnabled": enabled, "enabledPlatforms": ["steam", "riot"]}), + &StoreContext::shipped(), + ) +} + +/// The app as the controller sees it, answering each action like the TS +/// mocks, and recording them. +struct App { + settings: AppSettings, + registry: PlatformRegistry, + active_tab: String, + /// What `getAccounts` returns per tab once loaded. + loaded: BTreeMap>, + visible: Vec, + confirm: bool, + switched: bool, + events: Vec, + boot_ready: bool, + pin_locked: bool, + /// changeTab does not load the new tab (the TS "has not read it yet" case). + lazy_tab_load: bool, +} + +impl App { + fn new(active_tab: &str) -> Self { + let alice = account("account-1", "alice", "Alice"); + Self { + settings: settings(true), + registry: PlatformRegistry::shipped(), + active_tab: active_tab.into(), + loaded: BTreeMap::from([("steam".to_string(), vec![alice.clone()])]), + visible: if active_tab == "steam" { + vec![alice] + } else { + Vec::new() + }, + confirm: true, + switched: true, + events: Vec::new(), + boot_ready: true, + pin_locked: false, + lazy_tab_load: false, + } + } + + fn view(&self) -> DeepLinkView<'_> { + DeepLinkView { + boot_ready: self.boot_ready, + pin_locked: self.pin_locked, + settings: &self.settings, + registry: &self.registry, + runtime_os: RuntimeOs::Windows, + active_tab: &self.active_tab, + loader_loading: false, + accounts: &self.visible, + } + } + + /// Runs one URL to the end. + fn run(&mut self, controller: &mut DeepLinkController, url: &str) { + let now = Instant::now(); + controller.on_urls(&[url], now); + let mut actions = controller.poll(&self.view(), now); + let mut guard = 0; + while let Some(action) = actions.first().cloned() { + actions.remove(0); + guard += 1; + assert!(guard < 50, "the controller loops"); + let answer = match action { + DeepLinkAction::Toast(text) => { + self.events.push(format!("toast:{}", text_key(&text))); + continue; + } + DeepLinkAction::LoadPlatformAccounts(id) => { + self.events.push(format!("loadPlatform:{id}")); + DeepLinkAnswer::PlatformAccounts( + self.loaded.get(&id).cloned().unwrap_or_default(), + ) + } + DeepLinkAction::LoadAccounts => { + self.events.push("loadAccounts".into()); + self.visible = self + .loaded + .get(&self.active_tab) + .cloned() + .unwrap_or_default(); + DeepLinkAnswer::AccountsLoaded + } + DeepLinkAction::Confirm { + account, + platform_name, + } => { + self.events + .push(format!("confirm:{}:{platform_name}", account.id)); + DeepLinkAnswer::Confirmed(self.confirm) + } + DeepLinkAction::ChangeTab(tab) => { + self.events.push(format!("tab:{tab}")); + self.active_tab = tab.clone(); + self.visible = if self.lazy_tab_load { + Vec::new() + } else { + self.loaded.get(&tab).cloned().unwrap_or_default() + }; + DeepLinkAnswer::TabChanged + } + DeepLinkAction::Switch(account) => { + self.events.push(format!("switch:{}", account.id)); + DeepLinkAnswer::Switched(self.switched) + } + }; + let next = controller.answer(answer, &self.view(), now); + actions.extend(next); + } + assert!(!controller.is_busy()); + } +} + +fn text_key(text: &Text) -> String { + format!("{text:?}") +} + +fn started() -> DeepLinkController { + let mut controller = DeepLinkController::new(); + controller.start(); + controller +} + +#[test] +fn success_is_not_announced_when_the_switch_returns_false() { + let mut app = App::new("steam"); + app.switched = false; + app.run(&mut started(), "accshift://switch/steam/account-1"); + assert_eq!(app.events, ["confirm:account-1:Steam", "switch:account-1"]); +} + +#[test] +fn success_is_announced_only_after_a_confirmed_switch() { + let mut app = App::new("steam"); + app.run(&mut started(), "accshift://switch/steam/account-1"); + assert_eq!(app.events.len(), 3); + assert!( + app.events[2].contains("toast.deepLinkSwitched"), + "{:?}", + app.events + ); + assert!(app.events[2].contains("Alice"), "{:?}", app.events); +} + +#[test] +fn a_stopped_controller_drops_links_and_a_restart_listens_again() { + let mut controller = DeepLinkController::new(); + controller.start(); + controller.stop(); + controller.on_urls(&["accshift://switch/steam/account-1"], Instant::now()); + assert!(!controller.is_busy()); + controller.start(); + let mut app = App::new("steam"); + app.run(&mut controller, "accshift://switch/steam/account-1"); + assert_eq!(app.events.len(), 3); + // Links that came before `start` are not replayed. + assert!(!controller.is_busy()); +} + +#[test] +fn it_asks_before_it_changes_the_visible_tab() { + let mut app = App::new("riot"); + app.run(&mut started(), "accshift://switch/steam/account-1"); + assert_eq!( + &app.events[..4], + [ + "loadPlatform:steam", + "confirm:account-1:Steam", + "tab:steam", + "switch:account-1", + ] + ); +} + +#[test] +fn it_loads_the_new_tab_itself_when_the_tab_change_has_not_read_it_yet() { + let mut app = App::new("riot"); + app.lazy_tab_load = true; + app.run(&mut started(), "accshift://switch/steam/account-1"); + assert_eq!( + app.events.iter().filter(|e| *e == "loadAccounts").count(), + 1 + ); + assert!(app.events.contains(&"switch:account-1".to_string())); + assert!(!app + .events + .iter() + .any(|e| e.contains("toast.deepLinkAccountNotFound"))); +} + +#[test] +fn it_leaves_the_tab_alone_when_the_switch_is_declined() { + let mut app = App::new("riot"); + app.confirm = false; + app.run(&mut started(), "accshift://switch/steam/account-1"); + assert_eq!( + app.events, + ["loadPlatform:steam", "confirm:account-1:Steam"] + ); +} + +#[test] +fn the_gates_toast_and_stop() { + let toasts = |app: &mut App, url: &str| { + app.events.clear(); + app.run(&mut started(), url); + app.events.clone() + }; + let mut app = App::new("steam"); + assert!(toasts(&mut app, "accshift://nope")[0].contains("toast.deepLinkInvalid")); + assert!(toasts(&mut app, "accshift://switch/nowhere/a")[0] + .contains("toast.deepLinkUnknownPlatform")); + assert!(toasts(&mut app, "accshift://switch/steam/ghost") + .iter() + .any(|e| e.contains("toast.deepLinkAccountNotFound"))); + + app.settings = sanitize_settings( + &json!({"deepLinksEnabled": true, "enabledPlatforms": ["riot"]}), + &StoreContext::shipped(), + ); + assert!( + toasts(&mut app, "accshift://switch/steam/a")[0].contains("toast.deepLinkPlatformDisabled") + ); + + app.settings = settings(false); + let events = toasts(&mut app, "accshift://switch/steam/a"); + assert!(events[0].contains("toast.deepLinkDisabled"), "{events:?}"); + // Disabled links do not even toast an invalid URL. + assert!(toasts(&mut app, "accshift://nope").is_empty()); + + let mut locked = App::new("steam"); + locked.pin_locked = true; + assert!(toasts(&mut locked, "accshift://switch/steam/account-1").is_empty()); +} + +#[test] +fn a_link_waits_for_boot_and_the_latest_one_queues() { + let mut app = App::new("steam"); + app.boot_ready = false; + let mut controller = started(); + let start = Instant::now(); + controller.on_urls( + &[ + "accshift://switch/steam/first", + "accshift://switch/steam/second", + "accshift://switch/steam/account-1", + ], + start, + ); + assert!(controller.poll(&app.view(), start).is_empty()); + assert!(controller.is_busy()); + // Boot never comes: the URL step goes on after 20 s, the switch step + // gives up 20 s later, then the queued (latest) link runs. + let late = start + BOOT_TIMEOUT + Duration::from_millis(1); + assert!(controller.poll(&app.view(), late).is_empty()); + let later = late + BOOT_TIMEOUT + Duration::from_millis(1); + assert!(controller.poll(&app.view(), later).is_empty()); + app.boot_ready = true; + let actions = controller.poll(&app.view(), later); + assert!( + matches!(&actions[..], [DeepLinkAction::Confirm { account, .. }] if account.id == "account-1"), + "{actions:?}" + ); +} + +#[test] +fn a_failed_platform_load_ends_the_link_quietly_and_runs_the_queued_one() { + let app = App::new("riot"); + let mut controller = started(); + let now = Instant::now(); + controller.on_urls(&["accshift://switch/steam/first"], now); + assert_eq!( + controller.poll(&app.view(), now), + vec![DeepLinkAction::LoadPlatformAccounts("steam".into())] + ); + controller.on_urls(&["accshift://switch/riot/second"], now); + // `handleUrl`'s catch: no toast, the queued link starts on its tab. + let actions = controller.fail(&app.view(), now); + assert!( + matches!(&actions[..], [DeepLinkAction::LoadAccounts]), + "{actions:?}" + ); + assert!(controller.is_busy()); + // Nothing in flight: a stray failure changes nothing. + let mut idle = started(); + assert!(idle.fail(&app.view(), now).is_empty()); + assert!(!idle.is_busy()); +} + +// --------------------------------------------------------------------------- +// Single instance + +#[test] +fn the_names_match_the_plugin_for_the_app_identifier() { + // Strings only: no mutex or window is created for the real identifier. + assert_eq!( + windows_names("com.accshift.desktop"), + ( + "com.accshift.desktop-sic".into(), + "com.accshift.desktop-siw".into(), + "com.accshift.desktop-sim".into() + ) + ); + assert_eq!( + dbus_name_and_path("com.accshift.desktop"), + ( + "com.accshift.desktop.SingleInstance".into(), + "/com/accshift/desktop/SingleInstance".into() + ) + ); + assert_eq!( + dbus_name_and_path("my-app.test"), + ( + "my-app.test.SingleInstance".into(), + "/my_app/test/SingleInstance".into() + ) + ); + assert_eq!( + macos_socket_path("com.accshift.desktop"), + std::path::PathBuf::from("/tmp/com_accshift_desktop_si.sock") + ); +} + +#[test] +fn the_windows_payload_round_trips() { + let args = vec![ + "C:\\accshift.exe".to_string(), + "accshift://switch/steam/a".to_string(), + ]; + let payload = windows_payload(&args, "C:\\Users\\me"); + assert_eq!( + payload, + "C:\\Users\\me|C:\\accshift.exe|accshift://switch/steam/a\0" + ); + let (cwd, parsed) = parse_windows_payload(payload.as_bytes()); + assert_eq!(cwd, "C:\\Users\\me"); + assert_eq!(parsed, args); + // A `|` inside an argument splits it, as upstream. + let (_, split) = parse_windows_payload(b"C:\\|a|b|c\0junk"); + assert_eq!(split, ["a", "b", "c"]); +} + +#[cfg(any(windows, target_os = "macos"))] +fn test_identifier(tag: &str) -> String { + let id = format!("com.accshift.m5test.{tag}.{}", std::process::id()); + assert_ne!(id, "com.accshift.desktop"); + id +} + +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn a_second_instance_forwards_its_arguments_to_the_first() { + use accshift_app::lifecycle::single_instance::SecondInstance; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let identifier = test_identifier("inproc"); + let woken = Arc::new(AtomicUsize::new(0)); + let wake = { + let woken = woken.clone(); + Arc::new(move || { + woken.fetch_add(1, Ordering::SeqCst); + }) + }; + let first = + match single_instance::acquire(&identifier, &["first.exe".into()], "C:\\one", Some(wake)) { + Launch::Primary(service) => service, + other => panic!("first launch: {other:?}"), + }; + let args = vec![ + "second.exe".to_string(), + "accshift://switch/steam/bob".to_string(), + ]; + let second = single_instance::acquire(&identifier, &args, "C:\\two", None); + assert!(matches!(second, Launch::Forwarded), "{second:?}"); + let received = first + .receiver() + .recv_timeout(Duration::from_secs(10)) + .expect("forwarded launch"); + assert_eq!( + received, + SecondInstance { + args, + cwd: "C:\\two".into() + } + ); + assert_eq!(woken.load(Ordering::SeqCst), 1); + assert!(first.try_recv().is_none()); + + // Releasing it (the plugin's `destroy`) lets a new primary start. + drop(first); + match single_instance::acquire(&identifier, &["third.exe".into()], "", None) { + Launch::Primary(_) => {} + other => panic!("after release: {other:?}"), + } +} + +/// The other process of [`a_second_process_reaches_the_first`]; it only runs +/// when that test starts it. +#[test] +#[ignore = "child process of a_second_process_reaches_the_first"] +fn single_instance_child() { + let Ok(identifier) = std::env::var("ACCSHIFT_M5_SI_ID") else { + return; + }; + let args = vec![ + "child.exe".to_string(), + "accshift://switch/riot/main%20smurf".to_string(), + ]; + match single_instance::acquire(&identifier, &args, "child-cwd", None) { + Launch::Forwarded => println!("M5-CHILD:forwarded"), + other => println!("M5-CHILD:{other:?}"), + } +} + +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn a_second_process_reaches_the_first() { + let identifier = test_identifier("process"); + let first = match single_instance::acquire(&identifier, &["first.exe".into()], "", None) { + Launch::Primary(service) => service, + other => panic!("first launch: {other:?}"), + }; + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "single_instance_child", + "--ignored", + "--nocapture", + "--test-threads=1", + ]) + .env("ACCSHIFT_M5_SI_ID", &identifier) + .output() + .expect("child test process"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "{stdout}"); + assert!(stdout.contains("M5-CHILD:forwarded"), "{stdout}"); + let received = first + .receiver() + .recv_timeout(Duration::from_secs(10)) + .unwrap(); + assert_eq!( + received.args, + ["child.exe", "accshift://switch/riot/main%20smurf"] + ); + assert_eq!(received.cwd, "child-cwd"); + drop(first); +} + +#[test] +fn the_current_arguments_and_folder_are_read() { + assert!(!single_instance::current_args().is_empty()); + assert!(!single_instance::current_cwd().is_empty()); +} diff --git a/crates/accshift-app/tests/lifecycle_updater.rs b/crates/accshift-app/tests/lifecycle_updater.rs new file mode 100644 index 00000000..819e8255 --- /dev/null +++ b/crates/accshift-app/tests/lifecycle_updater.rs @@ -0,0 +1,1205 @@ +//! The updater against a local HTTP server on 127.0.0.1: a `latest.json` +//! and an artifact signed by a key generated here, as `tauri signer` does. +//! The install side effects go to a recording host; no installer runs. + +use accshift_app::lifecycle::update_flow::{run_command, ApplyHost, CommandResult, UpdateCommand}; +use accshift_app::lifecycle::updater::{ + bundle_type, bundle_type_from_marker, escape_msi_property_arg, escape_nsis_current_exe_arg, + fallback_targets, installer_for_bundle_type, parse_manifest, updater_arch, updater_os, + verify_signature, BundleType, DownloadEvent, Installer, InstallerHost, RemoteReleaseInner, + Update, Updater, UpdaterConfig, UpdaterError, WindowsUpdateInstallMode, WindowsUpdaterType, + PUBKEY, UPDATER_USER_AGENT, +}; +use base64::Engine; +use semver::Version; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; +use url::Url; + +// --------------------------------------------------------------------------- +// Local HTTP server + +#[derive(Debug, Clone)] +struct Request { + path: String, + headers: HashMap, +} + +type Route = dyn Fn(&str) -> (u16, Vec) + Send + Sync; + +/// One-request-per-connection HTTP/1.1 server; stops on drop. +struct Server { + base: String, + requests: Arc>>, + stop: Arc, + thread: Option>, +} + +impl Server { + fn start(route: impl Fn(&str) -> (u16, Vec) + Send + Sync + 'static) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1"); + let base = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let route: Arc = Arc::new(route); + let thread = { + let requests = requests.clone(); + let stop = stop.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + if stop.load(Ordering::SeqCst) { + break; + } + if let Ok(stream) = stream { + serve(stream, &route, &requests); + } + } + }) + }; + Self { + base, + requests, + stop, + thread: Some(thread), + } + } + + fn url(&self, path: &str) -> String { + format!("{}{path}", self.base) + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + let _ = TcpStream::connect(self.base.trim_start_matches("http://")); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn serve(mut stream: TcpStream, route: &Arc, requests: &Mutex>) { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut raw = Vec::new(); + let mut buffer = [0u8; 4096]; + while !raw.windows(4).any(|window| window == b"\r\n\r\n") { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => return, + Ok(read) => raw.extend_from_slice(&buffer[..read]), + } + } + let text = String::from_utf8_lossy(&raw).to_string(); + let mut lines = text.split("\r\n"); + let path = lines + .next() + .and_then(|line| line.split(' ').nth(1)) + .unwrap_or_default() + .to_string(); + let headers = lines + .take_while(|line| !line.is_empty()) + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.trim().to_lowercase(), value.trim().to_string())) + .collect(); + requests.lock().unwrap().push(Request { + path: path.clone(), + headers, + }); + let (status, body) = route(&path); + let reason = match status { + 200 => "OK", + 204 => "No Content", + 404 => "Not Found", + _ => "Internal Server Error", + }; + let head = if status == 204 { + format!("HTTP/1.1 {status} {reason}\r\nConnection: close\r\n\r\n") + } else { + format!( + "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + }; + let _ = stream.write_all(head.as_bytes()); + if status != 204 { + let _ = stream.write_all(&body); + } + let _ = stream.flush(); +} + +// --------------------------------------------------------------------------- +// Signing, as `tauri signer sign`: base64 of the key box and of the `.sig`. + +struct TestKey { + pair: minisign::KeyPair, +} + +impl TestKey { + fn new() -> Self { + Self { + pair: minisign::KeyPair::generate_unencrypted_keypair().expect("keypair"), + } + } + + fn pubkey(&self) -> String { + let text = self.pair.pk.to_box().unwrap().to_string(); + base64::engine::general_purpose::STANDARD.encode(text) + } + + fn sign(&self, data: &[u8]) -> String { + let signature = minisign::sign( + Some(&self.pair.pk), + &self.pair.sk, + std::io::Cursor::new(data), + Some("timestamp:0\tfile:accshift-m5test.exe"), + Some("signature from tauri secret key"), + ) + .expect("sign"); + base64::engine::general_purpose::STANDARD.encode(signature.to_string()) + } +} + +/// An NSIS setup as `infer` sees it: `MZ` and anything. +fn fake_setup() -> Vec { + let mut bytes = b"MZ".to_vec(); + bytes.extend((0..200_000u32).map(|i| (i % 251) as u8)); + bytes +} + +fn os_arch() -> (String, String) { + ( + updater_os().unwrap().to_string(), + updater_arch().unwrap().to_string(), + ) +} + +fn config( + server: &Server, + pubkey: &str, + current: &str, + bundle: Option, +) -> UpdaterConfig { + UpdaterConfig { + pubkey: pubkey.to_string(), + endpoints: vec![Url::parse(&server.url("/latest.json")).unwrap()], + install_mode: WindowsUpdateInstallMode::Passive, + installer_args: Vec::new(), + current_version: Version::parse(current).unwrap(), + app_name: "accshift-m5test".into(), + target: None, + timeout: Some(Duration::from_secs(20)), + headers: Vec::new(), + current_exe_args: vec!["accshift.exe".into(), "--flag".into(), "some space".into()], + restart_after_install: true, + bundle_type: bundle, + executable_path: None, + allow_downgrades: false, + } +} + +/// Serves `manifest` at `/latest.json` and `artifact` at `/artifact`. +fn release_server( + manifest: impl Fn(&str) -> Value + Send + Sync + 'static, + artifact: Vec, +) -> Server { + Server::start(move |path| { + if path.starts_with("/latest.json") { + (200, manifest(path).to_string().into_bytes()) + } else if path.starts_with("/artifact") { + (200, artifact.clone()) + } else { + (404, Vec::new()) + } + }) +} + +fn static_manifest(version: &str, base: &str, platforms: &[(&str, &str)]) -> Value { + let platforms: serde_json::Map = platforms + .iter() + .map(|(key, signature)| { + ( + key.to_string(), + json!({"url": format!("{base}/artifact/{key}"), "signature": signature}), + ) + }) + .collect(); + json!({ + "version": version, + "notes": "Bug fixes", + "pub_date": "2026-10-01T12:00:00Z", + "platforms": platforms, + }) +} + +/// A server whose base URL is known before the manifest is built. +fn server_with(build: impl Fn(&str) -> Value + Send + Sync + 'static, artifact: Vec) -> Server { + let base = Arc::new(Mutex::new(String::new())); + let server = { + let base = base.clone(); + release_server(move |_| build(&base.lock().unwrap()), artifact) + }; + *base.lock().unwrap() = server.base.clone(); + server +} + +fn check(config: UpdaterConfig) -> Result, UpdaterError> { + Updater::new(config).expect("updater builds").check() +} + +// --------------------------------------------------------------------------- +// Check and download + +#[test] +fn a_good_signature_is_accepted_and_progress_is_reported() { + let key = TestKey::new(); + let artifact = fake_setup(); + let signature = key.sign(&artifact); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let server = server_with( + move |base| static_manifest("v1.2.0", base, &[(platform.as_str(), signature.as_str())]), + artifact.clone(), + ); + + let update = check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .expect("1.2.0 is newer than 1.0.5"); + assert_eq!(update.version, "1.2.0"); + assert_eq!(update.current_version, "1.0.5"); + assert_eq!(update.body.as_deref(), Some("Bug fixes")); + assert_eq!( + update.date_rfc3339().as_deref(), + Some("2026-10-01T12:00:00Z") + ); + assert_eq!(update.target, os); + assert_eq!(update.raw_json["version"], "v1.2.0"); + + let mut events = Vec::new(); + let bytes = update + .download_with_events(|event| events.push(event)) + .unwrap(); + assert_eq!(bytes, artifact); + assert_eq!( + events.first(), + Some(&DownloadEvent::Started { + content_length: Some(artifact.len() as u64) + }) + ); + assert_eq!(events.last(), Some(&DownloadEvent::Finished)); + let received: usize = events + .iter() + .map(|event| match event { + DownloadEvent::Progress { chunk_length } => *chunk_length, + _ => 0, + }) + .sum(); + assert_eq!(received, artifact.len()); + + let requests = server.requests(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].headers["user-agent"], UPDATER_USER_AGENT); + assert_eq!(requests[0].headers["accept"], "application/json"); + assert_eq!(requests[1].headers["accept"], "application/octet-stream"); + assert_eq!(requests[1].path, format!("/artifact/{os}-{arch}")); +} + +#[test] +fn a_bad_signature_is_refused() { + let key = TestKey::new(); + let artifact = fake_setup(); + let signature = key.sign(b"some other file"); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let server = server_with( + move |base| static_manifest("1.2.0", base, &[(platform.as_str(), signature.as_str())]), + artifact, + ); + let update = check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .unwrap(); + let error = update.download(|_, _| {}, || {}).unwrap_err(); + assert!(matches!(error, UpdaterError::Minisign(_)), "{error:?}"); +} + +#[test] +fn a_signature_from_another_key_is_refused() { + let signer = TestKey::new(); + let trusted = TestKey::new(); + let artifact = fake_setup(); + let signature = signer.sign(&artifact); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let server = server_with( + move |base| static_manifest("1.2.0", base, &[(platform.as_str(), signature.as_str())]), + artifact, + ); + let update = check(config(&server, &trusted.pubkey(), "1.0.5", None)) + .unwrap() + .unwrap(); + let error = update.download(|_, _| {}, || {}).unwrap_err(); + assert!(matches!(error, UpdaterError::Minisign(_)), "{error:?}"); +} + +#[test] +fn a_missing_or_undecodable_signature_is_refused() { + let key = TestKey::new(); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + + // Static shape without `signature`: the manifest does not parse. + let server = server_with( + { + let platform = platform.clone(); + move |base| { + json!({ + "version": "1.2.0", + "platforms": { platform.as_str(): { "url": format!("{base}/artifact") } } + }) + } + }, + fake_setup(), + ); + let error = check(config(&server, &key.pubkey(), "1.0.5", None)).unwrap_err(); + assert!( + matches!(&error, UpdaterError::Serialization(text) if text.contains("missing field `signature`")), + "{error:?}" + ); + + // Dynamic shape without `signature`. + let server = server_with( + |base| json!({"version": "1.2.0", "url": format!("{base}/artifact")}), + fake_setup(), + ); + let error = check(config(&server, &key.pubkey(), "1.0.5", None)).unwrap_err(); + assert_eq!( + error.to_string(), + "the `signature` field was not set on the updater response" + ); + + // An empty signature string: refused at download. + let server = server_with( + move |base| static_manifest("1.2.0", base, &[(platform.as_str(), "")]), + fake_setup(), + ); + let update = check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .unwrap(); + let error = update.download(|_, _| {}, || {}).unwrap_err(); + assert!(matches!(error, UpdaterError::Minisign(_)), "{error:?}"); + + // Not base64 at all. + let error = verify_signature(b"data", "%%%", &key.pubkey()).unwrap_err(); + assert!(matches!(error, UpdaterError::Base64(_)), "{error:?}"); +} + +#[test] +fn an_older_or_equal_version_is_ignored() { + let key = TestKey::new(); + let artifact = fake_setup(); + let signature = key.sign(&artifact); + let (os, arch) = os_arch(); + for (remote, current) in [("1.0.5", "1.0.5"), ("1.0.4", "1.0.5"), ("0.9.0", "1.0.5")] { + let platform = format!("{os}-{arch}"); + let signature = signature.clone(); + let server = server_with( + move |base| static_manifest(remote, base, &[(platform.as_str(), signature.as_str())]), + artifact.clone(), + ); + assert!( + check(config(&server, &key.pubkey(), current, None)) + .unwrap() + .is_none(), + "{remote} against {current}" + ); + // `allowDowngrades`: anything different counts. + let mut downgrade = config(&server, &key.pubkey(), current, None); + downgrade.allow_downgrades = true; + assert_eq!(check(downgrade).unwrap().is_some(), remote != current); + } +} + +#[test] +fn a_prerelease_and_a_build_suffix_compare_as_semver() { + let key = TestKey::new(); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let signature = key.sign(b"x"); + let server = server_with( + move |base| { + static_manifest( + "1.1.0-beta.1", + base, + &[(platform.as_str(), signature.as_str())], + ) + }, + Vec::new(), + ); + assert!(check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .is_some()); + assert!(check(config(&server, &key.pubkey(), "1.1.0", None)) + .unwrap() + .is_none()); +} + +#[test] +fn the_installer_key_wins_over_the_plain_platform_key() { + let key = TestKey::new(); + let (os, arch) = os_arch(); + let with_installer = format!("{os}-{arch}-nsis"); + let plain = format!("{os}-{arch}"); + let server = server_with( + { + let (with_installer, plain) = (with_installer.clone(), plain.clone()); + move |base| { + static_manifest( + "1.2.0", + base, + &[(with_installer.as_str(), "c2ln"), (plain.as_str(), "c2ln")], + ) + } + }, + Vec::new(), + ); + let update = check(config( + &server, + &key.pubkey(), + "1.0.5", + Some(BundleType::Nsis), + )) + .unwrap() + .unwrap(); + assert_eq!( + update.download_url.path(), + format!("/artifact/{with_installer}") + ); + + // An unpatched binary (no bundle type) asks for the plain key. + let update = check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .unwrap(); + assert_eq!(update.download_url.path(), format!("/artifact/{plain}")); + + // An MSI build falls back to the plain key when its own is missing. + let update = check(config( + &server, + &key.pubkey(), + "1.0.5", + Some(BundleType::Msi), + )) + .unwrap() + .unwrap(); + assert_eq!(update.download_url.path(), format!("/artifact/{plain}")); +} + +#[test] +fn every_platform_key_of_the_release_matrix_resolves() { + // The keys `tauri-action` writes into accshift's latest.json. + let keys = [ + "windows-x86_64", + "windows-x86_64-nsis", + "windows-x86_64-msi", + "windows-aarch64-nsis", + "darwin-aarch64", + "darwin-aarch64-app", + "darwin-x86_64", + "darwin-x86_64-app", + "linux-x86_64", + "linux-x86_64-appimage", + "linux-x86_64-deb", + "linux-x86_64-rpm", + ]; + let manifest = static_manifest( + "1.2.0", + "https://example.invalid", + &keys.iter().map(|key| (*key, "c2ln")).collect::>(), + ); + let release = parse_manifest(manifest).unwrap(); + let mut sorted = keys.map(String::from).to_vec(); + sorted.sort(); + assert_eq!(release.platform_keys(), sorted); + for (os, arch, installer, expected) in [ + ( + "windows", + "x86_64", + Some(Installer::Nsis), + "windows-x86_64-nsis", + ), + ( + "windows", + "x86_64", + Some(Installer::Msi), + "windows-x86_64-msi", + ), + ("windows", "x86_64", None, "windows-x86_64"), + ( + "windows", + "aarch64", + Some(Installer::Nsis), + "windows-aarch64-nsis", + ), + ( + "darwin", + "aarch64", + Some(Installer::App), + "darwin-aarch64-app", + ), + ("darwin", "x86_64", None, "darwin-x86_64"), + ( + "linux", + "x86_64", + Some(Installer::AppImage), + "linux-x86_64-appimage", + ), + ("linux", "x86_64", Some(Installer::Deb), "linux-x86_64-deb"), + ("linux", "x86_64", Some(Installer::Rpm), "linux-x86_64-rpm"), + ] { + let targets = fallback_targets(os, arch, installer); + let found = targets + .iter() + .find(|target| release.download_url(target).is_ok()) + .unwrap(); + assert_eq!(found, expected); + assert_eq!( + release.download_url(found).unwrap().as_str(), + format!("https://example.invalid/artifact/{expected}") + ); + } + assert_eq!( + fallback_targets("windows", "x86_64", Some(Installer::Nsis)), + ["windows-x86_64-nsis", "windows-x86_64"] + ); + assert_eq!( + fallback_targets("linux", "riscv64", None), + ["linux-riscv64"] + ); +} + +#[test] +fn a_manifest_without_this_platform_fails_even_when_nothing_is_newer() { + let key = TestKey::new(); + let server = server_with( + |base| static_manifest("1.0.0", base, &[("plan9-mips", "c2ln")]), + Vec::new(), + ); + let (os, arch) = os_arch(); + let error = check(config( + &server, + &key.pubkey(), + "1.0.5", + Some(BundleType::Nsis), + )) + .unwrap_err(); + assert_eq!( + error.to_string(), + format!( + "None of the fallback platforms `[\"{os}-{arch}-nsis\", \"{os}-{arch}\"]` were found in the response `platforms` object" + ) + ); + + let mut custom = config(&server, &key.pubkey(), "1.0.5", None); + custom.target = Some("custom-target".into()); + let error = check(custom).unwrap_err(); + assert_eq!( + error.to_string(), + "the platform `custom-target` was not found in the response `platforms` object" + ); +} + +#[test] +fn the_dynamic_shape_and_the_name_alias_parse() { + let release = parse_manifest(json!({ + "name": "v2.0.0", + "url": "https://example.invalid/setup.exe", + "signature": "c2ln", + })) + .unwrap(); + assert_eq!(release.version, Version::new(2, 0, 0)); + assert!(matches!(release.data, RemoteReleaseInner::Dynamic(_))); + assert_eq!( + release.download_url("anything").unwrap().as_str(), + "https://example.invalid/setup.exe" + ); + assert!(release.platform_keys().is_empty()); + + let error = parse_manifest(json!({"version": "1.0.0", "signature": "c2ln"})).unwrap_err(); + assert_eq!( + error.to_string(), + "the `url` field was not set on the updater response" + ); + let error = parse_manifest(json!({"version": "1.0.0", "pub_date": "yesterday", "url": "https://a.invalid", "signature": "c2ln"})).unwrap_err(); + assert!( + error + .to_string() + .starts_with("invalid value for `pub_date`: "), + "{error}" + ); + let error = + parse_manifest(json!({"version": "one", "url": "https://a.invalid", "signature": "c2ln"})) + .unwrap_err(); + assert!( + error + .to_string() + .contains("unexpected character 'o' while parsing major version number"), + "{error}" + ); + let error = parse_manifest(json!({"url": "https://a.invalid"})).unwrap_err(); + assert!( + error.to_string().contains("missing field `version`"), + "{error}" + ); +} + +#[test] +fn a_204_means_no_update() { + let server = Server::start(|_| (204, Vec::new())); + let key = TestKey::new(); + assert!(check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .is_none()); +} + +#[test] +fn a_failing_endpoint_falls_through_to_the_next_one() { + let key = TestKey::new(); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let good = server_with( + move |base| static_manifest("1.2.0", base, &[(platform.as_str(), "c2ln")]), + Vec::new(), + ); + let broken = Server::start(|_| (500, b"oops".to_vec())); + let mut both = config(&good, &key.pubkey(), "1.0.5", None); + both.endpoints = vec![ + Url::parse(&broken.url("/latest.json")).unwrap(), + Url::parse(&good.url("/latest.json")).unwrap(), + ]; + assert!(check(both).unwrap().is_some()); + assert_eq!(broken.requests().len(), 1); + + // Only failing statuses: no last error is kept, so the release is "not found". + let missing = Server::start(|_| (404, Vec::new())); + let error = check(config(&missing, &key.pubkey(), "1.0.5", None)).unwrap_err(); + assert_eq!( + error.to_string(), + "Could not fetch a valid release JSON from the remote" + ); +} + +#[test] +fn an_unreachable_endpoint_is_a_request_error() { + // A port nothing listens on: bind, read the port, close. + let port = { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + let server = Server::start(|_| (404, Vec::new())); + let key = TestKey::new(); + let mut unreachable = config(&server, &key.pubkey(), "1.0.5", None); + unreachable.endpoints = + vec![Url::parse(&format!("http://127.0.0.1:{port}/latest.json")).unwrap()]; + let error = check(unreachable).unwrap_err(); + assert!( + matches!(&error, UpdaterError::Reqwest(text) if text.contains("error sending request")), + "{error:?}" + ); +} + +#[test] +fn a_failed_download_reports_its_status() { + let key = TestKey::new(); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let base = Arc::new(Mutex::new(String::new())); + let server = { + let base = base.clone(); + Server::start(move |path| { + if path.starts_with("/latest.json") { + let manifest = static_manifest( + "1.2.0", + &base.lock().unwrap(), + &[(platform.as_str(), "c2ln")], + ); + (200, manifest.to_string().into_bytes()) + } else { + (500, Vec::new()) + } + }) + }; + *base.lock().unwrap() = server.base.clone(); + let update = check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .unwrap(); + let error = update.download(|_, _| {}, || {}).unwrap_err(); + assert_eq!( + error.to_string(), + "`Download request failed with status: 500 Internal Server Error`" + ); +} + +#[test] +fn the_endpoint_variables_are_replaced_raw_and_encoded() { + let server = Server::start(|_| (204, Vec::new())); + let key = TestKey::new(); + let mut templated = config( + &server, + &key.pubkey(), + "1.0.5+build.7", + Some(BundleType::Nsis), + ); + templated.endpoints = vec![Url::parse( + &server.url("/{{target}}/{{arch}}/{{bundle_type}}/latest.json?v={{current_version}}"), + ) + .unwrap()]; + // The path braces are percent-encoded by the URL parser, the query ones are not. + assert!(templated.endpoints[0] + .as_str() + .contains("%7B%7Btarget%7D%7D")); + assert!(check(templated).unwrap().is_none()); + let (os, arch) = os_arch(); + assert_eq!( + server.requests()[0].path, + format!("/{os}/{arch}/nsis/latest.json?v=1.0.5%2Bbuild.7") + ); + + let updater = Updater::new(config(&server, &key.pubkey(), "1.0.5", None)).unwrap(); + let url = updater + .endpoint_url( + &Url::parse("https://example.invalid/{{bundle_type}}/x").unwrap(), + "windows", + ) + .unwrap(); + assert_eq!(url.as_str(), "https://example.invalid/unknown/x"); +} + +#[test] +fn the_config_is_validated_and_matches_tauri_conf() { + let accshift = UpdaterConfig::accshift(); + assert_eq!( + accshift.endpoints[0].as_str(), + "https://github.com/klNuno/accshift/releases/latest/download/latest.json" + ); + assert_eq!(accshift.pubkey, PUBKEY); + assert_eq!(accshift.install_mode, WindowsUpdateInstallMode::Passive); + // The shipped key decodes: a wrong signature fails on the signature, not the key. + let key = TestKey::new(); + let error = verify_signature(b"data", &key.sign(b"data"), PUBKEY).unwrap_err(); + assert!(matches!(error, UpdaterError::Minisign(_)), "{error:?}"); + + let mut empty = accshift.clone(); + empty.endpoints.clear(); + assert!(matches!( + Updater::new(empty).err(), + Some(UpdaterError::EmptyEndpoints) + )); +} + +#[test] +fn the_bundle_marker_maps_to_the_installer() { + for (marker, bundle, installer) in [ + ( + "__TAURI_BUNDLE_TYPE_VAR_NSS", + BundleType::Nsis, + Installer::Nsis, + ), + ( + "__TAURI_BUNDLE_TYPE_VAR_MSI", + BundleType::Msi, + Installer::Msi, + ), + ( + "__TAURI_BUNDLE_TYPE_VAR_DEB", + BundleType::Deb, + Installer::Deb, + ), + ( + "__TAURI_BUNDLE_TYPE_VAR_RPM", + BundleType::Rpm, + Installer::Rpm, + ), + ( + "__TAURI_BUNDLE_TYPE_VAR_APP", + BundleType::AppImage, + Installer::AppImage, + ), + ] { + assert_eq!(bundle_type_from_marker(marker), Some(bundle)); + assert_eq!(installer_for_bundle_type(Some(bundle)), Some(installer)); + } + let unpatched = bundle_type_from_marker("__TAURI_BUNDLE_TYPE_VAR_UNK"); + assert_eq!( + unpatched, + cfg!(target_os = "macos").then_some(BundleType::App) + ); + // A test binary is never patched by the bundler. + assert_eq!(bundle_type(), unpatched); +} + +// --------------------------------------------------------------------------- +// Install + +#[derive(Default)] +struct RecordingHost { + calls: Mutex>, + launched: Mutex>, +} + +impl InstallerHost for RecordingHost { + fn before_exit(&self) { + self.calls.lock().unwrap().push("before_exit".into()); + } + + fn shell_execute(&self, file: &OsStr, parameters: &OsStr) -> std::io::Result<()> { + self.calls.lock().unwrap().push("shell_execute".into()); + let exists = std::path::Path::new(file).exists(); + *self.launched.lock().unwrap() = Some((file.to_owned(), parameters.to_owned(), exists)); + Ok(()) + } + + fn exit(&self, code: i32) { + self.calls.lock().unwrap().push(format!("exit({code})")); + } +} + +fn offline_update(version: &str, args: &[&str], mode: WindowsUpdateInstallMode) -> Update { + let server = Server::start(|_| (404, Vec::new())); + let key = TestKey::new(); + let mut config = config(&server, &key.pubkey(), "1.0.5", Some(BundleType::Nsis)); + config.install_mode = mode; + config.current_exe_args = args.iter().map(OsString::from).collect(); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let release = parse_manifest(static_manifest( + version, + "https://example.invalid", + &[(platform.as_str(), "c2ln")], + )) + .unwrap(); + let updater = Updater::new(config.clone()).unwrap(); + let (url, signature) = updater.get_urls(&release, None).unwrap(); + Update { + body: None, + current_version: "1.0.5".into(), + version: version.into(), + date: None, + target: os, + download_url: url.clone(), + signature: signature.clone(), + raw_json: Value::Null, + timeout: None, + headers: Vec::new(), + install: accshift_app::lifecycle::updater::InstallContext { + app_name: config.app_name, + install_mode: config.install_mode, + installer_args: config.installer_args, + current_exe_args: config.current_exe_args, + restart_after_install: config.restart_after_install, + bundle_type: config.bundle_type, + executable_path: None, + pubkey: config.pubkey, + }, + } +} + +#[cfg(windows)] +#[test] +fn the_windows_install_launches_the_nsis_setup_then_exits() { + let update = offline_update( + "1.2.0", + &["accshift.exe", "--flag", "some space", "/start"], + WindowsUpdateInstallMode::Passive, + ); + let host = RecordingHost::default(); + update.install(&fake_setup(), &host).unwrap(); + assert_eq!( + *host.calls.lock().unwrap(), + ["before_exit", "shell_execute", "exit(0)"] + ); + let (file, parameters, existed) = host.launched.lock().unwrap().take().unwrap(); + let file = std::path::PathBuf::from(file); + assert!(existed, "the setup is on disk when it is launched"); + assert_eq!( + file.file_name().unwrap(), + "accshift-m5test-1.2.0-installer.exe" + ); + let folder = file.parent().unwrap().to_path_buf(); + assert!(folder + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("accshift-m5test-1.2.0-updater-")); + assert_eq!( + parameters, + "/P /UPDATE /R /ARGS --flag \"some space\" \"/start\"" + ); + // The kept folder (upstream keeps it for the setup); removed here. + std::fs::remove_dir_all(folder).unwrap(); +} + +#[test] +fn a_zipped_setup_is_refused_as_without_the_plugins_zip_feature() { + let update = offline_update("1.3.0", &["accshift.exe"], WindowsUpdateInstallMode::Quiet); + let error = update.extract(b"PK\x03\x04\x14\x00\x00\x00").unwrap_err(); + assert_eq!(error.to_string(), "invalid updater binary format"); +} + +#[test] +fn an_unknown_payload_is_refused_before_anything_runs() { + let update = offline_update( + "1.2.0", + &["accshift.exe"], + WindowsUpdateInstallMode::Passive, + ); + let host = RecordingHost::default(); + let error = update.extract(b"not an installer").unwrap_err(); + assert_eq!(error.to_string(), "invalid updater binary format"); + if cfg!(windows) { + assert!(update.install(b"not an installer", &host).is_err()); + assert!(host.calls.lock().unwrap().is_empty()); + } +} + +#[test] +fn the_installer_command_lines_match_the_plugin() { + let args = ["accshift.exe", "--flag", "some space"]; + let nsis = WindowsUpdaterType::nsis("C:\\t\\setup.exe".into(), None); + let msi = WindowsUpdaterType::msi("C:\\t\\Accshift 1.2.0.msi".into(), None); + assert_eq!( + msi.path(), + std::path::Path::new("\"C:\\t\\Accshift 1.2.0.msi\"") + ); + + let passive = offline_update("1.2.0", &args, WindowsUpdateInstallMode::Passive); + assert_eq!( + passive.updater_parameters(&nsis), + "/P /UPDATE /R /ARGS --flag \"some space\"" + ); + assert_eq!( + passive.updater_parameters(&msi), + "/i \"C:\\t\\Accshift 1.2.0.msi\" /passive /promptrestart AUTOLAUNCHAPP=True LAUNCHAPPARGS=\"--flag \"\"some space\"\"\"" + ); + let basic = offline_update("1.2.0", &args, WindowsUpdateInstallMode::BasicUi); + assert_eq!( + basic.updater_parameters(&nsis), + "/UPDATE /ARGS --flag \"some space\"" + ); + let mut quiet = offline_update("1.2.0", &args, WindowsUpdateInstallMode::Quiet); + quiet.install.restart_after_install = false; + quiet.install.installer_args = vec!["/D=C:\\Apps".into()]; + assert_eq!(quiet.updater_parameters(&nsis), "/S /UPDATE /D=C:\\Apps"); + assert_eq!( + quiet.updater_parameters(&msi), + "/i \"C:\\t\\Accshift 1.2.0.msi\" /quiet /promptrestart /D=C:\\Apps" + ); + + use accshift_app::lifecycle::updater::windows_installer_file; + assert_eq!(windows_installer_file(&nsis, None), "C:\\t\\setup.exe"); + assert_eq!( + windows_installer_file(&msi, Some("C:\\Windows".into())), + "C:\\Windows\\System32\\msiexec.exe" + ); + assert_eq!(windows_installer_file(&msi, None), "msiexec.exe"); +} + +/// updater.rs `it_escapes_correctly_for_msi`. +#[test] +fn msi_arguments_escape_as_the_plugin_tests() { + let cases = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "some space", + "--arg value", + "--arg=unwrapped space", + "--arg=\"wrapped\"", + "--arg=\"wrapped space\"", + "--arg=midword\"wrapped space\"", + "", + ]; + let cases_escaped = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "\"\"some space\"\"", + "\"\"--arg value\"\"", + "--arg=\"\"unwrapped space\"\"", + r#"--arg=""""""wrapped"""""""#, + r#"--arg=""""""wrapped space"""""""#, + r#"--arg=""midword""""wrapped space"""""""#, + "\"\"\"\"", + ]; + for (orig, escaped) in cases.iter().zip(cases_escaped) { + assert_eq!(escape_msi_property_arg(orig), escaped); + } +} + +/// updater.rs `it_escapes_correctly_for_nsis`. +#[test] +fn nsis_arguments_escape_as_the_plugin_tests() { + let cases = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "some space", + "--arg value", + "--arg=unwrapped space", + "--arg=\"wrapped\"", + "--arg=\"wrapped space\"", + "--arg=midword\"wrapped space\"", + "", + ]; + let cases_escaped = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "\"some space\"", + "\"--arg value\"", + "\"--arg=unwrapped space\"", + "--arg=\\\"wrapped\\\"", + "\"--arg=\\\"wrapped space\\\"\"", + "\"--arg=midword\\\"wrapped space\\\"\"", + "\"\"", + ]; + for (orig, escaped) in cases.iter().zip(cases_escaped) { + assert_eq!(escape_nsis_current_exe_arg(OsStr::new(orig)), escaped); + } + // A trailing backslash inside quotes is doubled, `/` forces quotes. + assert_eq!(escape_nsis_current_exe_arg("C:\\a b\\"), "\"C:\\a b\\\\\""); + assert_eq!(escape_nsis_current_exe_arg("/S"), "\"/S\""); +} + +// --------------------------------------------------------------------------- +// run_command: the update flow on the real updater + +struct ApplyRecorder { + inner: RecordingHost, + flush: Result<(), String>, + relaunch: Result<(), String>, +} + +impl InstallerHost for ApplyRecorder { + fn before_exit(&self) { + self.inner.before_exit(); + } + + fn shell_execute(&self, file: &OsStr, parameters: &OsStr) -> std::io::Result<()> { + self.inner.shell_execute(file, parameters) + } + + fn exit(&self, code: i32) { + self.inner.exit(code); + } +} + +impl ApplyHost for ApplyRecorder { + fn before_relaunch(&self) -> Result<(), String> { + self.inner + .calls + .lock() + .unwrap() + .push("before_relaunch".into()); + self.flush.clone() + } + + fn relaunch(&self) -> Result<(), String> { + self.inner.calls.lock().unwrap().push("relaunch".into()); + self.relaunch.clone() + } +} + +#[test] +fn run_command_drives_the_real_updater_and_the_host() { + let key = TestKey::new(); + let artifact = fake_setup(); + let signature = key.sign(&artifact); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let server = server_with( + move |base| static_manifest("1.2.0", base, &[(platform.as_str(), signature.as_str())]), + artifact.clone(), + ); + let updater = Updater::new(config(&server, &key.pubkey(), "1.0.5", None)).unwrap(); + let host = ApplyRecorder { + inner: RecordingHost::default(), + flush: Err("settings flush failed".into()), + relaunch: Err("spawn failed".into()), + }; + + let Some(CommandResult::Checked(checked)) = run_command(&updater, UpdateCommand::Check, &host) + else { + panic!("check result"); + }; + let update = (*checked).unwrap().unwrap(); + assert_eq!(update.version, "1.2.0"); + + let Some(CommandResult::Downloaded(bytes)) = + run_command(&updater, UpdateCommand::Download(update.clone()), &host) + else { + panic!("download result"); + }; + assert_eq!(bytes.unwrap(), artifact); + + // A failed flush stops before the installer. + let Some(CommandResult::Installed(installed)) = run_command( + &updater, + UpdateCommand::Install { + update, + bytes: artifact, + }, + &host, + ) else { + panic!("install result"); + }; + assert_eq!(installed, Err("settings flush failed".to_string())); + assert_eq!(*host.inner.calls.lock().unwrap(), ["before_relaunch"]); + + let Some(CommandResult::RelaunchFailed(error)) = + run_command(&updater, UpdateCommand::Relaunch, &host) + else { + panic!("relaunch result"); + }; + assert_eq!(error, "spawn failed"); +} + +/// Acceptance check against the real endpoint of tauri.conf.json: one GET of +/// `latest.json`, no download, no install. Ignored; runs only with +/// `ACCSHIFT_M5_REAL_ENDPOINT=1 cargo test --test lifecycle_updater -- --ignored real_endpoint`. +#[test] +#[ignore = "network: the real release manifest"] +fn real_endpoint_manifest_reads() { + if std::env::var("ACCSHIFT_M5_REAL_ENDPOINT").as_deref() != Ok("1") { + return; + } + let mut config = UpdaterConfig::accshift(); + config.timeout = Some(Duration::from_secs(30)); + let updater = Updater::new(config).unwrap(); + let release = updater.fetch_release().unwrap().expect("a release"); + println!("M5-REAL version={}", release.version); + println!("M5-REAL pub_date={:?}", release.pub_date); + println!("M5-REAL platforms={:?}", release.platform_keys()); + let current = Version::parse(accshift_app::lifecycle::updater::APP_VERSION).unwrap(); + println!("M5-REAL newer_than_{current}={}", release.version > current); + match updater.get_urls(&release, Some(Installer::Nsis)) { + Ok((url, signature)) => { + println!("M5-REAL nsis_url={url} signature_chars={}", signature.len()) + } + Err(error) => println!("M5-REAL nsis_error={error}"), + } +} diff --git a/crates/accshift-app/tests/mock_parity.rs b/crates/accshift-app/tests/mock_parity.rs new file mode 100644 index 00000000..f0efc9cb --- /dev/null +++ b/crates/accshift-app/tests/mock_parity.rs @@ -0,0 +1,468 @@ +//! The mock backend against the dumped TS mock data. + +use accshift_app::dispatch::dispatch; +use accshift_app::jobs::{Jobs, Poll}; +use accshift_app::mock::{cs2_settings_from_fixture, MockBackend, AVATAR_DIR, NOW}; +use accshift_app::real::{RealBackend, PORTED}; +use accshift_app::types::DescriptorPreview; +use accshift_app::{Backend, BackendError, COMMANDS}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const SCENARIOS: [&str; 5] = ["demo", "dev", "empty", "huge", "slow"]; + +fn repo_root() -> PathBuf { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .nth(2) + .expect("repo root") + .to_path_buf() +} + +fn data_dir() -> PathBuf { + MockBackend::data_dir(&repo_root()) +} + +fn raw(scenario: &str) -> Value { + let text = std::fs::read_to_string(data_dir().join(format!("{scenario}.json"))).unwrap(); + serde_json::from_str(&text).unwrap() +} + +fn load(scenario: &str) -> MockBackend { + MockBackend::load(&data_dir(), scenario, Some(&repo_root())).expect(scenario) +} + +/// The scenario's JSON with `edit` applied, as a backend. +fn load_edited(scenario: &str, edit: impl FnOnce(&mut Value)) -> MockBackend { + let mut data = raw(scenario); + edit(&mut data); + MockBackend::from_json(&data.to_string(), Some(&repo_root())).unwrap() +} + +/// Arguments a command accepts: `{}` except where the command decodes a +/// structured argument, which an empty object would fail before the backend. +fn sample_args(command: &str) -> Value { + match command { + "steam_bulk_edit" => json!({ "request": { "steamIds": [], "launchOptions": [] } }), + "save_custom_theme" => json!({ "theme": { + "id": "t", "name": "T", "colorScheme": "dark", "tokens": {} + } }), + "telemetry_track_settings_snapshot" => json!({ + "uiLanguage": "en", "enabledPlatforms": [], "personasEnabled": false, + "pinEnabled": false, "cliEnabled": false, "deepLinksEnabled": false, + "streamerMode": "off", "animations": "on" + }), + _ => json!({}), + } +} + +/// Recorded value with avatar paths rewritten the way the test expects: +/// `/src/lib/mock/avatars/`, built here independently. +fn expected(value: &Value, root: &Path) -> Value { + match value { + Value::String(s) if s.starts_with(AVATAR_DIR) => { + let mut path = root.to_path_buf(); + for part in s.split('/') { + path.push(part); + } + assert!(path.is_file(), "avatar missing: {}", path.display()); + Value::String(path.to_string_lossy().into_owned()) + } + Value::Array(items) => Value::Array(items.iter().map(|v| expected(v, root)).collect()), + Value::Object(map) => Value::Object( + map.iter() + .map(|(k, v)| (k.clone(), expected(v, root))) + .collect(), + ), + other => other.clone(), + } +} + +#[test] +fn every_scenario_file_loads() { + let found = MockBackend::scenarios(&data_dir()); + assert_eq!(found, SCENARIOS.map(String::from).to_vec()); + for id in SCENARIOS { + let mock = load(id); + assert_eq!(mock.scenario(), id); + assert_eq!(mock.now(), NOW); + assert_eq!(mock.frozen(), id == "demo"); + } + println!("scenarios loaded: {}", found.len()); +} + +#[test] +fn every_recorded_command_is_a_backend_command() { + for id in SCENARIOS { + let mock = load(id); + for command in mock.recorded().keys().chain(mock.skipped().keys()) { + assert!(COMMANDS.contains(&command.as_str()), "{id}: {command}"); + } + } +} + +#[test] +fn every_recorded_call_answers_as_recorded() { + let root = repo_root(); + let mut total = 0; + for id in SCENARIOS { + let data = raw(id); + let mock = load(id); + let mut calls = 0; + for (command, recorded) in data["commands"].as_object().unwrap() { + for call in recorded.as_array().unwrap() { + let args = &call["args"]; + let answer = dispatch(&mock, command, args) + .unwrap_or_else(|| panic!("{command} is not dispatched")); + match call.get("error") { + Some(error) => assert_eq!( + answer, + Err(BackendError::new(error.as_str().unwrap())), + "{id}: {command} {args}" + ), + None => { + let mut want = expected(&call["result"], &root); + if command == "cs2_bridge_get_settings" { + want = cs2_settings_from_fixture(want); + } + if command == "descriptor_preview_file" { + // The typed descriptor writes out every field it + // defaults; the recording only has the file's. + want = typed::(want); + } + assert_eq!(answer, Ok(want), "{id}: {command} {args}"); + } + } + calls += 1; + } + } + println!("{id}: {calls} recorded calls answered as recorded"); + total += calls; + } + println!("total: {total} recorded calls"); + assert!(total > 0); +} + +#[test] +fn avatar_paths_resolve_to_files() { + let mock = load("demo"); + let infos = mock + .steam_get_profile_infos(&[mock.spec().current_steam_account.clone()]) + .unwrap(); + let url = infos.values().next().unwrap().avatar_url.clone().unwrap(); + let path = mock.avatar_path(&url).unwrap(); + assert!(path.is_file(), "{}", path.display()); + assert!(mock.avatar_path("https://example.com/a.png").is_none()); +} + +#[test] +fn a_switch_changes_the_current_account() { + let mock = load_edited("dev", |data| data["spec"]["switchDelayMs"] = json!(50)); + let before = mock.platform_get_current_account("steam").unwrap(); + assert_eq!(before, mock.spec().current_steam_account); + let accounts = mock.platform_get_accounts("steam").unwrap(); + let target = accounts + .as_array() + .unwrap() + .iter() + .map(|a| a["steam_id"].as_str().unwrap().to_string()) + .find(|id| *id != before) + .unwrap(); + + let started = Instant::now(); + mock.platform_switch_account("steam", &target, &json!({})) + .unwrap(); + assert!(started.elapsed() >= Duration::from_millis(50)); + assert_eq!(mock.platform_get_current_account("steam").unwrap(), target); + let snapshot = mock.platform_get_startup_snapshot("steam").unwrap(); + assert_eq!(snapshot["currentAccount"], json!(target)); + + mock.platform_switch_account("riot", "riot-2", &json!({})) + .unwrap(); + let riot = mock.platform_get_startup_snapshot("riot").unwrap(); + assert_eq!(riot["currentProfile"], json!("riot-2")); + // Untouched by the other platforms' switches. + assert_eq!(mock.platform_get_current_account("steam").unwrap(), target); +} + +#[test] +fn the_scenario_switch_delay_is_honoured() { + let mock = load("dev"); + let started = Instant::now(); + mock.platform_switch_account("steam", "76561198100000002", &json!({})) + .unwrap(); + assert!(started.elapsed() >= Duration::from_millis(mock.spec().switch_delay_ms)); +} + +#[test] +fn unmocked_guarded_commands_are_refused() { + let mock = load("dev"); + let mut refused = 0; + for command in COMMANDS { + if mock.is_mocked(command) { + continue; + } + let answer = dispatch(&mock, command, &sample_args(command)).unwrap(); + let guarded = [ + "platform_", + "steam_", + "riot_", + "roblox_", + "cs2_bridge_", + "telemetry_", + "pin_", + ] + .iter() + .any(|p| command.starts_with(p)); + if guarded { + assert_eq!( + answer, + Err(BackendError::new(format!( + "mock mode: {command} is not available" + ))), + "{command}" + ); + refused += 1; + } else { + // Forwarded to the real backend by the TS mock; a no-op here. + assert_eq!(answer, Ok(Value::Null), "{command}"); + } + } + println!("refused: {refused}"); + assert!(refused > 0); +} + +#[test] +fn save_client_storage_store_saves_nothing() { + let mock = load("dev"); + let before = mock.load_client_storage_snapshot().unwrap(); + let saved = mock + .save_client_storage_store("client.view-mode", &json!("list")) + .unwrap(); + assert_eq!(saved, None); + let after = mock.load_client_storage_snapshot().unwrap(); + assert_eq!( + serde_json::to_value(before).unwrap(), + serde_json::to_value(after).unwrap() + ); +} + +#[test] +fn fail_and_latency_overrides() { + let mock = load("dev"); + assert!(mock.fail("get_runtime_os", Some("boom"))); + assert_eq!(mock.get_runtime_os(), Err(BackendError::new("boom"))); + // An override beats the guard too, like `mockInvoke`. + assert!(mock.fail("platform_forget_account", Some("nope"))); + assert_eq!( + mock.platform_forget_account("steam", "x"), + Err(BackendError::new("nope")) + ); + assert!(mock.fail("get_runtime_os", None)); + assert_eq!(mock.get_runtime_os().unwrap(), "windows"); + + assert_eq!(mock.latency(40), 40); + let started = Instant::now(); + mock.get_runtime_os().unwrap(); + assert!(started.elapsed() >= Duration::from_millis(40)); + + assert!(mock.set_runtime_os("macos")); + assert_eq!(mock.get_runtime_os().unwrap(), "macos"); + assert_eq!(mock.get_boot_payload().unwrap().runtime_os, "macos"); +} + +#[test] +fn the_cs2_override_enables_the_bridge() { + let mock = load("dev"); + assert!(!mock.cs2_bridge_get_settings().unwrap().enabled); + assert!(mock.cs2_bridge_fetch().is_err()); + let row = json!({ + "steamId": "76561198100000001", "level": 24, "xp": 3120, "xpMax": 5000, + "caseEarned": true, "weekStartTs": null, "lastUpdated": null, + }); + mock.set_cs2(Some(vec![row])); + assert!(mock.cs2_bridge_get_settings().unwrap().enabled); + let rows = mock.cs2_bridge_fetch().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].level, Some(24)); + let checked = mock.cs2_bridge_check("76561198100000001").unwrap(); + assert_eq!(checked.map(|a| a.xp), Some(Some(3120))); + assert!(mock + .cs2_bridge_check("76561198100000002") + .unwrap() + .is_none()); + // The other bridge commands stay refused. + assert!(mock.cs2_bridge_test().is_err()); + mock.set_cs2(None); + assert!(!mock.cs2_bridge_get_settings().unwrap().enabled); +} + +#[test] +fn the_setup_override_walks_its_statuses() { + let mock = load("dev"); + assert!(mock.platform_begin_setup("steam", &json!({})).is_err()); + let status = |state: &str| { + json!({ + "setupId": "setup-1", "state": state, "accountId": "", + "accountDisplayName": "", "errorMessage": "", + }) + }; + mock.set_setup(Some(vec![status("waiting_for_client"), status("failed")])); + let begun = mock.platform_begin_setup("steam", &json!({})).unwrap(); + assert_eq!( + (begun.setup_id.as_str(), begun.state.as_str()), + ("setup-1", "waiting_for_client") + ); + // Each poll answers the next status, the last one repeating. + for _ in 0..2 { + let polled = mock.platform_get_setup_status("steam", "setup-1").unwrap(); + assert_eq!(polled.state, "failed"); + } + // A new setup starts over. + let again = mock.platform_begin_setup("steam", &json!({})).unwrap(); + assert_eq!(again.state, "waiting_for_client"); + assert!(mock.platform_cancel_setup("steam", "setup-1").is_ok()); + mock.set_setup(Some(Vec::new())); + assert!(mock.platform_begin_setup("steam", &json!({})).is_err()); +} + +#[test] +fn the_demo_scenario_ignores_overrides() { + let mock = load("demo"); + assert!(!mock.fail("get_runtime_os", Some("boom"))); + assert!(mock.get_runtime_os().is_ok()); + assert_eq!(mock.latency(500), 0); + assert!(!mock.set_runtime_os("linux")); + assert_eq!(mock.get_runtime_os().unwrap(), "windows"); +} + +#[test] +fn a_pin_locks_the_switch_until_unlocked() { + let hash = accshift_core::pin::hash_pin_code("1234").unwrap(); + let mock = load_edited("dev", |data| { + data["spec"]["switchDelayMs"] = json!(0); + let settings = &mut data["commands"]["load_client_storage_snapshot"][0]["result"]["stores"] + ["client.settings"]; + settings["pinEnabled"] = json!(true); + settings["pinHash"] = json!(hash); + }); + let locked = mock + .platform_switch_account("steam", "76561198100000002", &json!({})) + .unwrap_err(); + assert!(locked.is_pin_locked(), "{locked}"); + let wrong = mock.pin_unlock("0000").unwrap(); + assert_eq!( + (wrong.status.as_str(), wrong.retry_after_ms), + ("invalid", 1000) + ); + let right = mock.pin_unlock("1234").unwrap(); + assert_eq!((right.status.as_str(), right.legacy), ("unlocked", false)); + mock.platform_switch_account("steam", "76561198100000002", &json!({})) + .unwrap(); + mock.pin_lock().unwrap(); + assert!(mock + .platform_switch_account("steam", "76561198100000001", &json!({})) + .unwrap_err() + .is_pin_locked()); + // No PIN in the scenario: nothing to unlock. + let open = load("empty").pin_unlock("1234").unwrap(); + assert_eq!(open.status, "not_configured"); +} + +#[test] +fn batch_and_unknown_ids_follow_the_fixtures() { + let mock = load("dev"); + let ids = vec![ + "76561198100000003".to_string(), + "nobody".to_string(), + "76561198100000001".to_string(), + ]; + let bans = mock.steam_get_player_bans(&ids).unwrap(); + let order: Vec<&str> = bans.iter().map(|b| b.steam_id.as_str()).collect(); + assert_eq!(order, ["76561198100000003", "76561198100000001"]); + let infos = mock.steam_get_profile_infos(&ids).unwrap(); + assert_eq!(infos.len(), 2); + assert!(mock.steam_get_profile_info("nobody").unwrap().is_none()); + assert_eq!(mock.roblox_get_profile_info("0").unwrap().avatar_url, None); + assert_eq!(mock.platform_get_accounts("epic").unwrap(), json!([])); + assert_eq!(mock.platform_get_current_account("epic").unwrap(), ""); + let plan = mock.platform_dry_run("steam", "76561198100000002").unwrap(); + assert_eq!(plan.account_id, "76561198100000002"); + assert!(!plan.applied); +} + +#[test] +fn jobs_deliver_off_thread_and_wake() { + let wakes = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&wakes); + let jobs = Jobs::new(Arc::new(load("dev")), 2, move || { + counter.fetch_add(1, Ordering::SeqCst); + }); + let mut os = jobs.run(|b| b.get_runtime_os()); + let current = jobs.run(|b| b.platform_get_current_account("steam")); + let panicked = jobs.run::<(), _>(|_| panic!("job panics on purpose")); + assert_eq!(current.wait(), Some(Ok("76561198100000001".to_string()))); + assert_eq!(panicked.wait(), None); + let deadline = Instant::now() + Duration::from_secs(5); + let answer = loop { + match os.poll() { + Poll::Ready(answer) => break answer, + Poll::Waiting if Instant::now() < deadline => std::thread::yield_now(), + other => panic!("{other:?}"), + } + }; + assert_eq!(answer, Ok("windows".to_string())); + assert_eq!(os.poll(), Poll::Gone); + // The pool survives a panicking call. + assert!(jobs.run(|b| b.steam_has_api_key()).wait().unwrap().unwrap()); + // A job wakes the UI right after it sends its result, so the last wake + // can land just after `wait` returns. + let deadline = Instant::now() + Duration::from_secs(5); + while wakes.load(Ordering::SeqCst) < 3 && Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(wakes.load(Ordering::SeqCst) >= 3); +} + +#[test] +fn the_real_backend_dispatches_every_command_and_refuses_the_unported() { + // A context on a scratch folder: nothing reads the user's real dirs. + struct Scratch(PathBuf); + impl accshift_core::AppContext for Scratch { + fn app_config_dir(&self) -> Result { + Ok(self.0.join("config")) + } + fn app_data_dir(&self) -> Result { + Ok(self.0.join("data")) + } + fn app_local_data_dir(&self) -> Result { + Ok(self.0.join("local")) + } + fn app_cache_dir(&self) -> Result { + Ok(self.0.join("cache")) + } + } + let real = RealBackend::with_context(Arc::new(Scratch(std::env::temp_dir()))); + assert_eq!(COMMANDS.len(), 73); + // The ported commands are not called here: some open a dialog, a URL or + // a folder. tests/real_*.rs covers them on a scratch context. + for command in PORTED { + assert!(COMMANDS.contains(command), "{command} is not a command"); + } + for command in COMMANDS.iter().filter(|command| !PORTED.contains(command)) { + let answer = dispatch(&real, command, &sample_args(command)) + .unwrap_or_else(|| panic!("{command} is not dispatched")); + assert_eq!(answer, Err(BackendError::not_ported(command)), "{command}"); + } + assert!(dispatch(&real, "minimize_window", &json!({})).is_none()); +} + +/// `value` read as `T` and written back, as the typed backend answers it. +fn typed(value: Value) -> Value { + let parsed: T = serde_json::from_value(value).expect("the recording reads as the type"); + serde_json::to_value(parsed).expect("the type writes back") +} diff --git a/crates/accshift-app/tests/platform_add_flow.rs b/crates/accshift-app/tests/platform_add_flow.rs new file mode 100644 index 00000000..90dc2abf --- /dev/null +++ b/crates/accshift-app/tests/platform_add_flow.rs @@ -0,0 +1,535 @@ +//! `platformAddFlow.test.ts` and the add flow against MockBackend, start to +//! success, cancel and error, for Steam and for a descriptor platform (GOG). + +mod support; + +use accshift_app::accounts::loader::LoadOptions; +use accshift_app::accounts::profile_cache::{ProfileCaches, UrlPolicy}; +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::backend::Backend; +use accshift_app::i18n::Locale; +use accshift_app::jobs::Jobs; +use accshift_app::platform_add_flow::{ + setup_key, AddFlowContext, AddFlowEvent, AddFlowView, PlatformAddFlow, SetupChipAction, + SetupKey, POLL_DELAY, +}; +use accshift_app::platforms::card::ChipTone; +use accshift_app::platforms::{AddFlowStatus, PlatformRegistry}; +use accshift_app::settings::sanitize_settings; +use accshift_app::storage::ClientStorage; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::text::Text; +use accshift_app::time::Clock; +use serde_json::{json, Value}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +fn status(setup_id: &str, state: &str) -> AddFlowStatus { + AddFlowStatus { + setup_id: setup_id.to_string(), + state: state.to_string(), + account_id: None, + account_display_name: None, + error_message: None, + } +} + +fn setup_json(setup_id: &str, state: &str, account_id: &str, name: &str, error: &str) -> Value { + json!({ + "setupId": setup_id, + "state": state, + "accountId": account_id, + "accountDisplayName": name, + "errorMessage": error, + }) +} + +struct Rig { + backend: Arc, + jobs: Jobs, + registry: PlatformRegistry, + caches: Arc, + clock: Clock, + stores: ClientStores, + flow: PlatformAddFlow, + active_tab: String, + t0: Instant, +} + +impl Rig { + fn new(active_tab: &str) -> Self { + let backend = Arc::new(support::scripted("dev")); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let clock = Clock::fixed(1_700_000_000_000.0); + Self { + backend, + jobs, + registry: PlatformRegistry::shipped(), + caches: Arc::new(ProfileCaches::new(UrlPolicy::HttpOrFile, clock.now_ms())), + clock, + stores: ClientStores::new(ClientStorage::new(), StoreContext::shipped()), + flow: PlatformAddFlow::new(), + active_tab: active_tab.to_string(), + t0: Instant::now(), + } + } + + fn view(&self) -> AddFlowView<'_> { + AddFlowView { + active_tab: &self.active_tab, + current_folder_id: None, + is_searching: false, + } + } + + fn with_ctx( + &mut self, + ms: u64, + run: impl FnOnce(&mut PlatformAddFlow, &AddFlowContext<'_>, &mut ClientStores) -> R, + ) -> R { + let ctx = AddFlowContext { + jobs: &self.jobs, + registry: &self.registry, + view: AddFlowView { + active_tab: &self.active_tab, + current_folder_id: None, + is_searching: false, + }, + caches: &self.caches, + clock: &self.clock, + now: self.t0 + Duration::from_millis(ms), + }; + run(&mut self.flow, &ctx, &mut self.stores) + } + + /// What the account loader does on the add button: the adapter begins + /// the setup, the flow takes it over. + fn begin(&mut self, platform_id: &str, answer: Value) { + self.backend.push("platform_begin_setup", Ok(answer)); + let settings = sanitize_settings(&json!({}), &StoreContext::shipped()); + let adapter = self.registry.adapter(platform_id).expect("adapter"); + let started = adapter + .add_account(self.backend.as_ref(), &settings) + .expect("setup begins") + .setup_status + .expect("a setup status"); + self.with_ctx(0, |flow, ctx, _| flow.start(ctx, platform_id, started)); + } + + /// Ticks at `ms` until no job is in flight; every event. + fn tick(&mut self, ms: u64) -> Vec { + let deadline = Instant::now() + Duration::from_secs(10); + let mut events = Vec::new(); + loop { + events.extend(self.with_ctx(ms, |flow, ctx, stores| flow.tick(ctx, stores))); + if !self.flow.is_busy() { + // Fire-and-forget telemetry ran. + self.jobs.run(|_| ()).wait(); + return events; + } + assert!(Instant::now() < deadline, "add flow job never finished"); + std::thread::sleep(Duration::from_millis(2)); + } + } +} + +fn silent_load() -> AddFlowEvent { + AddFlowEvent::LoadAccounts(LoadOptions { + silent: true, + ..LoadOptions::default() + }) +} + +// ------------------------------------------------------------ TS tests + +#[test] +fn a_contended_lock_says_another_operation_is_running() { + let mut rig = Rig::new("steam"); + rig.with_ctx(0, |flow, ctx, _| { + flow.start(ctx, "steam", status("setup-1", "busy")) + }); + let content = rig + .flow + .setup_extension_content("setup-1", Locale::default()) + .expect("content"); + assert_eq!( + content.sections[0].text, + Some(Text::key("platform.setupBusy")) + ); + assert!(content.sections[0].loading); + let pending = rig + .flow + .pending_setup_account(&rig.view()) + .expect("pending"); + assert_eq!(pending.username, Text::key("platform.setupBusy")); + rig.flow.stop(); +} + +#[test] +fn busy_is_non_terminal_and_the_poll_keeps_the_flow_alive() { + let mut rig = Rig::new("steam"); + rig.with_ctx(0, |flow, ctx, _| { + flow.start(ctx, "steam", status("setup-1", "waiting_for_login")) + }); + rig.backend.push( + "platform_get_setup_status", + Ok(setup_json("setup-1", "busy", "", "", "")), + ); + rig.tick(POLL_DELAY.as_millis() as u64); + assert_eq!(rig.flow.flow().unwrap().status.state, "busy"); + assert!(rig + .backend + .calls("telemetry_track_operation_failed") + .is_empty()); + // And it polls again. + assert!(rig.flow.next_deadline().is_some()); +} + +// ------------------------------------------------------------ Steam + +#[test] +fn steam_from_start_to_success() { + let mut rig = Rig::new("steam"); + rig.begin( + "steam", + setup_json("setup-7", "waiting_for_client", "", "", ""), + ); + assert!(rig.flow.is_active()); + assert_eq!(rig.flow.active_platform_setup_id("steam"), Some("setup-7")); + assert_eq!(rig.flow.active_platform_setup_id("riot"), None); + assert!(rig.flow.is_forced_open("steam", "setup-7")); + let pending = rig + .flow + .pending_setup_account(&rig.view()) + .expect("pending"); + assert_eq!(pending.id, "setup-7"); + assert_eq!( + pending.display_name, + Text::key("platform.newAccountPending") + ); + let content = rig + .flow + .setup_extension_content("setup-7", Locale::default()) + .unwrap(); + assert_eq!( + content.sections[0].text, + Some(Text::key("steam.setupWaitingForClient")) + ); + + // Nothing before the 1.5 s timer. + rig.tick(1000); + assert!(rig.backend.calls("platform_get_setup_status").is_empty()); + + // Detected: the card shows the name. + rig.backend.push( + "platform_get_setup_status", + Ok(setup_json("setup-7", "waiting_for_login", "", "Gaben", "")), + ); + rig.tick(1500); + let pending = rig.flow.pending_setup_account(&rig.view()).unwrap(); + assert_eq!(pending.display_name, Text::raw("Gaben")); + assert_eq!(pending.username, Text::key("steam.setupConnected")); + + // Ready at the next poll. The prime gets a full profile, so no retry. + rig.backend.push( + "steam_get_profile_info", + Ok(json!({ + "avatar_url": "https://avatars.example/gaben.jpg", + "display_name": "Gaben", + "vac_banned": false, + "trade_ban_state": "None", + })), + ); + rig.backend.push( + "platform_get_setup_status", + Ok(setup_json( + "setup-7", + "ready", + "76561198100000099", + "Gaben", + "", + )), + ); + let events = rig.tick(3000); + assert_eq!( + events, + vec![ + silent_load(), + AddFlowEvent::Toast(Toast::new( + Text::with("steam.setupReadyWithProfile", [("profile", "Gaben")]), + ToastKind::Default, + )), + ] + ); + assert!(!rig.flow.is_active()); + assert_eq!(rig.flow.next_deadline(), None); + // Steam primes the new profile and counts the add. + assert_eq!( + rig.backend.calls("steam_get_profile_info"), + vec![r#"("76561198100000099",)"#] + ); + assert_eq!( + rig.backend + .calls("telemetry_track_account_add_started") + .len(), + 1 + ); + assert_eq!(rig.backend.calls("telemetry_track_account_added").len(), 1); +} + +#[test] +fn steam_cancel_stops_polling_and_reloads() { + let mut rig = Rig::new("steam"); + rig.begin( + "steam", + setup_json("setup-8", "waiting_for_login", "", "", ""), + ); + // The plain mock has no setup to cancel. + rig.backend.push("platform_cancel_setup", Ok(json!(null))); + rig.with_ctx(10, |flow, ctx, _| flow.cancel(ctx)); + assert_eq!(rig.flow.next_deadline(), None); + let events = rig.tick(10); + assert_eq!(events, vec![silent_load()]); + assert!(!rig.flow.is_active()); + assert_eq!( + rig.backend.calls("platform_cancel_setup"), + vec![r#"("steam", "setup-8")"#] + ); + assert_eq!( + rig.backend + .calls("telemetry_track_account_add_cancelled") + .len(), + 1 + ); + // The poll never fired. + rig.tick(5000); + assert!(rig.backend.calls("platform_get_setup_status").is_empty()); +} + +#[test] +fn steam_poll_error_marks_the_flow_failed() { + let mut rig = Rig::new("steam"); + rig.begin( + "steam", + setup_json("setup-9", "waiting_for_login", "", "", ""), + ); + rig.backend.push( + "platform_get_setup_status", + Err("Steam closed unexpectedly"), + ); + let events = rig.tick(1500); + assert!(events.is_empty()); + let current = rig.flow.flow().unwrap(); + assert_eq!(current.status.state, "failed"); + assert_eq!( + current.status.error_message.as_deref(), + Some("Steam closed unexpectedly") + ); + assert_eq!( + rig.backend.calls("telemetry_track_operation_failed"), + vec![r#"("account_add", "other", Some("steam"))"#] + ); + // No more polling, the card says it failed and offers Close. + assert_eq!(rig.flow.next_deadline(), None); + assert!(rig.flow.pending_setup_account(&rig.view()).is_some()); + let content = rig + .flow + .setup_extension_content("setup-9", Locale::default()) + .unwrap(); + assert_eq!( + content.sections[0].title, + Some(Text::key("steam.setupFailed")) + ); + assert_eq!( + content.sections[1].lines, + vec!["Steam closed unexpectedly".to_string()] + ); + assert_eq!(content.sections[1].chips[0].tone, ChipTone::Red); + assert_eq!(content.chip_action(1, 0), Some(&SetupChipAction::Cancel)); + + // Close: the cancel ends the flow. + rig.with_ctx(2000, |flow, ctx, _| flow.cancel(ctx)); + rig.tick(2000); + assert!(!rig.flow.is_active()); +} + +// ------------------------------------------------------------ descriptor (GOG) + +#[test] +fn descriptor_platform_from_start_to_success_asks_for_a_label() { + let mut rig = Rig::new("gog"); + assert!(rig.registry.definition("gog").is_some()); + rig.begin("gog", setup_json("gog-1", "waiting_for_login", "", "", "")); + let content = rig + .flow + .setup_extension_content("gog-1", Locale::default()) + .unwrap(); + assert_eq!( + content.sections[0].text, + Some(Text::key(setup_key("gog", SetupKey::WaitingForLogin))) + ); + rig.backend.push( + "platform_get_setup_status", + Ok(setup_json("gog-1", "ready", "gog-user-42", "", "")), + ); + let events = rig.tick(1500); + assert_eq!( + events, + vec![ + silent_load(), + AddFlowEvent::Toast(Toast::new(Text::key("gog.setupReady"), ToastKind::Default)), + AddFlowEvent::AccountAdded { + platform_id: "gog".into(), + account_id: "gog-user-42".into(), + display_name: None, + }, + ] + ); + // GOG does not prime a profile. + assert!(rig.backend.calls("steam_get_profile_info").is_empty()); +} + +#[test] +fn descriptor_platform_cancel_from_another_tab() { + let mut rig = Rig::new("gog"); + rig.begin("gog", setup_json("gog-2", "waiting_for_client", "", "", "")); + // The plain mock has no setup to cancel. + rig.backend.push("platform_cancel_setup", Ok(json!(null))); + // A switch to the setup card itself does not cancel. + rig.with_ctx(5, |flow, ctx, _| { + flow.cancel_if_conflicting(ctx, "gog", Some("gog-2")) + }); + assert!(rig.flow.is_active()); + // Another platform: nothing. + rig.with_ctx(5, |flow, ctx, _| { + flow.cancel_if_conflicting(ctx, "steam", None) + }); + assert!(rig.flow.is_active()); + // A switch to another GOG account drops the flow; the tab moved meanwhile. + rig.active_tab = "steam".into(); + rig.with_ctx(5, |flow, ctx, _| { + flow.cancel_if_conflicting(ctx, "gog", Some("gog-other")) + }); + let events = rig.tick(5); + assert!(events.is_empty(), "no reload off the tab"); + assert!(!rig.flow.is_active()); + assert_eq!( + rig.backend.calls("platform_cancel_setup"), + vec![r#"("gog", "gog-2")"#] + ); +} + +#[test] +fn descriptor_platform_failed_state_and_cancel_error() { + let mut rig = Rig::new("gog"); + rig.begin("gog", setup_json("gog-3", "waiting_for_login", "", "", "")); + rig.backend.push( + "platform_get_setup_status", + Ok(setup_json( + "gog-3", + "failed", + "", + "", + "GOG Galaxy refused the login", + )), + ); + rig.tick(1500); + assert_eq!(rig.flow.flow().unwrap().status.state, "failed"); + assert_eq!( + rig.backend.calls("telemetry_track_operation_failed"), + vec![r#"("account_add", "other", Some("gog"))"#] + ); + let content = rig + .flow + .setup_extension_content("gog-3", Locale::default()) + .unwrap(); + assert_eq!( + content.sections[0].text, + Some(Text::key("gog.setupFailedMessage")) + ); + + // The cancel itself fails: its message is the toast, the flow ends. + rig.backend.push("platform_cancel_setup", Err("setup gone")); + rig.with_ctx(2000, |flow, ctx, _| flow.cancel(ctx)); + let events = rig.tick(2000); + assert_eq!( + events, + vec![ + AddFlowEvent::Toast(Toast::new(Text::raw("setup gone"), ToastKind::Default)), + silent_load(), + ] + ); + assert!(!rig.flow.is_active()); +} + +#[test] +fn a_setup_ready_at_once_ends_without_polling() { + let mut rig = Rig::new("discord"); + rig.begin( + "discord", + setup_json("d-1", "ready", "discord-1", "Wumpus", ""), + ); + assert!(!rig.flow.is_active()); + assert_eq!(rig.flow.next_deadline(), None); + rig.tick(5000); + assert!(rig.backend.calls("platform_get_setup_status").is_empty()); +} + +// ------------------------------------------------------------ views + +#[test] +fn the_pending_card_hides_in_folders_searches_and_other_tabs() { + let mut rig = Rig::new("steam"); + rig.with_ctx(0, |flow, ctx, _| { + flow.start(ctx, "steam", status("s-1", "waiting_for_login")) + }); + let folder = AddFlowView { + active_tab: "steam", + current_folder_id: Some("folder-1"), + is_searching: false, + }; + let search = AddFlowView { + active_tab: "steam", + current_folder_id: None, + is_searching: true, + }; + let riot = AddFlowView { + active_tab: "riot", + current_folder_id: None, + is_searching: false, + }; + assert!(rig.flow.pending_setup_account(&folder).is_none()); + assert!(rig.flow.pending_setup_account(&search).is_none()); + assert!(rig.flow.pending_setup_account(&riot).is_none()); + assert!(rig.flow.is_pending_setup_account(&rig.view(), "s-1")); + assert_eq!( + rig.flow + .pending_setup_account(&rig.view()) + .unwrap() + .to_account(Locale::default()) + .id, + "s-1" + ); + assert!(rig + .flow + .setup_extension_content("other", Locale::default()) + .is_none()); +} + +#[test] +fn roblox_offers_the_quick_login_code() { + let mut rig = Rig::new("roblox"); + let mut started = status("r-1", "waiting_for_login"); + started.account_display_name = Some("ABC123".into()); + rig.with_ctx(0, |flow, ctx, _| flow.start(ctx, "roblox", started)); + let content = rig + .flow + .setup_extension_content("r-1", Locale::default()) + .unwrap(); + assert!(content.sections[0].link.is_some()); + assert_eq!( + content.chip_action(0, 1), + Some(&SetupChipAction::CopyCode("ABC123".into())) + ); + rig.flow.stop(); +} diff --git a/crates/accshift-app/tests/platform_parity.rs b/crates/accshift-app/tests/platform_parity.rs new file mode 100644 index 00000000..fa6e46ed --- /dev/null +++ b/crates/accshift-app/tests/platform_parity.rs @@ -0,0 +1,138 @@ +//! The platform model over every parity scenario +//! (`crates/accshift-egui/parity/data/*.json`): the registry and adapters +//! build each platform's accounts through `MockBackend` without error, with +//! the account counts and current accounts the scenario holds. + +mod support; + +use accshift_app::menu::account::{build_account_context_menu, AppearanceInput}; +use accshift_app::platforms::adapter::MenuContext; +use accshift_app::platforms::{PlatformRegistry, StartupSnapshot, WarningLoadOptions}; +use accshift_app::settings::sanitize_settings; +use accshift_app::storage::ClientStorage; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::Backend; +use serde_json::Value; +use std::collections::BTreeMap; + +/// Builds every platform's snapshot, its menus, profiles and warnings, and +/// returns the snapshots by platform id. +fn build_model(scenario: &str) -> BTreeMap { + let backend = support::mock(scenario); + let registry = PlatformRegistry::shipped(); + let stores = ClientStores::new(ClientStorage::new(), StoreContext::shipped()); + let settings = sanitize_settings(&Value::Null, &StoreContext::shipped()); + let mut out = BTreeMap::new(); + for def in registry.defs() { + let adapter = registry.adapter(&def.id).expect("adapter"); + let snapshot = adapter + .startup_snapshot(&backend) + .unwrap_or_else(|error| panic!("{scenario}/{}: {error}", def.id)); + let ctx = MenuContext { + current_account_id: Some(&snapshot.current_account), + stores: &stores, + input_dialog: true, + }; + for account in &snapshot.accounts { + assert!(!account.id.is_empty(), "{scenario}/{}: empty id", def.id); + let menu = build_account_context_menu( + account, + &adapter, + &ctx, + &AppearanceInput::default(), + None, + ); + assert!(!menu.is_empty(), "{scenario}/{}: empty menu", def.id); + } + if def.id == "riot" { + for account in &snapshot.accounts { + let profile = adapter.profile_info(&backend, &account.id).unwrap(); + assert!( + profile.is_some(), + "{scenario}/riot/{}: no profile", + account.id + ); + } + } + out.insert(def.id.clone(), snapshot); + } + // The Steam warnings run against the mock's bans without error. + let steam = registry.adapter("steam").unwrap(); + let accounts = out["steam"].accounts.clone(); + let mut stores = stores; + let load = steam + .load_warning_states_blocking( + &backend, + &mut stores, + &settings, + &accounts, + WarningLoadOptions::default(), + backend.now() as f64 * 1000.0, + ) + .expect("steam has warnings"); + // TS settles nothing for an empty list. + assert_eq!(load.outcome.is_some(), !accounts.is_empty(), "{scenario}"); + assert!(backend.platform_detect_installed().is_ok()); + out +} + +fn counts(model: &BTreeMap) -> Vec<(String, usize)> { + model + .iter() + .filter(|(_, snapshot)| !snapshot.accounts.is_empty()) + .map(|(id, snapshot)| (id.clone(), snapshot.accounts.len())) + .collect() +} + +fn expect(model: &BTreeMap, wanted: &[(&str, usize)]) { + let wanted: Vec<(String, usize)> = wanted + .iter() + .map(|(id, count)| (id.to_string(), *count)) + .collect(); + assert_eq!(counts(model), wanted); +} + +#[test] +fn scenario_demo() { + let model = build_model("demo"); + expect(&model, &[("riot", 3), ("roblox", 4), ("steam", 17)]); + assert_eq!(model["steam"].current_account, "76561198000000001"); + assert_eq!(model["riot"].current_account, "riot-1"); + assert_eq!(model["roblox"].current_account, "1000000001"); +} + +#[test] +fn scenario_dev() { + let model = build_model("dev"); + expect(&model, &[("riot", 4), ("steam", 8)]); + assert_eq!(model["steam"].current_account, "76561198100000001"); + assert_eq!(model["riot"].current_account, "riot-1"); +} + +#[test] +fn scenario_empty() { + let model = build_model("empty"); + expect(&model, &[]); +} + +#[test] +fn scenario_huge() { + let model = build_model("huge"); + expect(&model, &[("steam", 200)]); + assert_eq!(model["steam"].current_account, "76561199000000000"); +} + +#[test] +fn scenario_slow() { + let model = build_model("slow"); + expect(&model, &[("riot", 4), ("steam", 8)]); +} + +#[test] +fn every_scenario_is_covered() { + let root = support::repo_root(); + let scenarios = accshift_app::mock::MockBackend::scenarios( + &accshift_app::mock::MockBackend::data_dir(&root), + ); + assert_eq!(scenarios, ["demo", "dev", "empty", "huge", "slow"]); +} diff --git a/crates/accshift-app/tests/platform_registry.rs b/crates/accshift-app/tests/platform_registry.rs new file mode 100644 index 00000000..99d21005 --- /dev/null +++ b/crates/accshift-app/tests/platform_registry.rs @@ -0,0 +1,364 @@ +//! `registry.test.ts`, `descriptors.test.ts`, `genericAdapter.test.ts`, +//! `battle-net/adapter.test.ts` and `ubisoft/adapter.test.ts` ported. + +mod support; + +use accshift_app::platforms::adapter::AdapterKind; +use accshift_app::platforms::descriptors::{ + install_descriptor_file, preview_descriptor_file, reload_user_platforms, remove_descriptor, + render_template, select_descriptor_file, PlatformDescriptor, +}; +use accshift_app::platforms::generic::{ + default_to_account, mask_email, to_battle_net_account, to_ubisoft_account, GenericConfig, +}; +use accshift_app::platforms::registry::{accent_for, PlatformRegistry, CHROME_ORDER}; +use accshift_app::platforms::{resolve_path_placeholder, PathPlaceholder, RuntimeOs}; +use serde_json::{json, Value}; + +fn acme() -> PlatformDescriptor { + PlatformDescriptor::from_value(&json!({ + "id": "acme", + "name": "Acme Launcher", + "os": { + "windows": { + "executable": { + "candidates": [ + { "kind": "registry" }, + { "kind": "path", "template": "${ProgramFiles}/Acme/Acme.exe" } + ] + } + }, + "linux": {} + } + })) + .unwrap() +} + +// ------------------------------------------------ registry: shipped platforms + +#[test] +fn shipped_takes_description_from_descriptor() { + let registry = PlatformRegistry::shipped(); + let gog = registry.definition("gog").unwrap(); + assert_eq!(gog.name, "GOG Galaxy"); + assert_eq!(gog.supported_os, vec![RuntimeOs::Windows]); + assert!( + resolve_path_placeholder(gog.path_placeholder.as_ref(), RuntimeOs::Windows) + .contains("GalaxyClient.exe") + ); +} + +#[test] +fn shipped_keeps_platforms_no_descriptor_covers() { + let registry = PlatformRegistry::shipped(); + let steam = registry.definition("steam").unwrap(); + assert_eq!(steam.name, "Steam"); + assert_eq!( + steam.supported_os, + vec![RuntimeOs::Windows, RuntimeOs::Linux, RuntimeOs::Macos] + ); +} + +#[test] +fn shipped_none_user_provided() { + let registry = PlatformRegistry::shipped(); + assert!(registry.defs().iter().all(|def| !def.user_provided)); + // Display order is the chrome order. + let ids: Vec<&str> = registry.defs().iter().map(|def| def.id.as_str()).collect(); + assert_eq!(ids, CHROME_ORDER); +} + +// ------------------------------------------------ registry: user platforms + +#[test] +fn user_platform_with_no_entry() { + let mut registry = PlatformRegistry::shipped(); + registry.register_user_platforms(&[acme()]); + let def = registry.definition("acme").unwrap(); + assert_eq!(def.name, "Acme Launcher"); + assert!(def.user_provided); + assert!(def.implemented); + assert_eq!(def.supported_os, vec![RuntimeOs::Windows, RuntimeOs::Linux]); + assert!(def.accent.starts_with("hsl(")); + let adapter = registry.adapter("acme").unwrap(); + assert_eq!( + adapter.kind(), + &AdapterKind::Generic(GenericConfig::user("Acme Launcher")) + ); +} + +#[test] +fn user_platform_spells_path_like_the_system() { + let mut registry = PlatformRegistry::shipped(); + registry.register_user_platforms(&[acme()]); + let def = registry.definition("acme").unwrap(); + assert_eq!( + resolve_path_placeholder(def.path_placeholder.as_ref(), RuntimeOs::Windows), + "%ProgramFiles%\\Acme\\Acme.exe" + ); + assert_eq!( + def.path_placeholder, + Some(PathPlaceholder::PerOs(vec![( + RuntimeOs::Windows, + "%ProgramFiles%\\Acme\\Acme.exe".to_string() + )])) + ); +} + +#[test] +fn user_platform_disappears_with_its_file() { + let mut registry = PlatformRegistry::shipped(); + registry.register_user_platforms(&[acme()]); + registry.register_user_platforms(&[]); + assert!(registry.definition("acme").is_none()); + assert!(registry.adapter("acme").is_none()); + assert_eq!(registry.definition("gog").unwrap().name, "GOG Galaxy"); +} + +#[test] +fn user_platform_cannot_take_over_shipped() { + let mut registry = PlatformRegistry::shipped(); + let mut fake = acme(); + fake.id = "steam".to_string(); + fake.name = "Not Steam".to_string(); + registry.register_user_platforms(&[fake]); + assert_eq!(registry.definition("steam").unwrap().name, "Steam"); + assert_eq!( + registry + .defs() + .iter() + .filter(|def| def.id == "steam") + .count(), + 1 + ); + assert_eq!( + registry.adapter("steam").unwrap().kind(), + &AdapterKind::Steam + ); +} + +#[test] +fn user_platform_keeps_its_colour() { + let mut registry = PlatformRegistry::shipped(); + registry.register_user_platforms(&[acme()]); + let first = registry.definition("acme").unwrap().accent.clone(); + registry.register_user_platforms(&[]); + registry.register_user_platforms(&[acme()]); + assert_eq!(registry.definition("acme").unwrap().accent, first); + assert_eq!(first, accent_for("acme")); +} + +// ------------------------------------------------ descriptors: folder calls + +/// A full engine descriptor for `acme`: the embedded GOG one renamed, so it +/// decodes as the engine's own type. +fn acme_core() -> Value { + let (loaded, _) = accshift_core::platforms::descriptor::load_embedded(); + let gog = loaded + .iter() + .find(|descriptor| descriptor.id == "gog") + .unwrap(); + let mut value = serde_json::to_value(gog).unwrap(); + value["id"] = json!("acme"); + value["name"] = json!("Acme Launcher"); + value +} + +fn report(loaded: Vec) -> Value { + json!({ "dir": "C:/data/platforms", "loaded": loaded, "skipped": [], "rejected": [] }) +} + +#[test] +fn descriptors_reload_registers_folder() { + let backend = support::scripted("demo"); + backend.push("reload_user_platforms", Ok(report(vec![acme_core()]))); + let mut registry = PlatformRegistry::shipped(); + let result = reload_user_platforms(&mut registry, &backend).unwrap(); + assert_eq!(backend.calls("reload_user_platforms").len(), 1); + assert_eq!(result.dir, "C:/data/platforms"); + assert!(registry.definition("acme").unwrap().user_provided); +} + +#[test] +fn descriptors_remove_drops_platform() { + let backend = support::scripted("demo"); + backend.push("reload_user_platforms", Ok(report(vec![acme_core()]))); + backend.push("descriptor_remove", Ok(report(vec![]))); + let mut registry = PlatformRegistry::shipped(); + reload_user_platforms(&mut registry, &backend).unwrap(); + remove_descriptor(&mut registry, &backend, "acme").unwrap(); + assert_eq!( + backend.calls("descriptor_remove"), + vec!["(\"acme\",)".to_string()] + ); + assert!(registry.definition("acme").is_none()); +} + +#[test] +fn descriptors_install_registers_without_second_call() { + let backend = support::scripted("demo"); + backend.push("descriptor_install_file", Ok(report(vec![acme_core()]))); + let mut registry = PlatformRegistry::shipped(); + install_descriptor_file(&mut registry, &backend, "C:/downloads/acme.json").unwrap(); + assert_eq!(backend.calls("descriptor_install_file").len(), 1); + assert!(backend.calls("reload_user_platforms").is_empty()); + assert_eq!(registry.definition("acme").unwrap().name, "Acme Launcher"); +} + +#[test] +fn descriptors_cancelled_picker_is_nothing() { + let backend = support::scripted("demo"); + backend.push("descriptor_select_file", Err("cancelled")); + assert_eq!(select_descriptor_file(&backend), None); +} + +#[test] +fn descriptors_rejection_reaches_caller() { + let backend = support::scripted("demo"); + backend.push( + "descriptor_preview_file", + Err("acme.json: schemaVersion: expected 1"), + ); + let error = preview_descriptor_file(&backend, "C:/downloads/acme.json").unwrap_err(); + assert_eq!(error.message, "acme.json: schemaVersion: expected 1"); +} + +// ------------------------------------------------ genericAdapter + +#[test] +fn generic_converts_ms_to_seconds() { + let account = default_to_account(&json!({ + "accountId": "acct-1", "label": "main", "lastUsedAt": 1_760_000_000_999u64 + })); + assert_eq!(account.last_login_at_sec, Some(1_760_000_000.0)); +} + +#[test] +fn generic_missing_stamp_is_none() { + assert_eq!( + default_to_account(&json!({ "accountId": "acct-1", "label": "main" })).last_login_at_sec, + None + ); + assert_eq!( + default_to_account(&json!({ "accountId": "acct-1", "label": "main", "lastUsedAt": null })) + .last_login_at_sec, + None + ); +} + +#[test] +fn generic_empty_label_falls_back_to_id() { + let account = default_to_account(&json!({ "accountId": "acct-1", "label": "" })); + assert_eq!(account.display_name, "acct-1"); +} + +// ------------------------------------------------ battle-net + +#[test] +fn battle_net_converts_ms_to_seconds() { + let account = to_battle_net_account(&json!({ + "email": "player@example.com", "battleTag": "Player#1234", "lastLoginAt": 1_760_000_000_999u64 + })); + assert_eq!(account.last_login_at_sec, Some(1_760_000_000.0)); +} + +#[test] +fn battle_net_missing_stamp_is_none() { + assert_eq!( + to_battle_net_account(&json!({ "email": "player@example.com" })).last_login_at_sec, + None + ); + assert_eq!( + to_battle_net_account(&json!({ "email": "player@example.com", "lastLoginAt": null })) + .last_login_at_sec, + None + ); +} + +#[test] +fn battle_net_discovered_account_without_metadata() { + let account = to_battle_net_account(&json!({ + "email": "fresh@example.com", "battleTag": "", "lastLoginAt": null + })); + assert_eq!(account.display_name, "fresh"); + assert_eq!(account.last_login_at_sec, None); +} + +#[test] +fn battle_net_prefers_battle_tag_name() { + let account = + to_battle_net_account(&json!({ "email": "player@example.com", "battleTag": "Nova#1234" })); + assert_eq!(account.display_name, "Nova"); + assert_eq!(account.id, "player@example.com"); +} + +// ------------------------------------------------ ubisoft + +#[test] +fn ubisoft_converts_ms_to_seconds() { + let account = to_ubisoft_account(&json!({ + "uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "label": "main", + "lastUsedAt": 1_760_000_000_999u64, "snapshotSaved": true + })); + assert_eq!(account.last_login_at_sec, Some(1_760_000_000.0)); +} + +#[test] +fn ubisoft_missing_stamp_is_none() { + let account = to_ubisoft_account(&json!({ + "uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "label": "main", "snapshotSaved": false + })); + assert_eq!(account.last_login_at_sec, None); +} + +#[test] +fn ubisoft_shortens_uuid_without_label() { + let account = to_ubisoft_account(&json!({ + "uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "label": "", "snapshotSaved": false + })); + assert_eq!(account.display_name, "aaaaaaaa"); +} + +// ------------------------------------------------ Rust-only checks + +#[test] +fn render_template_per_system() { + let template = "${LOCALAPPDATA}/Acme\\${ProgramFiles(x86)}/x"; + assert_eq!( + render_template(template, RuntimeOs::Windows), + "%LOCALAPPDATA%\\Acme\\%ProgramFiles(x86)%\\x" + ); + assert_eq!( + render_template(template, RuntimeOs::Linux), + "$LOCALAPPDATA/Acme/$ProgramFiles(x86)/x" + ); + // Not a variable: left as written. + assert_eq!(render_template("${}/a", RuntimeOs::Linux), "${}/a"); +} + +#[test] +fn battle_net_masks_emails_in_switch_logs() { + assert_eq!(mask_email("player@example.com"), "pla\u{2026}"); + let config = GenericConfig::shipped("battle-net").unwrap(); + assert_eq!( + config.mask_switch_log_id("ab@x.y"), + Some("ab\u{2026}".to_string()) + ); + assert!(!config.supports_account_labels()); + assert_eq!( + GenericConfig::shipped("gog") + .unwrap() + .mask_switch_log_id("a"), + None + ); +} + +#[test] +fn every_shipped_platform_has_an_adapter() { + let registry = PlatformRegistry::shipped(); + for def in registry.defs() { + let adapter = registry.adapter(&def.id).unwrap(); + assert_eq!(adapter.id(), def.id); + assert!(adapter.no_accounts_toast().is_some(), "{}", def.id); + } +} diff --git a/crates/accshift-app/tests/platform_shared.rs b/crates/accshift-app/tests/platform_shared.rs new file mode 100644 index 00000000..c7958035 --- /dev/null +++ b/crates/accshift-app/tests/platform_shared.rs @@ -0,0 +1,358 @@ +//! `shared/platformPathError.test.ts`, `shared/contextMenu/ +//! accountFolderActions.test.ts`, `riot/adapter.test.ts` and +//! `roblox/adapter.test.ts` ported, plus checks of the shared menu model. + +mod support; + +use accshift_app::i18n::{translate, Locale}; +use accshift_app::menu::account::{ + account_folder_actions, build_account_context_menu, AccountFolderChoice, AppearanceInput, + FolderInput, +}; +use accshift_app::menu::{ + build_context_menu_items, MenuAction, MenuCommand, MenuEntry, PlatformTask, + ACCOUNT_CONTEXT_GROUP_ORDER, +}; +use accshift_app::platforms::adapter::{MenuContext, PlatformAdapter}; +use accshift_app::platforms::path_error::{parse_platform_path_error, platform_path_error_message}; +use accshift_app::platforms::riot::{to_riot_account, RiotProfile}; +use accshift_app::platforms::roblox::{is_session_expired_error, to_roblox_account}; +use accshift_app::platforms::PlatformAccount; +use accshift_app::storage::ClientStorage; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::text::Text; +use serde_json::json; + +const GENERIC: &str = "Couldn't save the Steam path"; + +fn en(text: &Text) -> String { + text.resolve(Locale::En) +} + +// ------------------------------------------------ platformPathError.test.ts + +#[test] +fn path_error_splits_code_and_fallback() { + let parsed = parse_platform_path_error( + "steam_path_never_signed_in|Steam found, sign in once so it creates its login history", + ); + assert_eq!(parsed.code.as_deref(), Some("steam_path_never_signed_in")); + assert_eq!( + parsed.fallback, + "Steam found, sign in once so it creates its login history" + ); +} + +#[test] +fn path_error_plain_message_has_no_code() { + let parsed = parse_platform_path_error("Could not locate Steam installation"); + assert_eq!(parsed.code, None); + assert_eq!(parsed.fallback, "Could not locate Steam installation"); +} + +#[test] +fn path_error_prose_pipe_is_not_a_code() { + let message = "Could not write config | retry as administrator"; + let parsed = parse_platform_path_error(message); + assert_eq!(parsed.code, None); + assert_eq!(parsed.fallback, message); +} + +#[test] +fn path_error_non_string_rejection() { + // `String(new Error(...))` and `undefined`, as the backend hands them. + assert_eq!( + parse_platform_path_error("Error: steam_path_not_steam|nope").code, + None + ); + let empty = parse_platform_path_error(""); + assert_eq!(empty.code, None); + assert_eq!(empty.fallback, ""); +} + +#[test] +fn path_error_never_signed_in_translated() { + let message = platform_path_error_message( + "steam_path_never_signed_in|Steam found, sign in once so it creates its login history", + Text::raw(GENERIC), + ); + assert_eq!( + en(&message), + "Steam found, sign in once so it creates its login history" + ); +} + +#[test] +fn path_error_other_codes_translated() { + assert_eq!( + en(&platform_path_error_message( + "steam_path_not_steam|whatever", + Text::raw(GENERIC) + )), + translate(Locale::En, "settings.pathNotSteamFolder") + ); + assert_eq!( + en(&platform_path_error_message( + "steam_path_not_a_directory|whatever", + Text::raw(GENERIC) + )), + translate(Locale::En, "settings.pathNotADirectory") + ); +} + +#[test] +fn path_error_unknown_code_is_generic() { + assert_eq!( + en(&platform_path_error_message( + "steam_path_from_the_future|nope", + Text::raw(GENERIC) + )), + GENERIC + ); +} + +#[test] +fn path_error_uncoded_failure_is_generic() { + assert_eq!( + en(&platform_path_error_message( + "disk full", + Text::raw(GENERIC) + )), + GENERIC + ); +} + +// ------------------------------------------------ accountFolderActions.test.ts + +fn acc1() -> PlatformAccount { + PlatformAccount { + id: "acc-1".to_string(), + display_name: "Main".to_string(), + username: "main".to_string(), + last_login_at_sec: None, + } +} + +fn folders(current: Option<&str>, list: &[(&str, &str)]) -> FolderInput { + FolderInput { + folders: list + .iter() + .map(|(id, label)| AccountFolderChoice { + id: id.to_string(), + label: label.to_string(), + }) + .collect(), + current_folder_id: current.map(str::to_string), + } +} + +const TWO: [(&str, &str); 2] = [("f1", "Smurfs"), ("f2", "Smurfs / Old")]; + +fn labels(actions: &[MenuAction]) -> Vec { + actions[0] + .submenu_actions() + .unwrap() + .iter() + .map(|action| match &action.label { + Text::Raw(raw) => raw.clone(), + Text::Key { key, .. } => key.clone(), + }) + .collect() +} + +#[test] +fn folder_lists_every_folder_at_root() { + let actions = account_folder_actions(&acc1(), &folders(None, &TWO)); + assert_eq!(actions.len(), 1); + assert_eq!(labels(&actions), ["Smurfs", "Smurfs / Old"]); +} + +#[test] +fn folder_offers_root_and_hides_current() { + let actions = account_folder_actions(&acc1(), &folders(Some("f1"), &TWO)); + assert_eq!( + labels(&actions), + ["context.menu.moveToRoot", "Smurfs / Old"] + ); +} + +#[test] +fn folder_nothing_without_folders_at_root() { + assert!(account_folder_actions(&acc1(), &folders(None, &[])).is_empty()); +} + +#[test] +fn folder_root_is_only_way_out() { + let actions = account_folder_actions(&acc1(), &folders(Some("f1"), &[("f1", "Smurfs")])); + assert_eq!(labels(&actions), ["context.menu.moveToRoot"]); +} + +#[test] +fn folder_moves_to_picked_and_root() { + let actions = account_folder_actions(&acc1(), &folders(Some("f1"), &TWO)); + let submenu = actions[0].submenu_actions().unwrap(); + assert_eq!( + submenu[0].command(), + Some(&MenuCommand::MoveToFolder { + account_id: "acc-1".to_string(), + folder_id: None + }) + ); + assert_eq!( + submenu[1].command(), + Some(&MenuCommand::MoveToFolder { + account_id: "acc-1".to_string(), + folder_id: Some("f2".to_string()) + }) + ); +} + +// ------------------------------------------------ riot/adapter.test.ts + +fn riot(extra: serde_json::Value) -> RiotProfile { + let mut base = json!({ "id": "riot-1", "label": "main", "snapshot_state": "ready" }); + for (key, value) in extra.as_object().unwrap() { + base[key] = value.clone(); + } + RiotProfile::from_value(&base).unwrap() +} + +#[test] +fn riot_converts_ms_to_seconds() { + let account = to_riot_account(&riot(json!({ "last_used_at": 1_760_000_000_999u64 }))); + assert_eq!(account.last_login_at_sec, Some(1_760_000_000.0)); +} + +#[test] +fn riot_falls_back_to_capture_stamp() { + let account = to_riot_account(&riot(json!({ "last_captured_at": 1_759_000_000_500u64 }))); + assert_eq!(account.last_login_at_sec, Some(1_759_000_000.0)); +} + +#[test] +fn riot_missing_stamp_is_none() { + assert_eq!(to_riot_account(&riot(json!({}))).last_login_at_sec, None); +} + +// ------------------------------------------------ roblox/adapter.test.ts + +#[test] +fn roblox_converts_ms_to_seconds() { + let account = to_roblox_account(&json!({ + "userId": "1234", "username": "player", "displayName": "Player", + "lastLoginAt": 1_760_000_000_999u64 + })); + assert_eq!(account.last_login_at_sec, Some(1_760_000_000.0)); +} + +#[test] +fn roblox_missing_stamp_is_none() { + let account = to_roblox_account( + &json!({ "userId": "1234", "username": "player", "displayName": "Player" }), + ); + assert_eq!(account.last_login_at_sec, None); +} + +// ------------------------------------------------ Rust-only: shared menu model + +#[test] +fn riot_alias_and_status_labels() { + let pending = to_riot_account(&riot(json!({ + "snapshot_state": "setup_pending", "account_name": "Nova", "account_tag_line": "EUW" + }))); + assert_eq!(pending.display_name, "Nova#EUW"); + assert_eq!(pending.username, "main \u{b7} Waiting for connection"); + let awaiting = to_riot_account(&riot(json!({ "snapshot_state": "awaiting_capture" }))); + assert_eq!(awaiting.display_name, "main"); + assert_eq!(awaiting.username, "Capture required"); +} + +#[test] +fn roblox_session_expiry_pattern() { + assert!(is_session_expired_error( + "Auth ticket request failed (HTTP 401 Unauthorized)" + )); + assert!(!is_session_expired_error( + "auth ticket request failed (http 500" + )); +} + +#[test] +fn groups_sort_and_separate() { + let close = || MenuCommand::Close; + let actions = vec![ + MenuAction::item("a", Some("platform.danger"), Text::key("a"), close()), + MenuAction::item("b", Some("custom"), Text::key("b"), close()), + MenuAction::item("c", Some("platform.primary"), Text::key("c"), close()), + MenuAction::item("d", None, Text::key("d"), close()), + MenuAction::item("e", Some("platform.danger"), Text::key("e"), close()), + ]; + let entries = build_context_menu_items(actions, &ACCOUNT_CONTEXT_GROUP_ORDER); + let shape: Vec<&str> = entries + .iter() + .map(|entry| entry.id().unwrap_or("|")) + .collect(); + assert_eq!(shape, ["c", "|", "a", "e", "|", "b", "|", "d"]); + assert!(entries + .iter() + .filter(|e| !matches!(e, MenuEntry::Separator)) + .all(MenuEntry::is_enabled)); +} + +#[test] +fn account_menu_rename_copy_and_appearance() { + let stores = ClientStores::new(ClientStorage::new(), StoreContext::shipped()); + let ctx = MenuContext { + current_account_id: None, + stores: &stores, + input_dialog: true, + }; + let gog = PlatformAdapter::shipped("gog").unwrap(); + let account = PlatformAccount { + id: "g1".to_string(), + display_name: "Gamer".to_string(), + username: "g1".to_string(), + last_login_at_sec: None, + }; + let entries = build_account_context_menu( + &account, + &gog, + &ctx, + &AppearanceInput::default(), + Some(&folders(None, &TWO)), + ); + let ids: Vec<&str> = entries.iter().map(|e| e.id().unwrap_or("|")).collect(); + assert_eq!( + ids, + [ + "platform.rename.g1", + "|", + "account.folder.g1", + "|", + "platform.copy.root", + "|", + "gog.forget.g1", + "|", + "account.appearance.g1", + ] + ); + let MenuEntry::Item { command, .. } = &entries[0] else { + panic!("rename"); + }; + let Some(MenuCommand::Input(request)) = command else { + panic!("input"); + }; + assert_eq!( + request.confirm("New"), + PlatformTask::SetAccountLabel { + platform_id: "gog".to_string(), + account_id: "g1".to_string(), + label: "New".to_string() + } + ); + // Battle.net has no labels: no rename entry. + let bnet = PlatformAdapter::shipped("battle-net").unwrap(); + let entries = + build_account_context_menu(&account, &bnet, &ctx, &AppearanceInput::default(), None); + assert_ne!(entries[0].id(), Some("platform.rename.g1")); +} diff --git a/crates/accshift-app/tests/platform_shell.rs b/crates/accshift-app/tests/platform_shell.rs new file mode 100644 index 00000000..47e641aa --- /dev/null +++ b/crates/accshift-app/tests/platform_shell.rs @@ -0,0 +1,279 @@ +//! The platform shell state (`platformShell.svelte.ts`) and the CS2 bridge +//! queue (`cs2Bridge.test.ts`) against MockBackend. + +mod support; + +use accshift_app::backend::Backend; +use accshift_app::cs2_bridge::Cs2BridgeQueue; +use accshift_app::jobs::Jobs; +use accshift_app::platform_shell::{ + initial_active_tab, is_platform_compatible_with_os, PlatformShell, +}; +use accshift_app::platforms::{PlatformAdapter, PlatformRegistry, RuntimeOs}; +use accshift_app::settings::{sanitize_settings, AppSettings}; +use accshift_app::stores::StoreContext; +use serde_json::{json, Value}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +fn settings(patch: Value) -> AppSettings { + sanitize_settings(&patch, &StoreContext::shipped()) +} + +// ------------------------------------------------------------ shell + +#[test] +fn the_initial_tab_prefers_the_enabled_usable_default() { + let registry = PlatformRegistry::shipped(); + let both = settings(json!({ + "enabledPlatforms": ["steam", "riot"], + "defaultPlatformId": "riot", + })); + assert_eq!( + initial_active_tab(®istry, &both, RuntimeOs::Windows), + "riot" + ); + // The default is not enabled: the first enabled usable one. + let not_enabled = settings(json!({ + "enabledPlatforms": ["steam"], + "defaultPlatformId": "riot", + })); + assert_eq!( + initial_active_tab(®istry, ¬_enabled, RuntimeOs::Windows), + "steam" + ); + // The OS is unknown at boot: nothing is usable, the first enabled wins. + assert_eq!( + initial_active_tab(®istry, &both, RuntimeOs::Unknown), + "steam" + ); +} + +#[test] +fn compatibility_follows_the_supported_os_list() { + let registry = PlatformRegistry::shipped(); + let steam = registry.definition("steam"); + assert!(is_platform_compatible_with_os(steam, RuntimeOs::Windows)); + assert!(!is_platform_compatible_with_os(steam, RuntimeOs::Unknown)); + assert!(!is_platform_compatible_with_os(None, RuntimeOs::Windows)); +} + +#[test] +fn ensure_active_tab_moves_off_an_unusable_tab() { + let registry = PlatformRegistry::shipped(); + let windows_only = registry + .defs() + .iter() + .find(|def| { + def.implemented && def.supports(RuntimeOs::Windows) && !def.supports(RuntimeOs::Linux) + }) + .map(|def| def.id.clone()); + let mut shell = PlatformShell::new( + ®istry, + settings(json!({ "enabledPlatforms": ["steam", "riot"], "defaultPlatformId": "steam" })), + ); + assert_eq!(shell.active_tab(), "steam"); + assert!( + shell.adapter(®istry).is_none(), + "nothing usable on an unknown OS" + ); + shell.set_runtime_os(RuntimeOs::Windows); + assert!(!shell.ensure_active_tab(®istry)); + assert!(shell.active_tab_usable(®istry)); + assert_eq!( + shell.adapter(®istry).map(|a| a.id().to_string()), + Some("steam".into()) + ); + assert_eq!( + shell.accent_color(®istry), + Some(registry.definition("steam").unwrap().accent.clone()) + ); + + if let Some(id) = windows_only { + shell.set_active_tab(id.clone()); + shell.set_runtime_os(RuntimeOs::Linux); + assert!(shell.ensure_active_tab(®istry)); + assert_ne!(shell.active_tab(), id); + assert!(shell.active_tab_usable(®istry)); + } +} + +#[test] +fn derived_lists_zoom_and_unavailable_ids() { + let registry = PlatformRegistry::shipped(); + let mut shell = PlatformShell::new( + ®istry, + settings(json!({ "enabledPlatforms": ["riot", "steam"], "uiScalePercent": 200 })), + ); + shell.set_runtime_os(RuntimeOs::Unknown); + // Shipped order, not the settings order. + let enabled: Vec<&str> = shell + .enabled_platforms(®istry) + .iter() + .map(|def| def.id.as_str()) + .collect(); + assert_eq!(enabled, vec!["steam", "riot"]); + assert_eq!( + shell + .unavailable_platform_ids(®istry) + .into_iter() + .collect::>(), + vec!["riot".to_string(), "steam".to_string()] + ); + shell.set_runtime_os(RuntimeOs::Windows); + assert!(shell.unavailable_platform_ids(®istry).is_empty()); + assert!(!shell.compatible_platforms(®istry).is_empty()); + assert_eq!(shell.ui_zoom_factor(), 1.5); + assert_eq!(shell.app_stage_zoom(), Some(1.5)); + let mut next = shell.settings().clone(); + next.ui_scale_percent = 100; + shell.refresh_settings(&next); + assert_eq!(shell.app_stage_zoom(), None); + next.ui_scale_percent = 10; + shell.set_settings(next); + assert_eq!(shell.ui_zoom_factor(), 0.75); +} + +// ------------------------------------------------------------ CS2 bridge queue + +fn account(steam_id: &str) -> Value { + json!({ "steamId": steam_id, "level": 10, "xp": 1200, "xpMax": 5000, "caseEarned": false }) +} + +struct Bridge { + backend: Arc, + jobs: Jobs, + steam: Arc, + queue: Cs2BridgeQueue, +} + +impl Bridge { + fn new() -> Self { + let backend = Arc::new(support::scripted("dev")); + let jobs = Jobs::new(backend.clone() as Arc, 2, || {}); + let steam = PlatformRegistry::shipped().adapter("steam").unwrap(); + Self { + backend, + jobs, + steam, + queue: Cs2BridgeQueue::new(), + } + } + + fn enabled(&self) { + self.backend.push( + "cs2_bridge_get_settings", + Ok(json!({ "enabled": true, "url": "http://127.0.0.1:1", "tokenConfigured": true })), + ); + } + + /// One frame. + fn tick(&mut self) { + self.queue.tick(&self.jobs, &self.steam, 1_000.0); + } + + fn settle(&mut self) { + let deadline = Instant::now() + Duration::from_secs(10); + while self.queue.is_busy() { + assert!(Instant::now() < deadline, "bridge queue never drained"); + self.tick(); + std::thread::sleep(Duration::from_millis(2)); + } + } + + fn has(&self, steam_id: &str) -> bool { + self.steam.state().cs2.data(steam_id).is_some() + } +} + +#[test] +fn rapid_checks_run_one_at_a_time_in_order() { + let mut bridge = Bridge::new(); + bridge.enabled(); + bridge.backend.push("cs2_bridge_check", Ok(account("one"))); + bridge.backend.push("cs2_bridge_check", Ok(account("two"))); + bridge.queue.check("one"); + bridge.queue.check("two"); + // The first frame sends only the first check. + bridge.tick(); + std::thread::sleep(Duration::from_millis(50)); + assert!(bridge.backend.calls("cs2_bridge_check").len() <= 1); + bridge.settle(); + assert_eq!( + bridge.backend.calls("cs2_bridge_check"), + vec![r#"("one",)"#, r#"("two",)"#] + ); + assert!(bridge.has("one") && bridge.has("two")); + // The settings were read once, by the first check. + assert_eq!(bridge.backend.calls("cs2_bridge_get_settings").len(), 1); + let logs = bridge.backend.calls("log_app_event").join("\n"); + assert!(logs.contains("Bridge check applied")); +} + +#[test] +fn a_check_waits_for_an_in_flight_full_fetch() { + let mut bridge = Bridge::new(); + bridge.enabled(); + bridge + .backend + .push("cs2_bridge_fetch", Ok(json!([account("stale")]))); + bridge + .backend + .push("cs2_bridge_check", Ok(account("fresh"))); + let cache = bridge.steam.state().cs2.clone(); + assert!(bridge.queue.load(&cache, true, 1_000.0)); + // A second load while one is in flight is the same load. + assert!(!bridge.queue.load(&cache, true, 1_000.0)); + bridge.queue.check("fresh"); + bridge.settle(); + assert_eq!(bridge.backend.calls("cs2_bridge_fetch").len(), 1); + assert_eq!(bridge.backend.calls("cs2_bridge_check").len(), 1); + assert!(bridge.has("stale") && bridge.has("fresh")); +} + +#[test] +fn a_disabled_bridge_asks_nothing_more() { + let mut bridge = Bridge::new(); + bridge.backend.push( + "cs2_bridge_get_settings", + Ok(json!({ "enabled": false, "url": "", "tokenConfigured": false })), + ); + bridge.queue.check("one"); + bridge.queue.check("two"); + bridge.settle(); + assert!(bridge.backend.calls("cs2_bridge_check").is_empty()); + // Known disabled: no second settings read. + assert_eq!(bridge.backend.calls("cs2_bridge_get_settings").len(), 1); +} + +#[test] +fn a_failed_check_is_silent_and_the_queue_goes_on() { + let mut bridge = Bridge::new(); + bridge.enabled(); + bridge.backend.push("cs2_bridge_check", Err("bridge down")); + bridge.backend.push("cs2_bridge_check", Ok(account("two"))); + bridge.queue.check("one"); + bridge.queue.check("two"); + bridge.settle(); + assert!(!bridge.has("one") && bridge.has("two")); + let logs = bridge.backend.calls("log_app_event").join("\n"); + assert!(logs.contains("Bridge check failed")); +} + +#[test] +fn the_steam_tab_loads_once_per_ttl() { + let mut bridge = Bridge::new(); + bridge.enabled(); + bridge + .backend + .push("cs2_bridge_fetch", Ok(json!([account("a")]))); + let cache = bridge.steam.state().cs2.clone(); + assert!(!bridge.queue.ensure_loaded(&cache, "riot", 3, 1_000.0)); + assert!(!bridge.queue.ensure_loaded(&cache, "steam", 0, 1_000.0)); + assert!(bridge.queue.ensure_loaded(&cache, "steam", 3, 1_000.0)); + bridge.settle(); + assert!(bridge.has("a")); + // Fresh: the next show does nothing. + let cache = bridge.steam.state().cs2.clone(); + assert!(!bridge.queue.ensure_loaded(&cache, "steam", 3, 2_000.0)); +} diff --git a/crates/accshift-app/tests/platform_steam.rs b/crates/accshift-app/tests/platform_steam.rs new file mode 100644 index 00000000..dfe53884 --- /dev/null +++ b/crates/accshift-app/tests/platform_steam.rs @@ -0,0 +1,570 @@ +//! Steam: `adapter.test.ts`, `friendCode.test.ts`, `steamIdUtils.test.ts`, +//! `warnings.test.ts`, `steamApi.test.ts` and `shared/url.test.ts` ported, +//! plus checks of the menu and task model. + +mod support; + +use accshift_app::menu::{MenuActionBody, MenuCommand, PlatformTask, SubmenuLoader, TaskEffect}; +use accshift_app::platforms::adapter::{MenuContext, PlatformAdapter}; +use accshift_app::platforms::steam::api::ApiKeyMemo; +use accshift_app::platforms::steam::context_menu::steam_context_menu_actions; +use accshift_app::platforms::steam::friend_code::encode_friend_code; +use accshift_app::platforms::steam::warnings::{ + already_checked_ban_ids, load_blocking, AlreadyChecked, BanSession, +}; +use accshift_app::platforms::steam::{is_safe_http_url, to_profile_url, to_steam_account}; +use accshift_app::platforms::{PlatformAccount, WarningLoadOptions, WarningRefreshOutcome}; +use accshift_app::settings::{sanitize_settings, AppSettings}; +use accshift_app::storage::{ClientStorage, CLIENT_STORE_STEAM_BAN_CHECK_STATE}; +use accshift_app::stores::{ClientStores, DefaultGame, StoreContext}; +use accshift_app::text::Text; +use serde_json::{json, Value}; + +fn settings(ban_check_days: i64) -> AppSettings { + let mut settings = sanitize_settings(&Value::Null, &StoreContext::shipped()); + settings.data_refresh.ban_check_days = ban_check_days; + settings +} + +fn stores() -> ClientStores { + ClientStores::new(ClientStorage::new(), StoreContext::shipped()) +} + +fn account(id: &str) -> PlatformAccount { + PlatformAccount { + id: id.to_string(), + display_name: id.to_string(), + username: id.to_string(), + last_login_at_sec: None, + } +} + +fn ban_row(steam_id: &str) -> Value { + json!({ + "steam_id": steam_id, + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }) +} + +const QUIET: WarningLoadOptions = WarningLoadOptions { + force_refresh: false, + silent: true, +}; +const FORCED: WarningLoadOptions = WarningLoadOptions { + force_refresh: true, + silent: true, +}; +const NOW_MS: f64 = 1_791_000_000_000.0; + +// ------------------------------------------------ adapter.test.ts + +#[test] +fn steam_passes_seconds_through() { + let account = to_steam_account(&json!({ + "steam_id": "76561198000000001", "account_name": "player", + "persona_name": "Player", "last_login_at": 1_760_000_000u64 + })); + assert_eq!(account.last_login_at_sec, Some(1_760_000_000.0)); +} + +#[test] +fn steam_missing_stamp_is_none() { + let account = to_steam_account(&json!({ + "steam_id": "76561198000000001", "account_name": "player", "persona_name": "Player" + })); + assert_eq!(account.last_login_at_sec, None); +} + +// ------------------------------------------------ friendCode.test.ts + +#[test] +fn friend_code_known_vector() { + assert_eq!(encode_friend_code("76561197960287930"), "SUCVS-FADA"); +} + +#[test] +fn friend_code_stable() { + let id = "76561198123456789"; + assert_eq!(encode_friend_code(id), encode_friend_code(id)); +} + +#[test] +fn friend_code_non_numeric_is_empty() { + assert_eq!(encode_friend_code("some-junk-key"), ""); +} + +#[test] +fn friend_code_empty_is_zero() { + assert_eq!(encode_friend_code(""), encode_friend_code("0")); +} + +#[test] +fn friend_code_decimal_is_empty() { + assert_eq!(encode_friend_code("1.5"), ""); +} + +// ------------------------------------------------ steamIdUtils.test.ts + +#[test] +fn profile_url_for_steam_id() { + assert_eq!( + to_profile_url("76561198123456789"), + "https://steamcommunity.com/profiles/76561198123456789" + ); +} + +#[test] +fn profile_url_for_any_string() { + assert_eq!( + to_profile_url("12345"), + "https://steamcommunity.com/profiles/12345" + ); +} + +// ------------------------------------------------ warnings.test.ts + +#[test] +fn already_checked_forced_refresh_checks_everything() { + assert_eq!( + already_checked_ban_ids(true, 7, true), + AlreadyChecked::Nothing + ); + assert_eq!( + already_checked_ban_ids(true, 0, true), + AlreadyChecked::Nothing + ); +} + +#[test] +fn already_checked_session_without_delay() { + assert_eq!( + already_checked_ban_ids(false, 0, true), + AlreadyChecked::Session + ); +} + +#[test] +fn already_checked_cached_inside_window() { + assert_eq!( + already_checked_ban_ids(false, 7, true), + AlreadyChecked::Cached + ); +} + +#[test] +fn already_checked_lapsed_window_checks_everything() { + assert_eq!( + already_checked_ban_ids(false, 7, false), + AlreadyChecked::Nothing + ); +} + +#[test] +fn bans_asks_again_for_an_omitted_id() { + let backend = support::scripted("demo"); + let (mut session, mut memo, mut stores) = + (BanSession::default(), ApiKeyMemo::default(), stores()); + let settings = settings(0); + let accounts = [account("sess-kept"), account("sess-miss")]; + backend.push("steam_has_api_key", Ok(json!(true))); + backend.push("steam_get_player_bans", Ok(json!([ban_row("sess-kept")]))); + load_blocking( + &mut session, + &mut memo, + &backend, + &mut stores, + &settings, + &accounts, + FORCED, + NOW_MS, + ); + backend.clear_log(); + backend.push("steam_get_player_bans", Ok(json!([ban_row("sess-miss")]))); + load_blocking( + &mut session, + &mut memo, + &backend, + &mut stores, + &settings, + &accounts, + QUIET, + NOW_MS, + ); + assert_eq!( + backend.calls("steam_get_player_bans"), + vec!["([\"sess-miss\"],)".to_string()] + ); +} + +#[test] +fn bans_omitted_id_not_persisted() { + let backend = support::scripted("demo"); + let (mut session, mut memo, mut stores) = + (BanSession::default(), ApiKeyMemo::default(), stores()); + backend.push("steam_has_api_key", Ok(json!(true))); + backend.push("steam_get_player_bans", Ok(json!([ban_row("disk-kept")]))); + load_blocking( + &mut session, + &mut memo, + &backend, + &mut stores, + &settings(7), + &[account("disk-kept"), account("disk-miss")], + FORCED, + NOW_MS, + ); + let persisted = stores + .storage() + .get(CLIENT_STORE_STEAM_BAN_CHECK_STATE) + .cloned() + .unwrap(); + assert_eq!(persisted["checkedSteamIds"], json!(["disk-kept"])); +} + +#[test] +fn bans_report_how_a_load_ended() { + let backend = support::scripted("demo"); + let settings = settings(0); + let mut session = BanSession::default(); + let mut stores = stores(); + + let mut memo = ApiKeyMemo::default(); + backend.push("steam_has_api_key", Ok(json!(false))); + let load = load_blocking( + &mut session, + &mut memo, + &backend, + &mut stores, + &settings, + &[account("out-a")], + FORCED, + NOW_MS, + ); + assert_eq!(load.outcome, Some(WarningRefreshOutcome::NoApiKey)); + + // A failed key lookup says nothing about the key: a failure, not a skip. + let mut memo = ApiKeyMemo::default(); + backend.push("steam_has_api_key", Err("ipc down")); + let load = load_blocking( + &mut session, + &mut memo, + &backend, + &mut stores, + &settings, + &[account("out-a")], + FORCED, + NOW_MS, + ); + assert_eq!(load.outcome, Some(WarningRefreshOutcome::Failed)); + + let mut memo = ApiKeyMemo::default(); + backend.push("steam_has_api_key", Ok(json!(true))); + backend.push("steam_get_player_bans", Ok(json!([ban_row("out-a")]))); + let load = load_blocking( + &mut session, + &mut memo, + &backend, + &mut stores, + &settings, + &[account("out-a"), account("out-b")], + FORCED, + NOW_MS, + ); + assert_eq!( + load.outcome, + Some(WarningRefreshOutcome::Partial { + checked: 1, + requested: 2 + }) + ); + + backend.push("steam_get_player_bans", Err("down")); + let load = load_blocking( + &mut session, + &mut memo, + &backend, + &mut stores, + &settings, + &[account("out-a")], + FORCED, + NOW_MS, + ); + assert_eq!(load.outcome, Some(WarningRefreshOutcome::Failed)); +} + +#[test] +fn bans_stop_after_two_omissions_until_forced() { + let backend = support::scripted("demo"); + let settings = settings(0); + let (mut session, mut memo, mut stores) = + (BanSession::default(), ApiKeyMemo::default(), stores()); + let accounts = [account("gone-id")]; + backend.push("steam_has_api_key", Ok(json!(true))); + for _ in 0..2 { + backend.push("steam_get_player_bans", Ok(json!([]))); + load_blocking( + &mut session, + &mut memo, + &backend, + &mut stores, + &settings, + &accounts, + QUIET, + NOW_MS, + ); + } + backend.clear_log(); + load_blocking( + &mut session, + &mut memo, + &backend, + &mut stores, + &settings, + &accounts, + QUIET, + NOW_MS, + ); + assert!(backend.calls("steam_get_player_bans").is_empty()); + + backend.push("steam_get_player_bans", Ok(json!([]))); + load_blocking( + &mut session, + &mut memo, + &backend, + &mut stores, + &settings, + &accounts, + FORCED, + NOW_MS, + ); + assert_eq!( + backend.calls("steam_get_player_bans"), + vec!["([\"gone-id\"],)".to_string()] + ); +} + +// ------------------------------------------------ steamApi.test.ts + +#[test] +fn api_key_decrypts_once_then_memo() { + let backend = support::scripted("demo"); + let adapter = PlatformAdapter::shipped("steam").unwrap(); + backend.push("steam_has_api_key", Ok(json!(true))); + assert!(adapter.steam_has_api_key(&backend).unwrap()); + assert!(adapter.steam_has_api_key(&backend).unwrap()); + assert_eq!(backend.calls("steam_has_api_key").len(), 1); +} + +#[test] +fn api_key_set_drops_memo() { + let backend = support::scripted("demo"); + let adapter = PlatformAdapter::shipped("steam").unwrap(); + backend.push("steam_has_api_key", Ok(json!(false))); + assert!(!adapter.steam_has_api_key(&backend).unwrap()); + backend.push("steam_set_api_key", Ok(Value::Null)); + adapter.steam_set_api_key(&backend, "new-key").unwrap(); + backend.push("steam_has_api_key", Ok(json!(true))); + assert!(adapter.steam_has_api_key(&backend).unwrap()); + assert_eq!(backend.calls("steam_has_api_key").len(), 2); +} + +#[test] +fn api_key_read_in_flight_during_set_is_dropped() { + let mut memo = ApiKeyMemo::default(); + let generation = memo.generation(); + memo.invalidate(); + memo.finish_read(generation, false); + assert_eq!(memo.cached(), None); + memo.finish_read(memo.generation(), true); + assert_eq!(memo.cached(), Some(true)); +} + +#[test] +fn api_key_failed_set_keeps_memo() { + let backend = support::scripted("demo"); + let adapter = PlatformAdapter::shipped("steam").unwrap(); + backend.push("steam_has_api_key", Ok(json!(true))); + assert!(adapter.steam_has_api_key(&backend).unwrap()); + backend.push("steam_set_api_key", Err("locked")); + assert!(adapter.steam_set_api_key(&backend, "x").is_err()); + assert!(adapter.steam_has_api_key(&backend).unwrap()); + assert_eq!(backend.calls("steam_has_api_key").len(), 1); +} + +// ------------------------------------------------ url.test.ts + +#[test] +fn url_https() { + assert!(is_safe_http_url("https://example.com")); + assert!(is_safe_http_url("https://example.com/path?q=1")); +} + +#[test] +fn url_http() { + assert!(is_safe_http_url("http://example.com")); +} + +#[test] +fn url_javascript() { + assert!(!is_safe_http_url("javascript:alert(1)")); +} + +#[test] +fn url_data() { + assert!(!is_safe_http_url("data:text/html,

hi

")); +} + +#[test] +fn url_empty() { + assert!(!is_safe_http_url("")); +} + +#[test] +fn url_malformed() { + assert!(!is_safe_http_url("not a url at all")); + assert!(!is_safe_http_url("://missing-scheme")); +} + +#[test] +fn url_ftp() { + assert!(!is_safe_http_url("ftp://files.example.com/readme.txt")); +} + +// ------------------------------------------------ Rust-only: menu and tasks + +fn ids(actions: &[accshift_app::menu::MenuAction]) -> Vec { + actions.iter().map(|action| action.id.clone()).collect() +} + +#[test] +fn steam_menu_order_and_ids() { + let me = account("7656"); + let game = DefaultGame { + app_id: "730".to_string(), + name: "CS2".to_string(), + }; + let actions = steam_context_menu_actions(&me, Some("other"), Some(&game)); + assert_eq!( + ids(&actions), + [ + "steam.launch.online.7656", + "steam.launch.invisible.7656", + "steam.switch.run.7656", + "steam.switch.run.clear.7656", + "steam.copy.username.7656", + "steam.copy.id64.7656", + "steam.copy.friendCode.7656", + "steam.copy.profileUrl.7656", + "steam.open.userdata.7656", + "steam.copy.settings.7656", + "steam.forget.7656", + ] + ); + assert_eq!( + actions[2].label, + Text::with("steam.switchAndRunGame", [("game", "CS2")]) + ); + let MenuActionBody::Item { loader, .. } = &actions[9].body else { + panic!("item"); + }; + assert_eq!( + loader, + &Some(SubmenuLoader::SteamCopyableGames { + from_steam_id: "7656".to_string(), + to_steam_id: "other".to_string() + }) + ); +} + +#[test] +fn steam_menu_current_account() { + let me = account("7656"); + let actions = steam_context_menu_actions(&me, Some("7656"), None); + let ids = ids(&actions); + assert!(ids.contains(&"steam.clear.browserCache.7656".to_string())); + assert!(!ids.contains(&"steam.copy.settings.7656".to_string())); + assert!(!ids.contains(&"steam.switch.run.clear.7656".to_string())); + assert_eq!(actions[2].label, Text::key("steam.runPick")); + assert_eq!(actions[2].command(), None); +} + +#[test] +fn steam_forget_clears_default_game_first() { + let backend = support::scripted("demo"); + let adapter = PlatformAdapter::shipped("steam").unwrap(); + backend.push("platform_forget_account", Ok(Value::Null)); + let outcome = adapter.run_task( + &PlatformTask::Forget { + platform_id: "steam".to_string(), + account_id: "7656".to_string(), + toast: Text::key("steam.forgotAccount"), + }, + &backend, + &settings(0), + ); + assert_eq!( + outcome.effects, + vec![ + TaskEffect::ClearDefaultGame { + steam_id: "7656".to_string() + }, + TaskEffect::Toast(Text::key("steam.forgotAccount")), + TaskEffect::RemoveAccount("7656".to_string()), + ] + ); +} + +#[test] +fn failed_task_toasts_the_error_but_not_a_pin_refusal() { + let backend = support::scripted("demo"); + let adapter = PlatformAdapter::shipped("steam").unwrap(); + let task = PlatformTask::SteamOpenUserdata { + steam_id: "7656".to_string(), + }; + backend.push("steam_open_userdata", Err("no folder")); + let outcome = adapter.run_task(&task, &backend, &settings(0)); + assert_eq!( + outcome.effects, + vec![TaskEffect::Toast(Text::raw("no folder"))] + ); + backend.push("steam_open_userdata", Err("pin_locked: locked")); + assert!(adapter + .run_task(&task, &backend, &settings(0)) + .effects + .is_empty()); +} + +#[test] +fn steam_menu_through_the_adapter_reads_default_game() { + let mut stores = stores(); + stores.set_default_game( + "7656", + &DefaultGame { + app_id: "730".to_string(), + name: "CS2".to_string(), + }, + ); + let adapter = PlatformAdapter::shipped("steam").unwrap(); + let ctx = MenuContext { + current_account_id: Some("7656"), + stores: &stores, + input_dialog: true, + }; + let actions = adapter.context_menu_actions(&account("7656"), &ctx); + assert_eq!( + actions[2].label, + Text::with("steam.runGame", [("game", "CS2")]) + ); + assert!(matches!( + actions[2].command(), + Some(MenuCommand::Task(PlatformTask::SteamRunGame { + remember: None, + .. + })) + )); +} diff --git a/crates/accshift-app/tests/platform_time.rs b/crates/accshift-app/tests/platform_time.rs new file mode 100644 index 00000000..3e27feb0 --- /dev/null +++ b/crates/accshift-app/tests/platform_time.rs @@ -0,0 +1,362 @@ +//! `src/lib/shared/time.test.ts` ported, plus the absolute formats checked +//! against what Node's `Intl.DateTimeFormat` prints in UTC, Europe/Paris, +//! America/New_York and Asia/Kolkata, on both sides of every DST change +//! (`tests/data/platform/time_formats.json`, from `dump_time_formats.mjs`). + +use accshift_app::i18n::Locale; +use accshift_app::mock::NOW; +use accshift_app::time::{ + format_absolute_date, format_absolute_date_time, format_relative_time, + format_relative_time_compact, is_unix_seconds, named_zone, system_zone, unix_ms_to_seconds, + Clock, TimeContext, TimeZone, UNKNOWN_KEY, +}; +use serde_json::Value; +use std::time::Instant; + +const MINUTE: f64 = 60.0; +const HOUR: f64 = 60.0 * MINUTE; +const DAY: f64 = 24.0 * HOUR; + +fn ctx() -> TimeContext { + TimeContext::new( + &Clock::fixed(NOW as f64 * 1000.0), + Locale::En, + TimeZone::UTC, + ) +} + +fn seconds_ago(seconds: f64) -> f64 { + (ctx().now_ms / 1000.0).floor() - seconds +} + +fn relative(timestamp: Option) -> String { + format_relative_time(timestamp, &ctx(), UNKNOWN_KEY) +} + +// ------------------------------------------------ formatRelativeTimeFromUnixSeconds + +#[test] +fn relative_unknown_for_null() { + assert!(!relative(None).is_empty()); +} + +#[test] +fn relative_unknown_for_undefined() { + // TS null and undefined are both `None` here. + assert_eq!(relative(None), "unknown"); +} + +#[test] +fn relative_unknown_for_zero() { + assert_eq!(relative(Some(0.0)), relative(None)); +} + +#[test] +fn relative_unknown_for_negative() { + assert_eq!(relative(Some(-1.0)), relative(None)); +} + +#[test] +fn relative_just_now() { + assert!(relative(Some(seconds_ago(0.0))).contains("just now")); +} + +#[test] +fn relative_minutes_ago() { + let result = relative(Some(seconds_ago(5.0 * MINUTE))); + assert!( + result.contains('5') && result.contains("minute"), + "{result}" + ); +} + +#[test] +fn relative_hours_ago() { + let result = relative(Some(seconds_ago(2.0 * HOUR))); + assert!(result.contains('2') && result.contains("hour"), "{result}"); +} + +#[test] +fn relative_days_ago() { + let result = relative(Some(seconds_ago(3.0 * DAY))); + assert!(result.contains('3') && result.contains("day"), "{result}"); +} + +#[test] +fn relative_still_days_at_30() { + let result = relative(Some(seconds_ago(30.0 * DAY))); + assert!(result.contains("30") && result.contains("day"), "{result}"); +} + +#[test] +fn relative_absolute_past_30_days() { + let result = relative(Some(seconds_ago(412.0 * DAY))); + assert!(!result.contains("day"), "{result}"); + assert!( + result + .as_bytes() + .windows(4) + .any(|w| w.iter().all(u8::is_ascii_digit)), + "{result}" + ); +} + +// ------------------------------------------------ unit guard + +#[test] +fn unit_guard_millisecond_now_is_unknown() { + let result = relative(Some(ctx().now_ms)); + assert!(!result.contains("just now")); + assert_eq!(result, relative(None)); +} + +#[test] +fn unit_guard_millisecond_year_ago_is_unknown() { + assert_eq!( + relative(Some(seconds_ago(365.0 * DAY) * 1000.0)), + relative(None) + ); +} + +#[test] +fn unit_guard_nan_and_infinity_are_unknown() { + assert_eq!(relative(Some(f64::NAN)), relative(None)); + assert_eq!(relative(Some(f64::INFINITY)), relative(None)); +} + +#[test] +fn unit_guard_caller_unknown_wording() { + assert_eq!( + format_relative_time(Some(ctx().now_ms), &ctx(), "time.neverConnected"), + "never connected" + ); +} + +// ------------------------------------------------ isUnixSeconds + +#[test] +fn is_unix_seconds_accepts_plausible() { + assert!(is_unix_seconds(Some(1_760_000_000.0))); +} + +#[test] +fn is_unix_seconds_ceiling() { + assert!(is_unix_seconds(Some(1e11))); + assert!(!is_unix_seconds(Some(1e11 + 1.0))); + assert!(!is_unix_seconds(Some(1_760_000_000_000.0))); +} + +#[test] +fn is_unix_seconds_rejects_bad_values() { + assert!(!is_unix_seconds(None)); + assert!(!is_unix_seconds(Some(0.0))); + assert!(!is_unix_seconds(Some(-1.0))); + assert!(!is_unix_seconds(Some(f64::NAN))); +} + +// ------------------------------------------------ unixMsToSeconds + +#[test] +fn unix_ms_to_seconds_floors() { + assert_eq!( + unix_ms_to_seconds(Some(1_760_000_000_999.0)), + Some(1_760_000_000.0) + ); +} + +#[test] +fn unix_ms_to_seconds_rejects_bad_values() { + assert_eq!(unix_ms_to_seconds(None), None); + assert_eq!(unix_ms_to_seconds(Some(0.0)), None); + assert_eq!(unix_ms_to_seconds(Some(-5.0)), None); + assert_eq!(unix_ms_to_seconds(Some(f64::NAN)), None); +} + +// ------------------------------------------------ absolute formatters + +const OCTOBER_2025: f64 = 1_760_000_000.0; + +#[test] +fn absolute_localized_day() { + let result = format_absolute_date(Some(OCTOBER_2025), Locale::En, &TimeZone::UTC); + assert!( + result.contains("2025") && result.contains("Oct"), + "{result}" + ); +} + +#[test] +fn absolute_localized_day_and_time() { + let result = format_absolute_date_time(Some(OCTOBER_2025), Locale::En, &TimeZone::UTC); + assert!(result.contains("2025")); + let has_time = result + .split(|c: char| !c.is_ascii_digit() && c != ':') + .any(|part| { + part.split_once(':').is_some_and(|(h, m)| { + (1..=2).contains(&h.len()) && m.len() == 2 && m.bytes().all(|b| b.is_ascii_digit()) + }) + }); + assert!(has_time, "{result}"); +} + +#[test] +fn absolute_follows_locale() { + let en = format_absolute_date(Some(OCTOBER_2025), Locale::En, &TimeZone::UTC); + let fr = format_absolute_date(Some(OCTOBER_2025), Locale::Fr, &TimeZone::UTC); + assert!(fr.contains("2025")); + assert_ne!(fr, en); +} + +#[test] +fn absolute_empty_for_non_seconds() { + assert_eq!(format_absolute_date(None, Locale::En, &TimeZone::UTC), ""); + assert_eq!( + format_absolute_date(Some(0.0), Locale::En, &TimeZone::UTC), + "" + ); + assert_eq!( + format_absolute_date_time(None, Locale::En, &TimeZone::UTC), + "" + ); + assert_eq!( + format_absolute_date_time(Some(1_760_000_000_000.0), Locale::En, &TimeZone::UTC), + "" + ); +} + +// ------------------------------------------------ Rust-only checks + +fn locale_of(code: &str) -> Locale { + match code { + "en" => Locale::En, + "es" => Locale::Es, + "fr" => Locale::Fr, + "pt" => Locale::Pt, + "pt-br" => Locale::PtBr, + "ru" => Locale::Ru, + "zh" => Locale::Zh, + other => panic!("unknown locale {other}"), + } +} + +#[test] +fn absolute_formats_match_node_intl() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/data/platform/time_formats.json" + ); + let fixture: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + let rows = fixture["rows"].as_array().unwrap(); + let mut zone_cases = 0; + for row in rows { + let zone_name = row["zone"].as_str().unwrap(); + let zone = named_zone(zone_name).unwrap_or_else(|| panic!("no zone {zone_name}")); + let locale = locale_of(row["locale"].as_str().unwrap()); + let stamp = row["stamp"].as_f64().unwrap(); + let offset = zone + .to_offset(jiff::Timestamp::from_second(stamp as i64).unwrap()) + .seconds(); + assert_eq!( + i64::from(offset), + row["offset"].as_i64().unwrap() * 60, + "offset {row}" + ); + assert_eq!( + format_absolute_date(Some(stamp), locale, &zone), + row["date"].as_str().unwrap(), + "date {row}" + ); + assert_eq!( + format_absolute_date_time(Some(stamp), locale, &zone), + row["dateTime"].as_str().unwrap(), + "dateTime {row}" + ); + if zone_name != "UTC" { + zone_cases += 1; + } + } + // Paris and New York: 6 DST changes each (2025, 2026, 2040). + for zone in ["Europe/Paris", "America/New_York"] { + assert_eq!( + fixture["changes"][zone].as_array().unwrap().len(), + 6, + "{zone}" + ); + } + assert!(zone_cases > 900, "{zone_cases}"); + eprintln!("time-zones: {zone_cases} zone cases equal to Node"); +} + +#[test] +fn july_date_keeps_summer_time_in_january() { + // 2025-07-09 21:30 UTC is 23:30 in Paris (UTC+2) even when "now" is a + // January day at UTC+1: the offset is the stamp's, not today's. + let paris = named_zone("Europe/Paris").unwrap(); + assert_eq!( + format_absolute_date_time(Some(1_752_096_600.0), Locale::Fr, &paris), + "9 juil. 2025, 23:30" + ); + assert_eq!( + format_absolute_date_time(Some(1_736_458_200.0), Locale::Fr, &paris), + "9 janv. 2025, 22:30" + ); +} + +#[test] +fn system_zone_loads() { + let started = Instant::now(); + let zone = system_zone(); + let first = started.elapsed(); + let started = Instant::now(); + let again = system_zone(); + let cached = started.elapsed(); + assert_eq!(zone, again); + eprintln!( + "system zone {:?}: first load {} us, cached {} us", + zone.iana_name(), + first.as_micros(), + cached.as_micros() + ); +} + +#[test] +fn utc_offset_moves_the_day() { + // 2025-10-09 23:30 UTC is the 10th at UTC+2 and still the 9th at UTC-2. + let stamp = 1_760_052_600.0; + assert_eq!( + format_absolute_date(Some(stamp), Locale::En, &TimeZone::UTC), + "Oct 9, 2025" + ); + assert_eq!( + format_absolute_date( + Some(stamp), + Locale::En, + &accshift_app::time::fixed_zone(120) + ), + "Oct 10, 2025" + ); + assert_eq!( + format_absolute_date( + Some(stamp), + Locale::En, + &accshift_app::time::fixed_zone(-120) + ), + "Oct 9, 2025" + ); +} + +#[test] +fn compact_wraps_the_relative_label() { + let base = relative(Some(seconds_ago(5.0 * MINUTE))); + let compact = + format_relative_time_compact(Some(seconds_ago(5.0 * MINUTE)), &ctx(), UNKNOWN_KEY); + assert!(compact.contains(&base), "{compact} / {base}"); +} + +#[test] +fn mock_clock_starts_at_now_and_runs() { + let clock = Clock::mock(NOW); + let first = clock.now_ms(); + assert!(first >= NOW as f64 * 1000.0 && first < NOW as f64 * 1000.0 + 5_000.0); + assert!(clock.now_ms() >= first); +} diff --git a/crates/accshift-app/tests/profile_refresh.rs b/crates/accshift-app/tests/profile_refresh.rs new file mode 100644 index 00000000..96032382 --- /dev/null +++ b/crates/accshift-app/tests/profile_refresh.rs @@ -0,0 +1,338 @@ +//! `src/lib/app/useProfileRefresh.test.ts`, ported over a fake +//! [`RefreshEnv`]: each fake adapter answers from closures and records the +//! calls the TS mocks checked. + +use accshift_app::accounts::loader::LoadOptions; +use accshift_app::accounts::refresh::{refresh_avatars_now, refresh_bans_now, RefreshEnv}; +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::error::BackendError; +use accshift_app::platforms::warnings::WarningLoad; +use accshift_app::platforms::{ + PlatformAccount, PlatformCapabilities, PlatformDef, PlatformProfileInfo, ProfileRefresh, + WarningLoadOptions, WarningRefreshOutcome, +}; +use accshift_app::text::Text; +use std::collections::BTreeMap; + +type ProfileFn = fn(&str) -> Result, BackendError>; + +#[derive(Default)] +struct FakeAdapter { + accounts: Option, BackendError>>, + profile: Option, + warnings: Option, + no_accounts: Option, +} + +#[derive(Default)] +struct Env { + adapters: BTreeMap, + ensured: Vec, + profile_calls: Vec, + warning_calls: Vec<(Vec, WarningLoadOptions)>, +} + +impl RefreshEnv for Env { + type Adapter = String; + + fn ensure(&mut self, platform_id: &str) -> Option { + self.ensured.push(platform_id.into()); + self.adapters + .contains_key(platform_id) + .then(|| platform_id.to_string()) + } + + fn has_profile_info(&self, adapter: &String) -> bool { + self.adapters[adapter].profile.is_some() + } + + fn has_warnings(&self, adapter: &String) -> bool { + self.adapters[adapter].warnings.is_some() + } + + fn load_accounts(&mut self, adapter: &String) -> Result, BackendError> { + self.adapters[adapter] + .accounts + .clone() + .unwrap_or_else(|| Ok(Vec::new())) + } + + fn profile_info( + &mut self, + adapter: &String, + account_id: &str, + ) -> Result, BackendError> { + self.profile_calls.push(account_id.into()); + (self.adapters[adapter].profile.expect("profile"))(account_id) + } + + fn load_warning_states( + &mut self, + adapter: &String, + accounts: &[PlatformAccount], + options: WarningLoadOptions, + ) -> Result { + self.warning_calls + .push((accounts.iter().map(|a| a.id.clone()).collect(), options)); + Ok(self.adapters[adapter].warnings.clone().expect("warnings")) + } + + fn no_accounts_toast(&self, adapter: &String) -> Option { + self.adapters[adapter].no_accounts.clone() + } +} + +fn def(id: &str, refresh: Option) -> PlatformDef { + PlatformDef { + id: id.into(), + name: id.into(), + accent: "#000000".into(), + implemented: true, + supported_os: Vec::new(), + settings_tab_key: None, + path_label_key: None, + path_placeholder: None, + capabilities: PlatformCapabilities { + profile_refresh: refresh, + ..PlatformCapabilities::default() + }, + user_provided: false, + } +} + +fn defs() -> Vec { + vec![ + def( + "steam", + Some(ProfileRefresh { + avatars: true, + bans: true, + }), + ), + def("riot", None), + def( + "epic", + Some(ProfileRefresh { + avatars: true, + bans: false, + }), + ), + ] +} + +fn accounts() -> Vec { + ["a1", "a2"] + .into_iter() + .map(|id| PlatformAccount { + id: id.into(), + display_name: String::new(), + username: String::new(), + last_login_at_sec: None, + }) + .collect() +} + +fn avatar(_id: &str) -> Result, BackendError> { + Ok(Some(PlatformProfileInfo { + avatar_url: Some("https://cdn.example/a.png".into()), + ..PlatformProfileInfo::default() + })) +} + +fn steam_with(adapter: FakeAdapter) -> Env { + let mut env = Env::default(); + env.adapters.insert("steam".into(), adapter); + env +} + +fn settled(outcome: WarningRefreshOutcome) -> WarningLoad { + WarningLoad { + outcome: Some(outcome), + ..WarningLoad::default() + } +} + +fn avatar_reload() -> Option { + Some(LoadOptions { + silent: true, + force_refresh: true, + ..LoadOptions::default() + }) +} + +fn toast(text: Text, kind: ToastKind) -> Toast { + Toast::new(text, kind) +} + +#[test] +fn refreshes_avatars_only_on_platforms_that_declare_it_reloading_the_active_tab() { + let mut env = steam_with(FakeAdapter { + accounts: Some(Ok(accounts())), + profile: Some(avatar), + ..FakeAdapter::default() + }); + env.adapters.insert("epic".into(), FakeAdapter::default()); + + let report = refresh_avatars_now(&defs(), "steam", &mut env); + + assert_eq!(env.ensured, ["steam", "epic"]); + assert_eq!(env.profile_calls, ["a1", "a2"]); + assert_eq!(report.reload_active, avatar_reload()); + // Epic has no profile fetch: skipped before loading anything. + assert_eq!( + report.toasts, + [toast( + Text::with("toast.avatarRefreshComplete", [("count", 2.0)]), + ToastKind::Success + )] + ); +} + +#[test] +fn refreshes_bans_with_a_forced_load_and_does_not_reload_another_tab() { + let mut env = steam_with(FakeAdapter { + accounts: Some(Ok(accounts())), + warnings: Some(WarningLoad::default()), + ..FakeAdapter::default() + }); + + let report = refresh_bans_now(&defs(), "riot", &mut env); + + assert_eq!( + env.warning_calls, + [( + vec!["a1".to_string(), "a2".to_string()], + WarningLoadOptions { + force_refresh: true, + silent: false + } + )] + ); + assert!(report.reload_active.is_none()); + assert_eq!( + report.toasts, + [toast( + Text::with("toast.banRefreshComplete", [("count", 2.0)]), + ToastKind::Success + )] + ); +} + +#[test] +fn reports_a_failed_avatar_refresh_when_every_profile_is_missing_or_rejected() { + let failed = [toast(Text::key("toast.refreshFailed"), ToastKind::Error)]; + + let mut missing = steam_with(FakeAdapter { + accounts: Some(Ok(accounts())), + profile: Some(|_| Ok(None)), + ..FakeAdapter::default() + }); + assert_eq!( + refresh_avatars_now(&defs(), "steam", &mut missing).toasts, + failed + ); + + let mut rejected = steam_with(FakeAdapter { + accounts: Some(Ok(accounts())), + profile: Some(|_| Err(BackendError::new("down"))), + ..FakeAdapter::default() + }); + assert_eq!( + refresh_avatars_now(&defs(), "steam", &mut rejected).toasts, + failed + ); +} + +#[test] +fn reports_a_partial_avatar_refresh_when_only_some_profiles_return() { + let mut env = steam_with(FakeAdapter { + accounts: Some(Ok(accounts())), + profile: Some(|id| if id == "a1" { avatar(id) } else { Ok(None) }), + ..FakeAdapter::default() + }); + + let report = refresh_avatars_now(&defs(), "steam", &mut env); + + assert_eq!(report.reload_active, avatar_reload()); + assert_eq!( + report.toasts, + [toast( + Text::with("toast.refreshPartial", [("ok", 1.0), ("count", 2.0)]), + ToastKind::Info + )] + ); +} + +fn bans_settling(outcome: WarningRefreshOutcome) -> Env { + steam_with(FakeAdapter { + accounts: Some(Ok(accounts())), + warnings: Some(settled(outcome)), + ..FakeAdapter::default() + }) +} + +#[test] +fn does_not_announce_a_finished_ban_refresh_when_the_check_failed() { + let mut env = bans_settling(WarningRefreshOutcome::Failed); + assert!(refresh_bans_now(&defs(), "riot", &mut env) + .toasts + .is_empty()); +} + +#[test] +fn says_the_ban_refresh_was_skipped_when_no_api_key_is_set() { + let mut env = bans_settling(WarningRefreshOutcome::NoApiKey); + + let report = refresh_bans_now(&defs(), "steam", &mut env); + + assert!(report.reload_active.is_none()); + assert_eq!( + report.toasts, + [toast( + Text::key("toast.banRefreshNoApiKey"), + ToastKind::Error + )] + ); +} + +#[test] +fn reports_a_partial_ban_refresh_when_steam_left_accounts_out() { + let mut env = bans_settling(WarningRefreshOutcome::Partial { + checked: 1, + requested: 2, + }); + + let report = refresh_bans_now(&defs(), "riot", &mut env); + + assert_eq!( + report.toasts, + [toast( + Text::with("toast.refreshPartial", [("ok", 1.0), ("count", 2.0)]), + ToastKind::Info + )] + ); +} + +#[test] +fn shows_the_platforms_empty_message_and_an_error_toast_when_a_step_throws() { + let mut empty = steam_with(FakeAdapter { + accounts: Some(Ok(Vec::new())), + profile: Some(|_| Ok(None)), + warnings: Some(WarningLoad::default()), + no_accounts: Some(Text::raw("no accounts")), + }); + assert_eq!( + refresh_avatars_now(&defs(), "steam", &mut empty).toasts, + [toast(Text::raw("no accounts"), ToastKind::Default)] + ); + + let mut failing = steam_with(FakeAdapter { + accounts: Some(Err(BackendError::new("boom"))), + warnings: Some(WarningLoad::default()), + ..FakeAdapter::default() + }); + assert_eq!( + refresh_bans_now(&defs(), "steam", &mut failing).toasts, + [toast(Text::key("toast.refreshFailed"), ToastKind::Error)] + ); +} diff --git a/crates/accshift-app/tests/real_backend.rs b/crates/accshift-app/tests/real_backend.rs new file mode 100644 index 00000000..9e0bb2fb --- /dev/null +++ b/crates/accshift-app/tests/real_backend.rs @@ -0,0 +1,512 @@ +//! `RealBackend` part 1 on scratch app directories: storage, legacy +//! migration, themes, descriptors, the PIN session, logs and boot. Nothing +//! here opens a window, a dialog, a URL or a folder, and nothing reads or +//! writes the real `%APPDATA%\com.accshift.desktop`. + +use accshift_app::dispatch::dispatch; +use accshift_app::real::{RealBackend, UI_DETAILS_MAX_BYTES, UI_LOG_CAP}; +use accshift_app::types::CustomTheme; +use accshift_app::{Backend, BackendError}; +use accshift_core::pin::{hash_pin_code, PIN_LOCKED_MESSAGE}; +use accshift_core::storage::{STORE_FOLDERS, STORE_SETTINGS, STORE_VIEW_MODE}; +use accshift_core::AppContext; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard}; + +/// A context on a scratch folder, removed on drop. +struct Scratch { + root: PathBuf, +} + +impl Scratch { + fn new(tag: &str) -> Arc { + let root = + std::env::temp_dir().join(format!("accshift-app-real-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + Arc::new(Self { root }) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +impl AppContext for Scratch { + fn app_config_dir(&self) -> Result { + Ok(self.root.join("config")) + } + fn app_data_dir(&self) -> Result { + Ok(self.root.join("data")) + } + fn app_local_data_dir(&self) -> Result { + Ok(self.root.join("local")) + } + fn app_cache_dir(&self) -> Result { + Ok(self.root.join("cache")) + } +} + +fn backend(tag: &str) -> (Arc, RealBackend) { + let scratch = Scratch::new(tag); + let real = RealBackend::with_context(scratch.clone()); + (scratch, real) +} + +/// `reload_user_platforms` swaps a process-wide registry: the tests that +/// reload take turns. +fn registry_turn() -> MutexGuard<'static, ()> { + static TURN: Mutex<()> = Mutex::new(()); + TURN.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn fixture(name: &str) -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/data/real") + .join(name); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) +} + +fn app_log(scratch: &Scratch) -> String { + let path = accshift_core::logging::log_file_path(scratch).unwrap(); + std::fs::read_to_string(path).unwrap_or_default() +} + +// ---------------------------------------------------------------- storage + +#[test] +fn a_saved_store_comes_back_in_the_snapshot_and_the_manifest() { + let (scratch, real) = backend("storage"); + + // A fresh install lists every store, each `null`. + let empty = real.load_client_storage_snapshot().unwrap(); + assert_eq!(empty.stores.len(), 12); + assert!( + empty.stores.values().all(Value::is_null), + "{:?}", + empty.stores + ); + + let folders = json!({ "version": 2, "folders": { "f1": { "name": "Main" } } }); + let fingerprint = real + .save_client_storage_store(STORE_FOLDERS, &folders) + .unwrap() + .expect("the real command answers the fingerprint"); + let view = json!("grid"); + real.save_client_storage_store(STORE_VIEW_MODE, &view) + .unwrap(); + + let snapshot = real.load_client_storage_snapshot().unwrap(); + assert_eq!(snapshot.stores.get(STORE_FOLDERS), Some(&folders)); + assert_eq!(snapshot.stores.get(STORE_VIEW_MODE), Some(&view)); + + let manifest = real.get_storage_manifest().unwrap(); + assert_eq!(manifest.stores.get(STORE_FOLDERS), Some(&fingerprint)); + assert!(manifest.stores.contains_key(STORE_VIEW_MODE)); + assert_eq!(snapshot.manifest.stores, manifest.stores); + assert_eq!(snapshot.manifest.schema_version, manifest.schema_version); + + // A null value deletes the store: it reads back as `null`. + real.save_client_storage_store(STORE_VIEW_MODE, &Value::Null) + .unwrap(); + let snapshot = real.load_client_storage_snapshot().unwrap(); + assert_eq!(snapshot.stores.get(STORE_VIEW_MODE), Some(&Value::Null)); + + // Same log lines as the Tauri commands. + let log = app_log(&scratch); + assert!(log.contains("Saved client storage store"), "{log}"); + assert!(log.contains("Loaded client storage snapshot"), "{log}"); + assert!(log.contains("Built storage manifest"), "{log}"); +} + +#[test] +fn an_unknown_store_id_is_refused_with_the_core_message() { + let (_scratch, real) = backend("storage-unknown"); + let error = real + .save_client_storage_store("client.nope", &json!({})) + .unwrap_err(); + assert_eq!(error.message, "Unknown client store id: client.nope"); +} + +#[test] +fn the_manifest_and_snapshot_dispatch_to_the_webview_shape() { + let (_scratch, real) = backend("storage-dispatch"); + let saved = dispatch( + &real, + "save_client_storage_store", + &json!({ "storeId": STORE_VIEW_MODE, "value": "list" }), + ) + .unwrap() + .unwrap(); + assert!(saved.is_string(), "{saved}"); + let manifest = dispatch(&real, "get_storage_manifest", &json!({})) + .unwrap() + .unwrap(); + assert!(manifest["schemaVersion"].is_u64(), "{manifest}"); + assert_eq!(manifest["stores"][STORE_VIEW_MODE], saved); +} + +// ---------------------------------------------------------------- legacy migration and boot + +#[test] +fn a_legacy_config_is_migrated_once_and_kept_aside() { + let (scratch, real) = backend("legacy"); + let legacy = accshift_core::storage::legacy_config_path(&*scratch).unwrap(); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, fixture("legacy-config.json")).unwrap(); + + assert_eq!(real.migrate_legacy_config().unwrap(), "migrated"); + + assert!(!legacy.exists()); + let mut retired = legacy.clone().into_os_string(); + retired.push(".migrated"); + assert_eq!( + std::fs::read_to_string(retired).unwrap(), + fixture("legacy-config.json") + ); + let config = accshift_core::config::load_config(&*scratch); + let labels: Vec<&str> = config + .riot + .profiles + .iter() + .map(|profile| profile.label.as_str()) + .collect(); + assert_eq!(labels, ["Main", "Smurf"]); + + assert_eq!(real.migrate_legacy_config().unwrap(), "none"); +} + +#[test] +fn a_corrupt_legacy_config_answers_an_error_string() { + let (scratch, real) = backend("legacy-corrupt"); + let legacy = accshift_core::storage::legacy_config_path(&*scratch).unwrap(); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, b"{ not json").unwrap(); + + let answer = real.migrate_legacy_config().unwrap(); + assert!( + answer.starts_with("error:Legacy config is corrupt: "), + "{answer}" + ); + assert!(legacy.exists(), "the only copy stays"); +} + +#[test] +fn the_boot_payload_migrates_then_reads_everything() { + let _turn = registry_turn(); + let (scratch, real) = backend("boot"); + let legacy = accshift_core::storage::legacy_config_path(&*scratch).unwrap(); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, fixture("legacy-config.json")).unwrap(); + let theme: CustomTheme = serde_json::from_str(&fixture("theme.json")).unwrap(); + real.save_custom_theme(&theme).unwrap(); + real.save_client_storage_store(STORE_VIEW_MODE, &json!("grid")) + .unwrap(); + + let payload = real.get_boot_payload().unwrap(); + assert_eq!(payload.migration, "migrated"); + assert_eq!(payload.runtime_os, std::env::consts::OS); + assert_eq!( + payload.storage_snapshot.stores.get(STORE_VIEW_MODE), + Some(&json!("grid")) + ); + assert_eq!(payload.custom_themes.len(), 1); + assert_eq!(payload.custom_themes[0].id, "egui-fixture-theme"); + assert!(payload.user_platforms.loaded.is_empty()); + assert!(payload + .user_platforms + .dir + .ends_with(&format!("{}descriptors", std::path::MAIN_SEPARATOR))); + + let json = serde_json::to_value(&payload).unwrap(); + for key in [ + "migration", + "runtimeOs", + "storageSnapshot", + "customThemes", + "userPlatforms", + ] { + assert!(json.get(key).is_some(), "{key} missing from {json}"); + } +} + +#[test] +fn finish_boot_completes_once_and_logs_both_calls() { + let (scratch, real) = backend("finish-boot"); + assert!(!real.boot_state().is_completed()); + + real.finish_boot("app.boot", Some(&json!({ "firstFrameMs": 120 }))) + .unwrap(); + assert!(real.boot_state().is_completed()); + real.finish_boot("app.boot", None).unwrap(); + + let log = app_log(&scratch); + assert!(log.contains("Boot completed"), "{log}"); + assert!(log.contains("Boot completion requested again"), "{log}"); + assert!(log.contains("Frontend startup profile"), "{log}"); +} + +#[test] +fn runtime_os_and_streaming_detection_answer() { + let (_scratch, real) = backend("runtime"); + assert_eq!(real.get_runtime_os().unwrap(), std::env::consts::OS); + // A process scan: the answer depends on the machine, only its shape is + // checked. + let _: bool = real.detect_streaming_software().unwrap(); +} + +// ---------------------------------------------------------------- themes + +#[test] +fn a_custom_theme_is_saved_listed_and_deleted() { + let (_scratch, real) = backend("themes"); + assert!(real.list_custom_themes().unwrap().is_empty()); + + let theme: CustomTheme = serde_json::from_str(&fixture("theme.json")).unwrap(); + real.save_custom_theme(&theme).unwrap(); + let mut second = theme.clone(); + second.id = "another-fixture".into(); + second.name = "Another".into(); + real.save_custom_theme(&second).unwrap(); + + let listed = real.list_custom_themes().unwrap(); + let names: Vec<&str> = listed.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, ["Another", "Fixture Violet"], "sorted by name"); + assert_eq!( + serde_json::to_value(&listed[1]).unwrap(), + serde_json::from_str::(&fixture("theme.json")).unwrap() + ); + + real.delete_custom_theme("another-fixture").unwrap(); + let listed = real.list_custom_themes().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "egui-fixture-theme"); + // Deleting what is not there is not an error. + real.delete_custom_theme("another-fixture").unwrap(); +} + +#[test] +fn unsafe_theme_ids_are_refused_with_the_core_messages() { + let (_scratch, real) = backend("themes-bad"); + let mut theme: CustomTheme = serde_json::from_str(&fixture("theme.json")).unwrap(); + theme.id = "../escape".into(); + assert_eq!( + real.save_custom_theme(&theme).unwrap_err().message, + "Invalid theme ID: only alphanumeric characters, hyphens, and underscores are allowed" + ); + assert_eq!( + real.delete_custom_theme("../escape").unwrap_err().message, + "Invalid theme ID" + ); +} + +// ---------------------------------------------------------------- descriptors + +#[test] +fn a_descriptor_is_previewed_installed_and_removed() { + let _turn = registry_turn(); + let (scratch, real) = backend("descriptors"); + let live = scratch.root.join("live"); + let body = fixture("descriptor.json") + .replace("${LIVE}", &live.display().to_string().replace('\\', "/")); + let picked = scratch.root.join("picked-by-user.json"); + std::fs::write(&picked, &body).unwrap(); + let picked_path = picked.display().to_string(); + + let preview = real.descriptor_preview_file(&picked_path).unwrap(); + assert_eq!(preview.descriptor.id, "egui-fixture"); + assert_eq!(preview.file_name, "egui-fixture.json"); + assert!(preview.blocked.is_empty(), "{}", preview.blocked); + assert!(!preview.replaces); + + let installed = real.descriptor_install_file(&picked_path).unwrap(); + let ids: Vec<&str> = installed + .loaded + .iter() + .map(|descriptor| descriptor.id.as_str()) + .collect(); + assert_eq!(ids, ["egui-fixture"]); + let in_folder = Path::new(&installed.dir).join("egui-fixture.json"); + assert_eq!(std::fs::read_to_string(&in_folder).unwrap(), body); + assert!(picked.exists(), "the picked file stays the user's"); + assert_eq!(real.reload_user_platforms().unwrap().loaded.len(), 1); + assert!( + real.descriptor_preview_file(&picked_path).unwrap().replaces, + "a second preview says it would replace the installed one" + ); + + let removed = real.descriptor_remove("egui-fixture").unwrap(); + assert!(removed.loaded.is_empty()); + assert!(!in_folder.exists()); + assert!(real.reload_user_platforms().unwrap().loaded.is_empty()); +} + +#[test] +fn descriptor_errors_keep_the_core_messages() { + let _turn = registry_turn(); + let (scratch, real) = backend("descriptors-bad"); + assert_eq!( + real.descriptor_remove("steam").unwrap_err().message, + "`steam` is a platform this build ships" + ); + assert_eq!( + real.descriptor_remove("../x").unwrap_err().message, + "`../x` is not a platform id" + ); + let missing = scratch.root.join("missing.json"); + let error = real + .descriptor_install_file(&missing.display().to_string()) + .unwrap_err(); + assert!(error.message.contains("could not be read"), "{error}"); +} + +// ---------------------------------------------------------------- PIN + +fn settings_with_pin(enabled: bool, pin: &str) -> Value { + json!({ + "pinEnabled": enabled, + "pinHash": if enabled { hash_pin_code(pin).unwrap() } else { String::new() }, + }) +} + +#[test] +fn without_a_pin_unlock_is_not_configured() { + let (_scratch, real) = backend("pin-none"); + let result = real.pin_unlock("1234").unwrap(); + assert_eq!(result.status, "not_configured"); + assert_eq!(result.retry_after_ms, 0); + assert!(real.pin_session().ensure_unlocked(real.context()).is_ok()); +} + +#[test] +fn the_pin_session_locks_unlocks_and_guards_the_settings_store() { + let (scratch, real) = backend("pin"); + // Saved through the backend: no PIN on disk yet, so the session decides + // "unlocked" on this first write, as in the Tauri app. + real.save_client_storage_store(STORE_SETTINGS, &settings_with_pin(true, "1234")) + .unwrap(); + assert!(real.pin_session().ensure_unlocked(real.context()).is_ok()); + + real.pin_lock().unwrap(); + let refused = real + .pin_session() + .ensure_unlocked(real.context()) + .unwrap_err(); + assert_eq!(refused.message, PIN_LOCKED_MESSAGE); + assert!(BackendError::from(refused).is_pin_locked()); + + // Locked: the PIN cannot be turned off through the store. + let error = real + .save_client_storage_store(STORE_SETTINGS, &settings_with_pin(false, "")) + .unwrap_err(); + assert_eq!(error.message, PIN_LOCKED_MESSAGE); + + let wrong = real.pin_unlock("0000").unwrap(); + assert_eq!(wrong.status, "invalid"); + assert_eq!(wrong.retry_after_ms, 1000); + let too_soon = real.pin_unlock("1234").unwrap(); + assert_eq!(too_soon.status, "retry_later"); + assert!(too_soon.retry_after_ms > 0 && too_soon.retry_after_ms <= 1000); + + std::thread::sleep(std::time::Duration::from_millis(1050)); + let right = dispatch(&real, "pin_unlock", &json!({ "code": "1234" })) + .unwrap() + .unwrap(); + assert_eq!( + right, + json!({ "status": "unlocked", "legacy": false, "retryAfterMs": 0 }) + ); + assert!(real.pin_session().ensure_unlocked(real.context()).is_ok()); + + // Unlocked: the same write goes through. + real.save_client_storage_store(STORE_SETTINGS, &settings_with_pin(false, "")) + .unwrap(); + + // A new process with a PIN on disk starts locked. + real.save_client_storage_store(STORE_SETTINGS, &settings_with_pin(true, "4321")) + .unwrap(); + let next = RealBackend::with_context(scratch.clone()); + assert!(next.pin_session().ensure_unlocked(next.context()).is_err()); + assert_eq!(next.pin_unlock("4321").unwrap().status, "unlocked"); +} + +// ---------------------------------------------------------------- logs and URLs + +#[test] +fn ui_log_records_are_trimmed_and_capped_per_session() { + let (scratch, real) = backend("logs"); + let long = "x".repeat(UI_DETAILS_MAX_BYTES * 2); + real.log_app_event("info", "ui.test", "first record", Some(&long)) + .unwrap(); + let log = app_log(&scratch); + assert!(log.contains("first record"), "{log}"); + assert!( + !log.contains(&long), + "details are cut to {UI_DETAILS_MAX_BYTES} bytes" + ); + + for i in 1..UI_LOG_CAP { + real.log_app_event("debug", "ui.test", &format!("record {i}"), None) + .unwrap(); + } + real.log_app_event("info", "ui.test", "over the cap", None) + .unwrap(); + real.log_app_event("info", "ui.test", "still over", None) + .unwrap(); + let log = app_log(&scratch); + assert_eq!( + log.matches("Webview log cap reached").count(), + 1, + "warned once" + ); + assert!(!log.contains("over the cap")); + assert!(!log.contains("still over")); +} + +#[test] +fn open_url_refuses_what_the_tauri_command_refuses() { + let (_scratch, real) = backend("open-url"); + // Refused before anything is launched: nothing opens. + for url in [ + "file:///C:/Windows/System32/calc.exe", + "javascript:alert(1)", + "not a url", + ] { + let error = real.open_url(url).unwrap_err(); + assert_eq!( + error.message, + format!("Could not start process: Refusing to open URL with disallowed scheme: {url}") + ); + } +} + +// ---------------------------------------------------------------- wallpaper + +/// Captures the real desktop picture (read only, no window). Ignored by +/// default: run with `--ignored` on a desktop session. +#[test] +#[ignore] +fn the_wallpaper_capture_answers_a_jpeg_data_url() { + let (_scratch, real) = backend("wallpaper"); + let snapshot = real.get_desktop_wallpaper().unwrap(); + if cfg!(windows) { + let snapshot = snapshot.expect("a capture on a Windows desktop session"); + assert!(snapshot.data_url.starts_with("data:image/jpeg;base64,")); + assert!(snapshot.width > 0 && snapshot.height > 0); + println!( + "wallpaper: {}x{} at {},{}, {} bytes of data URL", + snapshot.width, + snapshot.height, + snapshot.x, + snapshot.y, + snapshot.data_url.len() + ); + // The second call is served from the cache: same answer. + assert_eq!(real.get_desktop_wallpaper().unwrap(), Some(snapshot)); + } else { + assert_eq!(snapshot, None); + } +} diff --git a/crates/accshift-app/tests/real_paths.rs b/crates/accshift-app/tests/real_paths.rs new file mode 100644 index 00000000..ca746142 --- /dev/null +++ b/crates/accshift-app/tests/real_paths.rs @@ -0,0 +1,105 @@ +//! The app directories of `EguiAppContext` against what Tauri's path resolver +//! returns for `com.accshift.desktop` (`tauri-2.11/src/path/desktop.rs`: +//! `dirs::config_dir()`, `data_dir()`, `data_local_dir()`, `cache_dir()` +//! joined with the identifier). Pinned from the environment each OS reads, +//! not from `dirs`, so a drift in the resolver crate shows up here. Only +//! resolves paths: nothing is created or written. + +use accshift_app::real::{EguiAppContext, RealBackend, BUNDLE_IDENTIFIER}; +use accshift_core::AppContext; +use std::path::PathBuf; + +const ID: &str = "com.accshift.desktop"; + +struct Expected { + config: PathBuf, + data: PathBuf, + local: PathBuf, + cache: PathBuf, +} + +#[cfg(windows)] +fn expected() -> Expected { + let roaming = PathBuf::from(std::env::var_os("APPDATA").expect("APPDATA")); + let local = PathBuf::from(std::env::var_os("LOCALAPPDATA").expect("LOCALAPPDATA")); + Expected { + config: roaming.join(ID), + data: roaming.join(ID), + local: local.join(ID), + cache: local.join(ID), + } +} + +#[cfg(target_os = "linux")] +fn expected() -> Expected { + let home = PathBuf::from(std::env::var_os("HOME").expect("HOME")); + let xdg = |var: &str, fallback: &str| { + std::env::var_os(var) + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| home.join(fallback)) + }; + let data = xdg("XDG_DATA_HOME", ".local/share"); + Expected { + config: xdg("XDG_CONFIG_HOME", ".config").join(ID), + data: data.join(ID), + local: data.join(ID), + cache: xdg("XDG_CACHE_HOME", ".cache").join(ID), + } +} + +#[cfg(target_os = "macos")] +fn expected() -> Expected { + let home = PathBuf::from(std::env::var_os("HOME").expect("HOME")); + let support = home.join("Library/Application Support"); + Expected { + config: support.join(ID), + data: support.join(ID), + local: support.join(ID), + cache: home.join("Library/Caches").join(ID), + } +} + +#[test] +fn the_identifier_and_version_are_the_bundled_ones() { + assert_eq!(BUNDLE_IDENTIFIER, ID); + let conf = include_str!("../../accshift-egui/tauri.conf.json"); + let conf: serde_json::Value = serde_json::from_str(conf).unwrap(); + assert_eq!(conf["identifier"], ID); + assert_eq!( + conf["version"], + accshift_app::lifecycle::updater::APP_VERSION + ); +} + +#[test] +fn every_app_dir_is_the_one_tauri_resolves() { + let ctx = EguiAppContext::new().unwrap(); + let want = expected(); + assert_eq!(ctx.app_config_dir().unwrap(), want.config); + assert_eq!(ctx.app_data_dir().unwrap(), want.data); + assert_eq!(ctx.app_local_data_dir().unwrap(), want.local); + assert_eq!(ctx.app_cache_dir().unwrap(), want.cache); +} + +#[test] +fn the_logs_dir_is_under_the_config_dir() { + let ctx = EguiAppContext::new().unwrap(); + let mut want = expected().config.join("logs"); + if cfg!(debug_assertions) { + // accshift-core scopes the log root under `dev` in a debug build, the + // Tauri dev build included: `scope_root(/logs)`. + want = want.join("dev"); + } + assert_eq!(accshift_core::storage::app_log_root(&ctx).unwrap(), want); +} + +#[test] +fn the_real_backend_builds_on_the_real_dirs_and_its_http_client() { + // Resolves the directories and builds the HTTP client; writes nothing. + let real = RealBackend::new().unwrap(); + assert_eq!(real.context().app_config_dir().unwrap(), expected().config); + let first: *const reqwest::Client = real.http_client().unwrap(); + let second: *const reqwest::Client = real.http_client().unwrap(); + assert_eq!(first, second, "one shared client"); +} diff --git a/crates/accshift-app/tests/real_platforms.rs b/crates/accshift-app/tests/real_platforms.rs new file mode 100644 index 00000000..8d97794d --- /dev/null +++ b/crates/accshift-app/tests/real_platforms.rs @@ -0,0 +1,613 @@ +//! `RealBackend` part 2 on scratch app directories: platform commands, +//! Steam on a fixture folder, the operation lock, Riot and Roblox argument +//! checks. Nothing here switches an account, stops Steam, writes the +//! keyring or the registry, or opens a network connection: the Steam folder +//! is a fixture set as the path override (so no registry lookup), and every +//! call that would reach a server or a secret is stopped by its argument +//! check first. + +use accshift_app::dispatch::dispatch; +use accshift_app::real::{RealBackend, LOCK_TIMEOUT, PORTED}; +use accshift_app::{Backend, COMMANDS}; +use accshift_core::pin::{hash_pin_code, PIN_LOCKED_MESSAGE}; +use accshift_core::storage::STORE_SETTINGS; +use accshift_core::AppContext; +use serde_json::json; +use std::path::{Path, PathBuf}; +use std::sync::{mpsc, Arc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +struct Scratch { + root: PathBuf, +} + +impl Scratch { + fn new(tag: &str) -> Arc { + let root = + std::env::temp_dir().join(format!("accshift-app-real2-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + Arc::new(Self { root }) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +impl AppContext for Scratch { + fn app_config_dir(&self) -> Result { + Ok(self.root.join("config")) + } + fn app_data_dir(&self) -> Result { + Ok(self.root.join("data")) + } + fn app_local_data_dir(&self) -> Result { + Ok(self.root.join("local")) + } + fn app_cache_dir(&self) -> Result { + Ok(self.root.join("cache")) + } +} + +fn backend(tag: &str) -> (Arc, RealBackend) { + let scratch = Scratch::new(tag); + let real = RealBackend::with_context(scratch.clone()); + (scratch, real) +} + +/// The descriptor tests swap the process-wide user platform registry. +fn registry_turn() -> MutexGuard<'static, ()> { + static TURN: Mutex<()> = Mutex::new(()); + TURN.lock().unwrap_or_else(|e| e.into_inner()) +} + +const ALPHA: &str = "76561198000000001"; +const BRAVO: &str = "76561198000000002"; + +/// SteamID64 to the userdata folder name. +fn account_id(steam_id: &str) -> u64 { + steam_id.parse::().unwrap() - 76_561_197_960_265_728 +} + +/// A Steam folder Steam never saw: `config/loginusers.vdf` with two +/// accounts, one game folder in alpha's userdata, its app manifest. +fn steam_fixture(scratch: &Scratch) -> PathBuf { + let steam = scratch.root.join("steam"); + std::fs::create_dir_all(steam.join("config")).unwrap(); + std::fs::write( + steam.join("config").join("loginusers.vdf"), + format!( + "\"users\"\n{{\n\t\"{ALPHA}\"\n\t{{\n\t\t\"AccountName\"\t\"alpha\"\n\t\t\"PersonaName\"\t\"Alpha\"\n\t\t\"MostRecent\"\t\"1\"\n\t\t\"Timestamp\"\t\"1700000000\"\n\t}}\n\t\"{BRAVO}\"\n\t{{\n\t\t\"AccountName\"\t\"bravo\"\n\t\t\"PersonaName\"\t\"Bravo\"\n\t\t\"MostRecent\"\t\"0\"\n\t\t\"Timestamp\"\t\"1600000000\"\n\t}}\n}}\n" + ), + ) + .unwrap(); + std::fs::create_dir_all( + steam + .join("userdata") + .join(account_id(ALPHA).to_string()) + .join("730"), + ) + .unwrap(); + std::fs::create_dir_all(steam.join("userdata").join(account_id(BRAVO).to_string())).unwrap(); + std::fs::create_dir_all(steam.join("steamapps")).unwrap(); + std::fs::write( + steam.join("steamapps").join("appmanifest_730.acf"), + "\"AppState\"\n{\n\t\"appid\"\t\t\"730\"\n\t\"name\"\t\t\"Counter-Strike 2\"\n}\n", + ) + .unwrap(); + steam +} + +/// Steam pointed at the fixture through the path override, so nothing asks +/// the registry where the real Steam lives. +fn with_steam(tag: &str) -> (Arc, RealBackend, PathBuf) { + let (scratch, real) = backend(tag); + let steam = steam_fixture(&scratch); + real.platform_set_path("steam", steam.to_str().unwrap()) + .unwrap(); + (scratch, real, steam) +} + +/// Holds the operation lock of `scratch` on another thread, as a second app +/// would, until the returned sender is dropped or sent to. +fn hold_lock(scratch: &Arc) -> mpsc::Sender<()> { + let (release, wait) = mpsc::channel::<()>(); + let (ready, is_ready) = mpsc::channel::<()>(); + let ctx = scratch.clone(); + std::thread::spawn(move || { + let _guard = accshift_core::lock::acquire_exclusive(&*ctx, Duration::from_secs(5)).unwrap(); + ready.send(()).unwrap(); + let _ = wait.recv(); + }); + is_ready.recv().unwrap(); + // Safety net: the commands below stop Steam or switch for real when they + // get the lock. Prove first that this thread cannot. + assert!( + matches!( + accshift_core::lock::acquire_exclusive(&**scratch, Duration::from_millis(100)), + Err(accshift_core::lock::LockError::Contended) + ), + "the held lock must refuse a second handle in this process" + ); + release +} + +const CONTENDED: &str = "Another accshift instance is holding the lock"; + +// ---------------------------------------------------------------- registry + +#[test] +fn every_command_is_ported_and_dispatches_without_not_ported() { + assert_eq!(PORTED.len(), COMMANDS.len()); + for command in COMMANDS { + assert!(PORTED.contains(command), "{command} is not in PORTED"); + } + // Cheap commands through dispatch: none answers "not ported". + let (_scratch, real) = backend("dispatch"); + for (command, args) in [ + ("telemetry_get_state", json!({})), + ("cs2_bridge_get_settings", json!({})), + ("steam_has_api_key", json!({})), + ("platform_get_accounts", json!({ "platformId": "nope" })), + ("steam_get_profile_infos", json!({ "steamIds": []})), + ("telemetry_track_streamer_mode", json!({})), + ] { + let answer = dispatch(&real, command, &args).expect(command); + if let Err(error) = &answer { + assert!(!error.message.contains("not ported"), "{command}: {error}"); + } + } +} + +#[test] +fn an_unknown_platform_is_refused_by_every_platform_command() { + let (_scratch, real) = backend("unknown"); + let expected = "Unknown platform: nope"; + let errors = [ + real.platform_get_accounts("nope").unwrap_err(), + real.platform_get_startup_snapshot("nope").unwrap_err(), + real.platform_get_current_account("nope").unwrap_err(), + real.platform_switch_account("nope", "x", &json!({})) + .unwrap_err(), + real.platform_forget_account("nope", "x").unwrap_err(), + real.platform_begin_setup("nope", &json!({})).unwrap_err(), + real.platform_get_setup_status("nope", "s").unwrap_err(), + real.platform_cancel_setup("nope", "s").unwrap_err(), + real.platform_get_path("nope").unwrap_err(), + real.platform_set_path("nope", "C:/x").unwrap_err(), + real.platform_select_path("nope").unwrap_err(), + real.platform_set_account_label("nope", "x", "l") + .unwrap_err(), + real.platform_dry_run("nope", "x").unwrap_err(), + ]; + for error in errors { + assert_eq!(error.message, expected); + } + // Through dispatch too, the shape the shell uses. + let error = dispatch( + &real, + "platform_get_accounts", + &json!({ "platformId": "nope" }), + ) + .expect("known command") + .unwrap_err(); + assert_eq!(error.message, expected); +} + +// ---------------------------------------------------------------- Steam fixture + +#[test] +fn steam_lists_the_fixture_accounts_and_its_path_override() { + let (_scratch, real, steam) = with_steam("steam-accounts"); + + assert_eq!( + real.platform_get_path("steam").unwrap(), + steam.to_str().unwrap() + ); + let accounts = real.platform_get_accounts("steam").unwrap(); + let accounts = accounts.as_array().unwrap(); + assert_eq!(accounts.len(), 2, "{accounts:?}"); + let alpha = accounts + .iter() + .find(|a| a["steam_id"] == ALPHA) + .expect("alpha listed"); + assert_eq!(alpha["account_name"], "alpha"); + assert_eq!(alpha["persona_name"], "Alpha"); + assert_eq!(alpha["last_login_at"], 1_700_000_000u64); + + // Copyable games: alpha's 730 folder named from its app manifest. + let games = real.steam_get_copyable_games(ALPHA, BRAVO).unwrap(); + assert_eq!(games.len(), 1, "{games:?}"); + assert_eq!(games[0].app_id, "730"); + assert_eq!(games[0].name, "Counter-Strike 2"); + // Same through dispatch, camelCase arguments. + let value = dispatch( + &real, + "steam_get_copyable_games", + &json!({ "fromSteamId": ALPHA, "toSteamId": BRAVO }), + ) + .unwrap() + .unwrap(); + assert_eq!(value[0]["app_id"], "730", "{value}"); +} + +#[test] +fn steam_path_override_refuses_a_folder_that_is_not_steam() { + let (scratch, real) = backend("steam-path"); + let plain = scratch.root.join("plain"); + std::fs::create_dir_all(&plain).unwrap(); + let error = real + .platform_set_path("steam", plain.to_str().unwrap()) + .unwrap_err(); + assert!( + error + .message + .ends_with("|This folder does not look like a Steam installation"), + "{error}" + ); + let error = real + .platform_set_path("steam", scratch.root.join("absent").to_str().unwrap()) + .unwrap_err(); + assert!( + error + .message + .ends_with("|Steam path override must be an existing directory"), + "{error}" + ); +} + +#[test] +fn steam_commands_check_their_ids_before_touching_anything() { + let (_scratch, real, _steam) = with_steam("steam-ids"); + let invalid = "Invalid SteamID64"; + assert_eq!( + real.steam_get_profile_info("123").unwrap_err().message, + invalid + ); + assert_eq!( + real.steam_get_profile_infos(&["abc".to_string()]) + .unwrap_err() + .message, + invalid + ); + assert_eq!( + real.steam_get_player_bans(&["1".to_string()]) + .unwrap_err() + .message, + invalid + ); + assert_eq!( + real.steam_copy_game_settings("1", BRAVO, "730") + .unwrap_err() + .message, + invalid + ); + assert_eq!( + real.steam_get_copyable_games(ALPHA, "x") + .unwrap_err() + .message, + invalid + ); + assert_eq!(real.steam_open_userdata("x").unwrap_err().message, invalid); + assert_eq!( + real.steam_get_account_games("x").unwrap_err().message, + invalid + ); + assert_eq!( + real.steam_bulk_edit( + serde_json::from_value(json!({ "steamIds": ["nope"], "launchOptions": [] })).unwrap() + ) + .unwrap_err() + .message, + invalid + ); + assert_eq!( + real.platform_forget_account("steam", "alpha") + .unwrap_err() + .message, + invalid + ); + // The game launch checks its arguments under the lock, before Steam. + assert_eq!( + real.steam_switch_account_and_launch_game("", "730", false, "", "") + .unwrap_err() + .message, + "Invalid username" + ); + assert_eq!( + real.steam_switch_account_and_launch_game("alpha", "7a0", false, "", "") + .unwrap_err() + .message, + "Invalid app id" + ); + // A switch with a username Steam cannot hold, refused before any write. + assert_eq!( + real.platform_switch_account("steam", "a\u{1}b", &json!({})) + .unwrap_err() + .message, + "Invalid username" + ); +} + +#[test] +fn steam_network_commands_answer_without_a_request_when_there_is_nothing_to_ask() { + let (_scratch, real) = backend("steam-empty"); + // No ids: no request, an empty map. + assert!(real.steam_get_profile_infos(&[]).unwrap().is_empty()); + // No API key in the config: bans answer empty without a request, and + // the key check reads the config only. + assert!(!real.steam_has_api_key().unwrap()); + assert!(real + .steam_get_player_bans(&[ALPHA.to_string()]) + .unwrap() + .is_empty()); +} + +#[test] +fn steam_labels_and_dry_runs_are_not_supported() { + let (_scratch, real, _steam) = with_steam("steam-unsupported"); + assert_eq!( + real.platform_set_account_label("steam", ALPHA, "Main") + .unwrap_err() + .message, + "Account labeling not supported" + ); + assert_eq!( + real.platform_dry_run("steam", "alpha").unwrap_err().message, + "Dry run is only available for platforms described by a descriptor" + ); +} + +// ---------------------------------------------------------------- lock and PIN + +#[test] +fn a_second_app_holding_the_lock_blocks_every_mutating_command() { + let (scratch, real, _steam) = with_steam("lock"); + let release = hold_lock(&scratch); + + let started = Instant::now(); + let error = real + .platform_switch_account("steam", "alpha", &json!({})) + .unwrap_err(); + let waited = started.elapsed(); + assert_eq!(error.message, CONTENDED); + assert!( + waited >= LOCK_TIMEOUT && waited < LOCK_TIMEOUT + Duration::from_secs(2), + "{waited:?}" + ); + + #[allow(unused_mut)] + let mut refused = vec![ + real.platform_forget_account("steam", ALPHA).unwrap_err(), + real.platform_begin_setup("steam", &json!({})).unwrap_err(), + real.steam_switch_account_and_launch_game("alpha", "730", false, "", "") + .unwrap_err(), + real.steam_copy_game_settings(ALPHA, BRAVO, "730") + .unwrap_err(), + real.steam_clear_browser_cache().unwrap_err(), + real.steam_bulk_edit( + serde_json::from_value(json!({ "steamIds": [ALPHA], "launchOptions": [] })).unwrap(), + ) + .unwrap_err(), + ]; + #[cfg(windows)] + refused.push(real.riot_capture_profile("profile").unwrap_err()); + for error in refused { + assert_eq!(error.message, CONTENDED); + } + + // The status poll reports `busy` instead of failing the wizard. + let status = real.platform_get_setup_status("steam", "setup-1").unwrap(); + assert_eq!(status.state, "busy"); + assert_eq!(status.setup_id, "setup-1"); + assert!(status.account_id.is_empty() && status.error_message.is_empty()); + let value = dispatch( + &real, + "platform_get_setup_status", + &json!({ "platformId": "steam", "setupId": "setup-1" }), + ) + .unwrap() + .unwrap(); + assert_eq!(value["state"], "busy", "{value}"); + + // Released: the lock is free again and the reads never needed it. + drop(release); + std::thread::sleep(Duration::from_millis(100)); + assert!(accshift_core::lock::acquire_exclusive(&*scratch, Duration::from_secs(1)).is_ok()); + assert_eq!( + real.platform_get_accounts("steam") + .unwrap() + .as_array() + .unwrap() + .len(), + 2 + ); +} + +#[test] +fn cancel_setup_waits_out_a_lock_held_for_less_than_its_budget() { + let (scratch, real, _steam) = with_steam("cancel"); + let release = hold_lock(&scratch); + let releaser = std::thread::spawn(move || { + // Longer than the 2 s budget of the other commands. + std::thread::sleep(LOCK_TIMEOUT + Duration::from_millis(800)); + drop(release); + }); + let started = Instant::now(); + // An unknown setup id is a no-op once the lock is free: Ok after the wait. + real.platform_cancel_setup("steam", "no-such-setup") + .unwrap(); + assert!(started.elapsed() > LOCK_TIMEOUT, "{:?}", started.elapsed()); + releaser.join().unwrap(); +} + +#[test] +fn a_locked_pin_refuses_the_switch_and_the_game_launch() { + let (_scratch, real, _steam) = with_steam("pin"); + real.save_client_storage_store( + STORE_SETTINGS, + &json!({ "pinEnabled": true, "pinHash": hash_pin_code("1234").unwrap() }), + ) + .unwrap(); + real.pin_lock().unwrap(); + // Safety net: the switches below are real ones if the PIN lets them by. + assert!(real.pin_session().ensure_unlocked(real.context()).is_err()); + + let error = real + .platform_switch_account("steam", "alpha", &json!({})) + .unwrap_err(); + assert_eq!(error.message, PIN_LOCKED_MESSAGE); + assert!(error.is_pin_locked()); + let error = real + .steam_switch_account_and_launch_game("alpha", "730", false, "", "") + .unwrap_err(); + assert_eq!(error.message, PIN_LOCKED_MESSAGE); +} + +// ---------------------------------------------------------------- descriptors + +#[test] +fn a_descriptor_platform_answers_a_dry_run_and_keeps_labels() { + let _turn = registry_turn(); + let (scratch, real) = backend("descriptor"); + let live = scratch.root.join("live"); + std::fs::create_dir_all(&live).unwrap(); + std::fs::write(live.join("session.json"), b"live").unwrap(); + let template = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/real/descriptor.json"), + ) + .unwrap(); + let live_json = live.to_string_lossy().replace('\\', "/"); + let descriptor = template.replace("${LIVE}", &live_json); + let picked = scratch.root.join("picked.json"); + std::fs::write(&picked, descriptor).unwrap(); + real.descriptor_install_file(picked.to_str().unwrap()) + .unwrap(); + + let plan = real.platform_dry_run("egui-fixture", "a3f0c2d1").unwrap(); + assert_eq!(plan.platform_id, "egui-fixture"); + assert!(!plan.applied); + assert!( + plan.warnings + .iter() + .any(|w| w.contains("No snapshot stored for account a3f0c2d1")), + "{:?}", + plan.warnings + ); + // The plan wrote nothing: the live file is untouched. + assert_eq!(std::fs::read(live.join("session.json")).unwrap(), b"live"); + let value = dispatch( + &real, + "platform_dry_run", + &json!({ "platformId": "egui-fixture", "accountId": "a3f0c2d1" }), + ) + .unwrap() + .unwrap(); + assert_eq!(value["platformId"], "egui-fixture", "{value}"); + + // An id outside the declared charset is refused. + assert!(real.platform_dry_run("egui-fixture", "not ok!").is_err()); + assert_eq!( + real.platform_get_accounts("egui-fixture").unwrap(), + json!([]) + ); + + real.descriptor_remove("egui-fixture").unwrap(); + assert_eq!( + real.platform_get_accounts("egui-fixture") + .unwrap_err() + .message, + "Unknown platform: egui-fixture" + ); +} + +// ---------------------------------------------------------------- Riot, Roblox + +#[cfg(windows)] +#[test] +fn riot_and_roblox_check_their_arguments_first() { + let (_scratch, real) = backend("riot-roblox"); + assert_eq!( + real.riot_capture_profile(" padded").unwrap_err().message, + "Invalid Riot profile id" + ); + assert_eq!( + real.riot_capture_profile("unknown-profile") + .unwrap_err() + .message, + "Riot profile not found" + ); + // Empty once the paste prefixes are stripped: refused before Roblox. A + // bare warning banner is not stripped (cookie.rs lines 32-37) and would + // reach roblox.com, so it is not in this list. + for cookie in ["", " ", ".ROBLOSECURITY=", ".ROBLOSECURITY:\"\""] { + assert_eq!( + real.roblox_add_account_by_cookie(cookie) + .unwrap_err() + .message, + "Cookie is empty", + "{cookie:?}" + ); + } + assert_eq!( + real.roblox_get_profile_info("12a").unwrap_err().message, + "Invalid Roblox user id" + ); + // No stored account: nothing to probe. + assert!(real.roblox_check_sessions().unwrap().is_empty()); + assert_eq!(real.platform_get_accounts("roblox").unwrap(), json!([])); +} + +#[cfg(not(windows))] +#[test] +fn riot_and_roblox_are_not_registered_off_windows() { + let (_scratch, real) = backend("riot-roblox"); + assert_eq!( + real.riot_capture_profile("p").unwrap_err().message, + "Command riot_capture_profile not found" + ); + assert_eq!( + real.roblox_check_sessions().unwrap_err().message, + "Command roblox_check_sessions not found" + ); +} + +// ---------------------------------------------------------------- CS2 settings + +#[test] +fn cs2_bridge_settings_round_trip_without_a_token() { + let (_scratch, real) = backend("cs2-settings"); + let fresh = real.cs2_bridge_get_settings().unwrap(); + assert!(!fresh.enabled && fresh.url.is_empty() && !fresh.token_configured); + + // No token given and none stored: a config write only, no keyring. + real.cs2_bridge_set_settings(true, " http://127.0.0.1:9/bridge/ ", None) + .unwrap(); + let saved = real.cs2_bridge_get_settings().unwrap(); + assert!(saved.enabled); + assert_eq!(saved.url, "http://127.0.0.1:9/bridge"); + assert!(!saved.token_configured); + let value = dispatch(&real, "cs2_bridge_get_settings", &json!({})) + .unwrap() + .unwrap(); + assert_eq!( + value, + json!({ "enabled": true, "url": "http://127.0.0.1:9/bridge", "tokenConfigured": false }) + ); + + assert_eq!( + real.cs2_bridge_set_settings(true, "ftp://example.test", None) + .unwrap_err() + .message, + "Bridge URL must use http or https" + ); + assert_eq!( + real.cs2_bridge_set_settings(true, "not a url", None) + .unwrap_err() + .message, + "Invalid bridge URL" + ); +} diff --git a/crates/accshift-app/tests/real_telemetry.rs b/crates/accshift-app/tests/real_telemetry.rs new file mode 100644 index 00000000..cb12e575 --- /dev/null +++ b/crates/accshift-app/tests/real_telemetry.rs @@ -0,0 +1,703 @@ +//! `RealBackend` part 2 against a fake endpoint on 127.0.0.1: the telemetry +//! queue, consent, `/forget`, `/export`, `/consent`, the events other +//! commands record, and the CS2 bridge. The real telemetry URL is never +//! used: every backend here is built with +//! `RealBackend::with_telemetry_endpoint` on the local fake, or with no +//! endpoint at all. + +use accshift_app::real::{app_version, RealBackend, NO_ENDPOINT, SURFACE}; +use accshift_app::types::SettingsSnapshot; +use accshift_app::Backend; +use accshift_core::telemetry::QueueParams; +use accshift_core::AppContext; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +// ---------------------------------------------------------------- scratch + +struct Scratch { + root: PathBuf, +} + +impl Scratch { + fn new(tag: &str) -> Arc { + let root = std::env::temp_dir().join(format!( + "accshift-app-real2-net-{tag}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + Arc::new(Self { root }) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +impl AppContext for Scratch { + fn app_config_dir(&self) -> Result { + Ok(self.root.join("config")) + } + fn app_data_dir(&self) -> Result { + Ok(self.root.join("data")) + } + fn app_local_data_dir(&self) -> Result { + Ok(self.root.join("local")) + } + fn app_cache_dir(&self) -> Result { + Ok(self.root.join("cache")) + } +} + +// ---------------------------------------------------------------- fake endpoint + +#[derive(Debug, Clone)] +struct Request { + method: String, + path: String, + user_agent: String, + body: Value, +} + +/// A one-request-per-connection HTTP server on a free loopback port. Each +/// request is recorded; the answer depends on the path. +struct Fake { + base: String, + requests: Arc>>, + fail_forget: Arc, +} + +impl Fake { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let fail_forget = Arc::new(AtomicBool::new(false)); + let (log, fail) = (requests.clone(), fail_forget.clone()); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let (log, fail) = (log.clone(), fail.clone()); + std::thread::spawn(move || serve(stream, &log, &fail)); + } + }); + Self { + base, + requests, + fail_forget, + } + } + + fn url(&self, prefix: &str) -> String { + format!("{}{prefix}", self.base) + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + fn on(&self, path: &str) -> Vec { + self.requests() + .into_iter() + .filter(|r| r.path == path) + .collect() + } + + /// Waits up to 5 s for a request on `path`. + fn wait_for(&self, path: &str) -> Request { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(found) = self.on(path).into_iter().next() { + return found; + } + assert!(Instant::now() < deadline, "no request on {path}"); + std::thread::sleep(Duration::from_millis(20)); + } + } + + /// Every event of every `/t/track` batch, with the batch mode. + fn events(&self) -> Vec<(String, Value)> { + self.on("/t/track") + .into_iter() + .flat_map(|r| { + let mode = r.body["mode"].as_str().unwrap_or_default().to_string(); + r.body["events"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .map(move |e| (mode.clone(), e)) + }) + .collect() + } +} + +fn serve(mut stream: TcpStream, log: &Mutex>, fail_forget: &AtomicBool) { + let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + let mut data = Vec::new(); + let mut chunk = [0u8; 4096]; + let header_end = loop { + let Ok(n) = stream.read(&mut chunk) else { + return; + }; + if n == 0 { + return; + } + data.extend_from_slice(&chunk[..n]); + if let Some(i) = data.windows(4).position(|w| w == b"\r\n\r\n") { + break i + 4; + } + }; + let head = String::from_utf8_lossy(&data[..header_end]).to_string(); + let mut lines = head.lines(); + let mut start = lines.next().unwrap_or_default().split(' '); + let method = start.next().unwrap_or_default().to_string(); + let target = start.next().unwrap_or_default().to_string(); + let path = target.split('?').next().unwrap_or_default().to_string(); + let header = |name: &str| { + head.lines() + .find_map(|l| { + let (k, v) = l.split_once(':')?; + k.trim() + .eq_ignore_ascii_case(name) + .then(|| v.trim().to_string()) + }) + .unwrap_or_default() + }; + let length: usize = header("content-length").parse().unwrap_or(0); + while data.len() < header_end + length { + let Ok(n) = stream.read(&mut chunk) else { + return; + }; + if n == 0 { + break; + } + data.extend_from_slice(&chunk[..n]); + } + let body: Value = serde_json::from_slice(&data[header_end..]).unwrap_or(Value::Null); + log.lock().unwrap().push(Request { + method, + path: path.clone(), + user_agent: header("user-agent"), + body: body.clone(), + }); + + let (status, answer) = match path.as_str() { + "/t/forget" if fail_forget.load(Ordering::SeqCst) => { + ("500 Internal Server Error", json!({})) + } + "/t/export" => ( + "200 OK", + json!({ "install_id": body["install_id"], "events": [] }), + ), + "/bridge/accounts" => ( + "200 OK", + json!({ "accounts": [ + { "steamId": ALPHA, "level": 12, "xp": 300, "caseEarned": true }, + { "steamId": "76561198999999999", "level": 1 } + ] }), + ), + "/bridge/check" => ( + "200 OK", + json!({ "account": { "steamId": body["steamId"], "level": 13 } }), + ), + "/down/accounts" => ("503 Service Unavailable", json!({})), + _ => ("200 OK", json!({})), + }; + let answer = answer.to_string(); + let _ = write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{answer}", + answer.len() + ); +} + +// ---------------------------------------------------------------- helpers + +const ALPHA: &str = "76561198000000001"; + +fn on_fake(tag: &str, fake: &Fake) -> (Arc, RealBackend) { + let scratch = Scratch::new(tag); + let real = RealBackend::with_telemetry_endpoint(scratch.clone(), &fake.url("/t")); + (scratch, real) +} + +/// Flushes ten times a second, no daily ping: the batches of a test are only +/// what the test recorded. +fn quick() -> QueueParams { + QueueParams { + first_flush_interval: Duration::from_millis(100), + flush_interval: Duration::from_millis(100), + emit_ping: false, + ..QueueParams::default() + } +} + +fn names(events: &[(String, Value)]) -> Vec { + events + .iter() + .map(|(_, e)| e["name"].as_str().unwrap().to_string()) + .collect() +} + +fn config(scratch: &Scratch) -> accshift_core::config::AppConfig { + accshift_core::config::load_config(scratch) +} + +/// A Steam folder with two accounts, set as the override (no registry). +fn steam_fixture(scratch: &Scratch, real: &RealBackend) { + let steam = scratch.root.join("steam"); + std::fs::create_dir_all(steam.join("config")).unwrap(); + std::fs::write( + steam.join("config").join("loginusers.vdf"), + format!("\"users\"\n{{\n\t\"{ALPHA}\"\n\t{{\n\t\t\"AccountName\"\t\"alpha\"\n\t\t\"MostRecent\"\t\"1\"\n\t}}\n\t\"76561198000000002\"\n\t{{\n\t\t\"AccountName\"\t\"bravo\"\n\t\t\"MostRecent\"\t\"0\"\n\t}}\n}}\n"), + ) + .unwrap(); + real.platform_set_path("steam", steam.to_str().unwrap()) + .unwrap(); +} + +// ---------------------------------------------------------------- telemetry + +#[test] +fn mode_a_sends_the_tauri_events_and_drops_the_mode_b_ones() { + let fake = Fake::start(); + let (scratch, real) = on_fake("mode-a", &fake); + real.telemetry_complete_onboarding(true, false).unwrap(); + let consent = fake.wait_for("/t/consent"); + assert_eq!( + consent.body, + json!({ "choice": "basic", "app_version": app_version() }) + ); + let anonymous_id = config(&scratch).telemetry.anonymous_id; + assert!(accshift_core::telemetry::install_id::is_valid( + &anonymous_id + )); + + assert!(real.start_telemetry_with(quick())); + assert!(!real.start_telemetry_with(quick()), "starts once"); + + real.telemetry_track_persona_switch(3, 2).unwrap(); + real.telemetry_track_account_add_started("steam").unwrap(); + real.telemetry_track_account_add_cancelled("riot").unwrap(); + real.telemetry_track_account_added("not-a-platform") + .unwrap(); + real.telemetry_track_operation_failed("bulk_edit", "io", Some("steam")) + .unwrap(); + real.telemetry_track_operation_failed("free text", "free text", None) + .unwrap(); + real.telemetry_track_update("bogus", Some("9.9.9"), None) + .unwrap(); + real.telemetry_track_update("available", Some("1.2.3 "), None) + .unwrap(); + real.telemetry_track_streamer_mode().unwrap(); + real.track_deep_link(); + // Mode B only: never in a Mode A batch. + real.telemetry_track_settings_snapshot(&SettingsSnapshot { + ui_language: "fr".into(), + enabled_platforms: vec!["steam".into()], + personas_enabled: true, + pin_enabled: false, + cli_enabled: false, + deep_links_enabled: true, + streamer_mode: "auto".into(), + animations: "system".into(), + }) + .unwrap(); + real.end_telemetry_session(); + + let batches = fake.on("/t/track"); + assert!(!batches.is_empty()); + for batch in &batches { + assert_eq!(batch.method, "POST"); + assert_eq!(batch.body["mode"], "A"); + assert_eq!(batch.body["anonymous_id"], anonymous_id.as_str()); + assert!(batch.body.get("install_id").is_none(), "{}", batch.body); + assert_eq!( + batch.user_agent, + format!("Accshift/{} (telemetry)", app_version()) + ); + } + let events = fake.events(); + assert_eq!( + names(&events), + [ + "persona_switch", + "account_add_started", + "account_add_cancelled", + "account_added", + "operation_failed", + "operation_failed", + "update_available", + "streamer_mode_activated", + "deep_link_used", + "session_ended", + ] + ); + let event = |i: usize| &events[i].1; + for (_, e) in &events { + assert_eq!(e["app_version"], app_version()); + assert_eq!(e["surface"], SURFACE); + assert!(e["client_ts"].is_string()); + } + assert_eq!(event(0)["platforms"], 3); + assert_eq!(event(0)["succeeded"], 2); + assert_eq!(event(0)["count"], 2); + assert_eq!(event(1)["platform"], "steam"); + assert_eq!(event(2)["platform"], "riot"); + // Closed vocabularies: anything else collapses to `other`. + assert_eq!(event(3)["platform"], "other"); + assert_eq!(event(4)["operation"], "bulk_edit"); + assert_eq!(event(4)["error_code"], "io"); + assert_eq!(event(4)["platform"], "steam"); + assert_eq!(event(5)["operation"], "other"); + assert_eq!(event(5)["error_code"], "other"); + assert!(event(5).get("platform").is_none()); + assert_eq!(event(6)["target_version"], "1.2.3x"); + assert!(event(9)["duration_ms"].is_u64()); +} + +#[test] +fn mode_b_reports_the_first_boot_once_with_the_install_id() { + let fake = Fake::start(); + let (scratch, real) = on_fake("mode-b", &fake); + steam_fixture(&scratch, &real); + real.telemetry_complete_onboarding(true, true).unwrap(); + assert_eq!(fake.wait_for("/t/consent").body["choice"], "enhanced"); + let install_id = config(&scratch).telemetry.install_id; + assert!(accshift_core::telemetry::install_id::is_valid(&install_id)); + assert!(real.start_telemetry_with(quick())); + + real.finish_boot("frontend.boot", None).unwrap(); + real.finish_boot("frontend.boot", None).unwrap(); + assert!(config(&scratch).telemetry.first_run_reported); + real.telemetry_track_settings_snapshot(&SettingsSnapshot { + ui_language: "klingon".into(), + enabled_platforms: vec!["steam".into(), "made-up".into()], + personas_enabled: true, + pin_enabled: false, + cli_enabled: true, + deep_links_enabled: false, + streamer_mode: "loud".into(), + animations: "off".into(), + }) + .unwrap(); + real.end_telemetry_session(); + + for batch in fake.on("/t/track") { + assert_eq!(batch.body["mode"], "B"); + assert_eq!(batch.body["install_id"], install_id.as_str()); + assert!(batch.body.get("anonymous_id").is_none()); + } + let events = fake.events(); + assert_eq!( + names(&events), + [ + "first_run", + "app_launched", + "accounts_snapshot", + "settings_snapshot", + "session_ended" + ], + "one first boot, Steam the only non-empty platform" + ); + assert_eq!(events[2].1["platform"], "steam"); + assert_eq!(events[2].1["count"], 2); + let snapshot = &events[3].1; + assert_eq!(snapshot["ui_language"], "other"); + assert_eq!(snapshot["enabled_platforms"], json!(["steam", "other"])); + assert_eq!(snapshot["streamer_mode"], "other"); + assert_eq!(snapshot["animations"], "off"); + assert_eq!(snapshot["cli_enabled"], true); +} + +#[test] +fn failed_commands_record_their_operation_and_error_family() { + let fake = Fake::start(); + let (scratch, real) = on_fake("operations", &fake); + steam_fixture(&scratch, &real); + real.telemetry_complete_onboarding(true, false).unwrap(); + assert!(real.start_telemetry_with(quick())); + + // Failures. + assert!(real.steam_get_profile_infos(&["bad".into()]).is_err()); + assert!(real.steam_get_player_bans(&["bad".into()]).is_err()); + assert!(real + .steam_bulk_edit( + serde_json::from_value(json!({ "steamIds": ["bad"], "launchOptions": [] })).unwrap() + ) + .is_err()); + assert!(real.steam_copy_game_settings("bad", ALPHA, "730").is_err()); + assert!(real.platform_forget_account("steam", "bad").is_err()); + assert!(real + .platform_switch_account("steam", "", &json!({})) + .is_err()); + #[cfg(windows)] + { + assert!(real.roblox_add_account_by_cookie(" ").is_err()); + assert!(real.riot_capture_profile(" x").is_err()); + } + // Unknown platform: refused before anything is recorded. + assert!(real.platform_forget_account("nope", "x").is_err()); + // Successes record nothing. + assert!(real.steam_get_profile_infos(&[]).unwrap().is_empty()); + real.end_telemetry_session(); + + let events = fake.events(); + let failed: Vec<(String, String, String)> = events + .iter() + .filter(|(_, e)| e["name"] == "operation_failed") + .map(|(_, e)| { + ( + e["operation"].as_str().unwrap().to_string(), + e["platform"].as_str().unwrap_or("").to_string(), + e["error_code"].as_str().unwrap().to_string(), + ) + }) + .collect(); + let mut expected = vec![ + ("avatar_refresh", "steam", "other"), + ("ban_check", "steam", "other"), + ("bulk_edit", "steam", "other"), + ("game_settings_copy", "steam", "other"), + ("account_forget", "steam", "other"), + ]; + if cfg!(windows) { + expected.push(("account_add", "roblox", "other")); + expected.push(("profile_capture", "riot", "other")); + } + let expected: Vec<(String, String, String)> = expected + .into_iter() + .map(|(a, b, c)| (a.into(), b.into(), c.into())) + .collect(); + assert_eq!(failed, expected); + + let switch = events + .iter() + .find(|(_, e)| e["name"] == "platform_switch") + .expect("the failed switch is recorded") + .1 + .clone(); + assert_eq!(switch["platform"], "steam"); + assert_eq!(switch["success"], false); + assert_eq!(switch["count"], 0); + assert_eq!(switch["error_code"], "other"); + assert!(switch["duration_ms"].is_u64()); +} + +#[test] +fn opting_out_of_mode_b_forgets_the_id_and_keeps_it_until_acknowledged() { + let fake = Fake::start(); + let (scratch, real) = on_fake("forget", &fake); + real.telemetry_complete_onboarding(true, true).unwrap(); + let first_id = config(&scratch).telemetry.install_id; + + // The endpoint fails: the id stays in the outbox, the mode is off. + fake.fail_forget.store(true, Ordering::SeqCst); + let error = real.telemetry_set_mode_b(false).unwrap_err(); + assert_eq!(error.message, "status: 500 Internal Server Error"); + let state = real.telemetry_get_state().unwrap(); + assert!(!state.mode_b_enabled && state.forget_pending && state.install_id_set); + assert_eq!( + config(&scratch).telemetry.pending_forget_install_ids, + std::slice::from_ref(&first_id) + ); + + // A new opt-in gets a fresh id; export covers both. + real.telemetry_set_mode_b(true).unwrap(); + let second_id = config(&scratch).telemetry.install_id; + assert_ne!(second_id, first_id); + let export = real.telemetry_export().unwrap(); + assert_eq!( + export, + json!({ "installations": [ + { "install_id": second_id, "events": [] }, + { "install_id": first_id, "events": [] } + ] }) + ); + + // The retry succeeds: the old id leaves the outbox, the new one stays. + fake.fail_forget.store(false, Ordering::SeqCst); + real.telemetry_retry_forget().unwrap(); + let cfg = config(&scratch).telemetry; + assert!(cfg.pending_forget_install_ids.is_empty()); + assert_eq!(cfg.install_id, second_id); + assert_eq!( + real.telemetry_export().unwrap(), + json!({ "install_id": second_id, "events": [] }) + ); + let forgets: Vec = fake + .on("/t/forget") + .into_iter() + .map(|r| r.body["install_id"].clone()) + .collect(); + assert_eq!(forgets, [json!(first_id), json!(first_id)]); + + // Off again, endpoint up: forgotten at once. + real.telemetry_set_mode_b(false).unwrap(); + let state = real.telemetry_get_state().unwrap(); + assert!(!state.mode_b_enabled && !state.forget_pending && !state.install_id_set); + assert_eq!( + real.telemetry_export().unwrap_err().message, + "mode_b_disabled" + ); +} + +#[test] +fn mode_a_toggle_and_onboarding_rules() { + let fake = Fake::start(); + let (scratch, real) = on_fake("toggles", &fake); + let fresh = real.telemetry_get_state().unwrap(); + assert_eq!( + serde_json::to_value(&fresh).unwrap(), + json!({ + "mode_a_enabled": true, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": false + }) + ); + assert_eq!( + real.telemetry_complete_onboarding(false, true) + .unwrap_err() + .message, + "mode_b_requires_mode_a" + ); + assert!(!real.telemetry_get_state().unwrap().onboarding_completed); + + real.telemetry_complete_onboarding(false, false).unwrap(); + assert_eq!(fake.wait_for("/t/consent").body["choice"], "refused"); + let state = real.telemetry_get_state().unwrap(); + assert!(state.onboarding_completed && !state.mode_a_enabled); + assert!(config(&scratch).telemetry.anonymous_id.is_empty()); + + // Refused: the queue drops everything. + assert!(real.start_telemetry_with(quick())); + real.telemetry_track_streamer_mode().unwrap(); + std::thread::sleep(Duration::from_millis(300)); + assert!(fake.on("/t/track").is_empty()); + + // Mode A on later: an anonymous id appears and events flow. + real.telemetry_set_mode_a(true).unwrap(); + assert!(accshift_core::telemetry::install_id::is_valid( + &config(&scratch).telemetry.anonymous_id + )); + real.telemetry_track_streamer_mode().unwrap(); + real.end_telemetry_session(); + assert_eq!( + names(&fake.events()), + ["streamer_mode_activated", "session_ended"] + ); +} + +#[test] +fn nothing_is_sent_before_onboarding_or_without_an_endpoint() { + // Endpoint, no onboarding: the queue runs and sends nothing. + let fake = Fake::start(); + let (_scratch, real) = on_fake("silent", &fake); + assert!(real.start_telemetry_with(quick())); + real.telemetry_track_persona_switch(1, 1).unwrap(); + real.finish_boot("frontend.boot", None).unwrap(); + real.end_telemetry_session(); + assert!(fake.requests().is_empty(), "{:?}", fake.requests()); + + // No endpoint: the queue never starts, network commands refuse. + let scratch = Scratch::new("no-endpoint"); + let offline = RealBackend::with_context(scratch.clone()); + assert!(!offline.start_telemetry()); + assert!(!offline.telemetry_running()); + offline.telemetry_complete_onboarding(true, true).unwrap(); + let id = config(&scratch).telemetry.install_id; + assert_eq!(offline.telemetry_export().unwrap_err().message, NO_ENDPOINT); + assert_eq!( + offline.telemetry_set_mode_b(false).unwrap_err().message, + NO_ENDPOINT + ); + assert_eq!(config(&scratch).telemetry.pending_forget_install_ids, [id]); + offline.finish_boot("frontend.boot", None).unwrap(); + assert!( + !config(&scratch).telemetry.first_run_reported, + "a backend that sends nothing does not burn the first run" + ); +} + +// ---------------------------------------------------------------- CS2 bridge + +#[test] +fn cs2_bridge_fetches_checks_and_tests_against_the_configured_server() { + let fake = Fake::start(); + let (scratch, real) = on_fake("cs2", &fake); + steam_fixture(&scratch, &real); + + // Off: no request at all. + assert!(real.cs2_bridge_fetch().unwrap().is_empty()); + assert!(real.cs2_bridge_check(ALPHA).unwrap().is_none()); + assert!(fake.on("/bridge/accounts").is_empty()); + + real.cs2_bridge_set_settings(true, &fake.url("/bridge/"), None) + .unwrap(); + let accounts = real.cs2_bridge_fetch().unwrap(); + // Only the local accounts come back. + assert_eq!(accounts.len(), 1, "{accounts:?}"); + assert_eq!(accounts[0].steam_id, ALPHA); + assert_eq!(accounts[0].level, Some(12)); + assert_eq!(accounts[0].xp_max, 5000); + assert!(accounts[0].case_earned); + let asked = fake.wait_for("/bridge/accounts"); + let mut ids: Vec = serde_json::from_value(asked.body["steamIds"].clone()).unwrap(); + ids.sort(); + assert_eq!(ids, [ALPHA, "76561198000000002"]); + + let checked = real.cs2_bridge_check(ALPHA).unwrap().unwrap(); + assert_eq!(checked.level, Some(13)); + assert_eq!(fake.wait_for("/bridge/check").body["steamId"], ALPHA); + + let test = real.cs2_bridge_test().unwrap(); + assert!(test.ok && test.error.is_none()); + assert_eq!(test.account_count, 1); + + // A failing server: the test reports it, the fetch fails. + real.cs2_bridge_set_settings(true, &fake.url("/down"), None) + .unwrap(); + let test = real.cs2_bridge_test().unwrap(); + assert!(!test.ok); + assert_eq!( + test.error.as_deref(), + Some("Bridge returned 503 Service Unavailable") + ); + assert_eq!( + real.cs2_bridge_fetch().unwrap_err().message, + "Bridge returned 503 Service Unavailable" + ); +} + +#[test] +fn a_failed_bridge_fetch_is_recorded_as_cs2_bridge_fetch() { + let fake = Fake::start(); + let (scratch, real) = on_fake("cs2-telemetry", &fake); + steam_fixture(&scratch, &real); + real.cs2_bridge_set_settings(true, &fake.url("/down"), None) + .unwrap(); + real.telemetry_complete_onboarding(true, false).unwrap(); + assert!(real.start_telemetry_with(quick())); + assert!(real.cs2_bridge_fetch().is_err()); + real.end_telemetry_session(); + let failed: Vec = fake + .events() + .into_iter() + .map(|(_, e)| e) + .filter(|e| e["name"] == "operation_failed") + .collect(); + assert_eq!(failed.len(), 1); + assert_eq!(failed[0]["operation"], "cs2_bridge_fetch"); + assert_eq!(failed[0]["platform"], "steam"); + assert_eq!(failed[0]["error_code"], "other"); +} diff --git a/crates/accshift-app/tests/secure_pin.rs b/crates/accshift-app/tests/secure_pin.rs new file mode 100644 index 00000000..5a4fb31c --- /dev/null +++ b/crates/accshift-app/tests/secure_pin.rs @@ -0,0 +1,349 @@ +//! `src/lib/shared/pin.test.ts` and `pinSession.test.ts` against +//! `accshift_app::secure::pin`. + +mod support; + +use accshift_app::secure::pin::{ + hash_pin_code, is_pin_locked_error, is_pin_locked_message, is_valid_pin_hash, lock_pin_session, + sanitize_pin_digits, unlock_pin_session, verify_pin_code, PinCheck, PinFailureWait, + PinVerification, PIN_FAILURE_DELAY_MS, +}; +use accshift_app::BackendError; +use serde_json::json; + +// The cross-check vector of pin.test.ts: PIN 4321 under a fixed salt. +const PBKDF2_HASH: &str = + "000102030405060708090a0b0c0d0e0f:e19d9507e40b77fbb7503faedce7cb4ebf8c6820a8b746d9dfa9fcab899ec65d"; +// SHA-256 of the digits "1234", the legacy vector. +const LEGACY_1234: &str = "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4"; +const LOCKED: &str = "pin_locked: Accshift is locked. Enter the PIN to switch accounts."; + +fn is_pbkdf2(hash: &str) -> bool { + let Some((salt, digest)) = hash.split_once(':') else { + return false; + }; + salt.len() == 32 + && digest.len() == 64 + && hash + .chars() + .all(|c| c == ':' || c.is_ascii_digit() || ('a'..='f').contains(&c)) +} + +fn hash(code: &str) -> String { + hash_pin_code(code).expect("a four digit PIN hashes") +} + +const fn verdict(matches: bool) -> PinVerification { + PinVerification { + matches, + rehashed: None, + } +} + +// ------------------------------------------------------------ sanitizePinDigits + +#[test] +fn sanitize_removes_non_digits() { + assert_eq!(sanitize_pin_digits("1a2b3c4d"), "1234"); +} + +#[test] +fn sanitize_truncates_to_four_digits() { + assert_eq!(sanitize_pin_digits("123456"), "1234"); +} + +#[test] +fn sanitize_returns_empty_for_no_digits() { + assert_eq!(sanitize_pin_digits("abc"), ""); +} + +#[test] +fn sanitize_handles_empty_string() { + assert_eq!(sanitize_pin_digits(""), ""); +} + +// ------------------------------------------------------------ isValidPinHash + +#[test] +fn valid_hash_accepts_pbkdf2_format() { + assert!(is_valid_pin_hash(&format!( + "{}:{}", + "a".repeat(32), + "b".repeat(64) + ))); +} + +#[test] +fn valid_hash_accepts_legacy_sha256() { + assert!(is_valid_pin_hash(&"a".repeat(64))); +} + +#[test] +fn valid_hash_rejects_invalid_format() { + assert!(!is_valid_pin_hash("invalid")); +} + +#[test] +fn valid_hash_rejects_empty_string() { + assert!(!is_valid_pin_hash("")); +} + +// ------------------------------------------------------------ hashPinCode + +#[test] +fn hash_returns_salt_and_hash_for_valid_pin() { + assert!(is_pbkdf2(&hash("1234"))); +} + +#[test] +fn hash_returns_nothing_for_invalid_pin() { + assert_eq!(hash_pin_code("12"), None); + assert_eq!(hash_pin_code("abc"), None); +} + +#[test] +fn hash_generates_different_salts_each_call() { + assert_ne!(hash("1234"), hash("1234")); +} + +// ------------------------------------------------------------ verifyPinCode + +#[test] +fn verify_accepts_correct_pin_against_pbkdf2() { + assert_eq!(verify_pin_code("4321", PBKDF2_HASH), verdict(true)); +} + +#[test] +fn verify_rejects_wrong_pin() { + assert_eq!(verify_pin_code("0000", PBKDF2_HASH), verdict(false)); +} + +#[test] +fn verify_handles_legacy_sha256() { + assert!(verify_pin_code("1234", LEGACY_1234).matches); + assert!(!verify_pin_code("0000", LEGACY_1234).matches); +} + +#[test] +fn verify_handles_uppercase_stored_hashes() { + assert!(verify_pin_code("4321", &PBKDF2_HASH.to_uppercase()).matches); +} + +#[test] +fn verify_rejects_invalid_pin_length() { + assert_eq!(verify_pin_code("12", PBKDF2_HASH), verdict(false)); +} + +// ------------------------------------------------------------ legacy migration + +#[test] +fn legacy_hash_comes_back_with_a_pbkdf2_replacement_used_once() { + let first = verify_pin_code("1234", LEGACY_1234); + assert!(first.matches); + let rehashed = first.rehashed.expect("a replacement"); + assert!(is_pbkdf2(&rehashed)); + assert_ne!(rehashed, LEGACY_1234); + // The replacement takes the same PIN and has nothing left to migrate. + assert_eq!(verify_pin_code("1234", &rehashed), verdict(true)); + assert!(!verify_pin_code("0000", &rehashed).matches); +} + +#[test] +fn legacy_hash_rewrites_nothing_when_the_code_is_wrong() { + assert_eq!(verify_pin_code("0000", LEGACY_1234), verdict(false)); +} + +// ------------------------------------------------------------ CLI interop + +#[test] +fn verifies_the_shared_vectors() { + assert!(!verify_pin_code("1111", PBKDF2_HASH).matches); + assert!(verify_pin_code("1234", LEGACY_1234).matches); +} + +// ------------------------------------------------------------ pin_locked errors + +#[test] +fn recognizes_the_backend_refusal() { + assert!(is_pin_locked_message(LOCKED)); + assert!(is_pin_locked_error(&BackendError::new(LOCKED))); + assert!(!is_pin_locked_message("Steam is not installed")); + assert!(!is_pin_locked_message("")); +} + +// ------------------------------------------------------------ unlockPinSession + +#[test] +fn unlocks_through_the_backend() { + let backend = support::scripted("dev"); + backend.push( + "pin_unlock", + Ok(json!({ "status": "unlocked", "legacy": false, "retryAfterMs": 0 })), + ); + let check = unlock_pin_session(&backend, "4321", PBKDF2_HASH); + assert_eq!(backend.calls("pin_unlock"), vec![r#"("4321",)"#]); + assert_eq!(check, PinCheck::Match { rehashed: None }); +} + +#[test] +fn rehashes_a_legacy_pin_the_backend_accepted() { + let backend = support::scripted("dev"); + backend.push( + "pin_unlock", + Ok(json!({ "status": "unlocked", "legacy": true, "retryAfterMs": 0 })), + ); + match unlock_pin_session(&backend, "1234", "") { + PinCheck::Match { + rehashed: Some(hash), + } => assert!(is_pbkdf2(&hash)), + other => panic!("expected a rehashed match, got {other:?}"), + } +} + +#[test] +fn takes_the_backend_verdict_on_a_wrong_code() { + let backend = support::scripted("dev"); + backend.push( + "pin_unlock", + Ok(json!({ "status": "invalid", "legacy": false, "retryAfterMs": 8000 })), + ); + // 4321 matches the local hash: the backend still wins. + assert_eq!( + unlock_pin_session(&backend, "4321", PBKDF2_HASH), + PinCheck::Invalid { + retry_after_ms: 8000 + } + ); +} + +#[test] +fn passes_a_rate_limit_refusal_through() { + let backend = support::scripted("dev"); + backend.push( + "pin_unlock", + Ok(json!({ "status": "retry_later", "legacy": false, "retryAfterMs": 4000 })), + ); + assert_eq!( + unlock_pin_session(&backend, "4321", PBKDF2_HASH), + PinCheck::RetryLater { + retry_after_ms: 4000 + } + ); +} + +#[test] +fn checks_the_local_hash_when_the_backend_has_no_pin() { + let backend = support::scripted("dev"); + for _ in 0..2 { + backend.push( + "pin_unlock", + Ok(json!({ "status": "not_configured", "legacy": false, "retryAfterMs": 0 })), + ); + } + assert_eq!( + unlock_pin_session(&backend, "4321", PBKDF2_HASH), + PinCheck::Match { rehashed: None } + ); + assert_eq!( + unlock_pin_session(&backend, "1111", PBKDF2_HASH), + PinCheck::Invalid { retry_after_ms: 0 } + ); +} + +#[test] +fn checks_the_local_hash_when_the_backend_call_fails() { + let backend = support::scripted("dev"); + backend.push("pin_unlock", Err("ipc down")); + backend.push("pin_unlock", Err("ipc down")); + assert_eq!( + unlock_pin_session(&backend, "1111", PBKDF2_HASH), + PinCheck::Invalid { retry_after_ms: 0 } + ); + assert_eq!( + unlock_pin_session(&backend, "4321", PBKDF2_HASH), + PinCheck::Match { rehashed: None } + ); +} + +#[test] +fn lets_the_screen_go_when_neither_side_holds_a_pin() { + let backend = support::scripted("dev"); + backend.push( + "pin_unlock", + Ok(json!({ "status": "not_configured", "legacy": false, "retryAfterMs": 0 })), + ); + assert_eq!( + unlock_pin_session(&backend, "0000", ""), + PinCheck::Match { rehashed: None } + ); +} + +#[test] +fn the_mock_answers_with_its_own_hash() { + // MockBackend checks the code against the hash its settings hold. + let backend = support::scripted("dev"); + backend + .inner + .set_pin_settings(&json!({ "pinEnabled": true, "pinHash": PBKDF2_HASH })); + assert_eq!( + unlock_pin_session(&backend, "1111", PBKDF2_HASH), + PinCheck::Invalid { + retry_after_ms: 1000 + } + ); + assert_eq!( + unlock_pin_session(&backend, "4321", PBKDF2_HASH), + PinCheck::Match { rehashed: None } + ); +} + +// ------------------------------------------------------------ lockPinSession + +#[test] +fn locks_the_backend_and_swallows_a_failure() { + let backend = support::scripted("dev"); + backend.push("pin_lock", Err("ipc down")); + lock_pin_session(&backend); + assert_eq!(backend.calls("pin_lock").len(), 1); +} + +// ------------------------------------------------------------ pinFailureWait + +#[test] +fn keeps_the_local_pause_for_a_plain_wrong_code() { + assert_eq!( + PinCheck::Invalid { + retry_after_ms: 1000 + } + .failure_wait(), + Some(PinFailureWait { + wait_ms: PIN_FAILURE_DELAY_MS, + too_many_attempts: false, + }) + ); + assert_eq!(PinCheck::Match { rehashed: None }.failure_wait(), None); +} + +#[test] +fn follows_the_backend_once_its_wait_is_longer() { + assert_eq!( + PinCheck::Invalid { + retry_after_ms: 8000 + } + .failure_wait(), + Some(PinFailureWait { + wait_ms: 8000, + too_many_attempts: true, + }) + ); + assert_eq!( + PinCheck::RetryLater { + retry_after_ms: 500 + } + .failure_wait(), + Some(PinFailureWait { + wait_ms: PIN_FAILURE_DELAY_MS, + too_many_attempts: true, + }) + ); +} diff --git a/crates/accshift-app/tests/secure_screen.rs b/crates/accshift-app/tests/secure_screen.rs new file mode 100644 index 00000000..56532872 --- /dev/null +++ b/crates/accshift-app/tests/secure_screen.rs @@ -0,0 +1,463 @@ +//! The secure screen against MockBackend: lock at start, wrong codes up to +//! the lockout, unlock, the inactivity lock, the backend refusal, the legacy +//! rehash, plus `useWindowActivity.test.ts` and the inactivity timer. + +mod support; + +use accshift_app::backend::Backend; +use accshift_app::jobs::Jobs; +use accshift_app::secure::pin::PIN_FAILURE_DELAY_MS; +use accshift_app::secure::screen::{persist_pin_hash, UNLOCK_SETTLE}; +use accshift_app::secure::{ + InactivityBlur, ScreenCover, SecureEvent, SecureInputs, SecureScreen, WindowActivity, + WindowView, +}; +use accshift_app::settings::{sanitize_settings, AppSettings}; +use accshift_app::storage::ClientStorage; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::text::Text; +use accshift_app::BackendError; +use serde_json::{json, Value}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const PBKDF2_HASH: &str = + "000102030405060708090a0b0c0d0e0f:e19d9507e40b77fbb7503faedce7cb4ebf8c6820a8b746d9dfa9fcab899ec65d"; +const LEGACY_1234: &str = "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4"; +const LOCKED: &str = "pin_locked: Accshift is locked. Enter the PIN to switch accounts."; + +fn settings(patch: Value) -> AppSettings { + sanitize_settings(&patch, &StoreContext::shipped()) +} + +fn pin_settings(hash: &str, blur_seconds: i64) -> AppSettings { + settings(json!({ + "pinEnabled": true, + "pinHash": hash, + "inactivityBlurSeconds": blur_seconds, + })) +} + +struct Rig { + backend: Arc, + jobs: Jobs, + settings: AppSettings, + window: WindowView, + selection_view: bool, + screen: SecureScreen, + t0: Instant, +} + +impl Rig { + fn new(settings: AppSettings) -> Self { + let backend = Arc::new(support::scripted("dev")); + backend.inner.set_pin_settings(&json!({ + "pinEnabled": settings.pin_enabled, + "pinHash": settings.pin_hash, + })); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let t0 = Instant::now(); + let screen = SecureScreen::new(&settings, t0); + Self { + backend, + jobs, + settings, + window: WindowView::default(), + selection_view: true, + screen, + t0, + } + } + + fn at(&self, ms: u64) -> Instant { + self.t0 + Duration::from_millis(ms) + } + + /// One update at `ms`, then more at the same instant until no unlock is + /// in flight. Every event of the frames, in order. + fn frame(&mut self, ms: u64) -> Vec { + let now = self.at(ms); + let deadline = Instant::now() + Duration::from_secs(20); + let mut events = Vec::new(); + loop { + let inputs = SecureInputs { + settings: &self.settings, + is_account_selection_view: self.selection_view, + window: self.window, + app_version: "1.2.3", + }; + events.extend(self.screen.update(&inputs, &self.jobs, now)); + if !self.screen.is_busy() { + return events; + } + assert!(Instant::now() < deadline, "unlock never finished"); + std::thread::sleep(Duration::from_millis(2)); + } + } + + fn mount(&mut self) -> Vec { + let mut events = self.frame(0); + self.screen.handle_app_mounted(&self.jobs, self.t0); + events.extend(self.frame(0)); + events + } + + fn type_code(&mut self, code: &str, ms: u64) -> Vec { + self.screen.set_pin_attempt(code); + self.frame(ms) + } + + /// The fire-and-forget calls ran. + fn drain_jobs(&self) { + self.jobs.run(|_| ()).wait(); + } +} + +#[test] +fn starts_locked_with_a_valid_pin_and_tells_the_backend() { + let mut rig = Rig::new(pin_settings(PBKDF2_HASH, 0)); + assert!(rig.screen.is_pin_locked()); + let events = rig.mount(); + assert!(events.contains(&SecureEvent::FocusPinInput)); + rig.drain_jobs(); + assert_eq!(rig.backend.calls("pin_lock").len(), 1); + assert!(rig.screen.pin_overlay_visible()); + assert!(rig.screen.is_obscured()); + let cover = ScreenCover::compute(&rig.screen, false, false); + assert!(cover.stage_locked && cover.shell_inert && cover.keys_locked && cover.frost_visible); +} + +#[test] +fn starts_unlocked_without_a_pin_or_with_a_malformed_one() { + let rig = Rig::new(settings(json!({}))); + assert!(!rig.screen.is_pin_locked()); + let mut rig = Rig::new(pin_settings("not-a-hash", 0)); + assert!(!rig.screen.is_pin_locked()); + rig.mount(); + assert!(!rig.screen.is_pin_locked()); + assert_eq!(rig.backend.calls("pin_lock").len(), 0); +} + +#[test] +fn a_wrong_code_holds_the_field_then_the_right_one_unlocks() { + let mut rig = Rig::new(pin_settings(PBKDF2_HASH, 0)); + rig.mount(); + rig.drain_jobs(); + // The mock session follows: switching is refused while locked. + let refused = rig + .backend + .inner + .platform_switch_account("steam", "76561198100000001", &json!({})) + .expect_err("locked"); + assert!(rig.screen.report_backend_error(&refused)); + + // The mock checks the hash and answers a plain wrong code with 1 s. + rig.type_code("1111", 0); + assert_eq!(rig.screen.pin_error(), Some(&Text::key("pin.invalid"))); + assert!(rig.screen.is_pin_retry_locked()); + assert!(!rig.screen.pin_input_enabled()); + assert_eq!(rig.screen.pin_attempt(), ""); + + // Typing during the pause does not send anything. + rig.backend.clear_log(); + rig.type_code("4321", 100); + assert!(rig.backend.calls("pin_unlock").is_empty()); + assert!(rig.screen.is_pin_locked()); + + // The pause ends at 1200 ms, the field takes focus, the code goes out. + let events = rig.frame(PIN_FAILURE_DELAY_MS); + assert!(events.contains(&SecureEvent::FocusPinInput)); + assert_eq!(rig.backend.calls("pin_unlock").len(), 1); + assert_eq!(rig.screen.pin_error(), None); + // Accepted: the overlay settles for 240 ms before it lets go. + assert!(rig.screen.is_pin_locked()); + let settle = PIN_FAILURE_DELAY_MS + UNLOCK_SETTLE.as_millis() as u64; + rig.frame(settle); + assert!(!rig.screen.is_pin_locked()); + assert!(!rig.screen.is_obscured()); + + // The mock session is unlocked: a switch goes through. + assert_eq!( + rig.backend + .inner + .platform_switch_account("steam", "76561198100000001", &json!({})), + Ok(()) + ); +} + +#[test] +fn wrong_codes_escalate_to_the_lockout_then_unlock() { + let mut rig = Rig::new(pin_settings(PBKDF2_HASH, 0)); + rig.mount(); + // The backend's schedule (accshift_core::pin::failure_delay): four + // wrong codes at 1 s, then doubling. + let mut ms = 0; + for failures in 1..=6u32 { + let retry = accshift_core::pin::failure_delay(failures).as_millis() as u64; + rig.backend.push( + "pin_unlock", + Ok(json!({ "status": "invalid", "legacy": false, "retryAfterMs": retry })), + ); + rig.type_code("1111", ms); + let expected = if retry > PIN_FAILURE_DELAY_MS { + Text::with("pin.tooManyAttempts", [("seconds", retry.div_ceil(1000))]) + } else { + Text::key("pin.invalid") + }; + assert_eq!( + rig.screen.pin_error(), + Some(&expected), + "failure {failures}" + ); + let wait = retry.max(PIN_FAILURE_DELAY_MS); + // Still held one millisecond before the wait ends. + rig.frame(ms + wait - 1); + assert!(rig.screen.is_pin_retry_locked(), "failure {failures}"); + ms += wait; + rig.frame(ms); + assert!(!rig.screen.is_pin_retry_locked(), "failure {failures}"); + } + // Locked out: the backend refuses even the right code for a while. + rig.backend.push( + "pin_unlock", + Ok(json!({ "status": "retry_later", "legacy": false, "retryAfterMs": 3000 })), + ); + rig.type_code("4321", ms); + assert_eq!( + rig.screen.pin_error(), + Some(&Text::with("pin.tooManyAttempts", [("seconds", 3u64)])) + ); + ms += 3000; + rig.frame(ms); + // Then the mock checks it for real. + rig.type_code("4321", ms); + rig.frame(ms + UNLOCK_SETTLE.as_millis() as u64); + assert!(!rig.screen.is_pin_locked()); + assert_eq!(rig.backend.calls("pin_unlock").len(), 8); +} + +#[test] +fn inactivity_blurs_then_locks_when_a_pin_is_set() { + let mut rig = Rig::new(pin_settings(PBKDF2_HASH, 30)); + // Start unlocked, as after a first unlock. + rig.mount(); + rig.type_code("4321", 0); + rig.frame(UNLOCK_SETTLE.as_millis() as u64); + assert!(!rig.screen.is_pin_locked()); + rig.backend.clear_log(); + + // Activity pushes the deadline: a key at 20 s, nothing until 50 s. + rig.screen.on_key_down(rig.at(20_000)); + rig.frame(49_000); + assert!(!rig.screen.is_pin_locked()); + let events = rig.frame(50_000); + assert!(rig.screen.blur().is_blurred()); + assert!(rig.screen.is_pin_locked()); + assert!(events.contains(&SecureEvent::CloseContextMenu)); + assert!(events.contains(&SecureEvent::FocusPinInput)); + rig.drain_jobs(); + assert_eq!(rig.backend.calls("pin_lock").len(), 1); + // The PIN overlay wins over the AFK cover. + assert!(!rig.screen.afk_overlay_visible()); +} + +#[test] +fn inactivity_without_a_pin_shows_the_afk_cover() { + let mut rig = Rig::new(settings(json!({ "inactivityBlurSeconds": 10 }))); + rig.mount(); + rig.frame(9_999); + assert!(!rig.screen.afk_overlay_visible()); + let events = rig.frame(10_000); + assert!(rig.screen.afk_overlay_visible()); + assert!(rig.screen.afk_wave_active()); + assert_eq!(rig.screen.afk_version_label(), Some("1.2.3")); + assert!(events.contains(&SecureEvent::CloseContextMenu)); + assert!(!rig.screen.is_pin_locked()); + + // Any key lifts it; the wave text fades for 900 ms more. + rig.screen.on_key_down(rig.at(11_000)); + rig.frame(11_000); + assert!(!rig.screen.afk_overlay_visible()); + assert!(rig.screen.afk_wave_active()); + rig.frame(11_900); + assert!(!rig.screen.afk_wave_active()); + + // The AFK cover only covers the account grid. + rig.selection_view = false; + rig.frame(30_000); + assert!(rig.screen.blur().is_blurred()); + assert!(!rig.screen.afk_overlay_visible()); +} + +#[test] +fn the_backend_refusal_locks_until_it_accepts_a_code() { + // No PIN in this window's settings: only the backend's lock holds. + let mut rig = Rig::new(settings(json!({}))); + rig.mount(); + assert!(!rig + .screen + .report_backend_error(&BackendError::new("Steam is not installed"))); + assert!(!rig.screen.is_pin_locked()); + assert!(rig.screen.report_backend_error(&BackendError::new(LOCKED))); + let events = rig.frame(10); + assert!(rig.screen.is_pin_locked()); + assert!(rig.screen.is_backend_locked()); + assert!(events.contains(&SecureEvent::FocusPinInput)); + // The backend is locked already: no pin_lock from this side. + rig.drain_jobs(); + assert_eq!(rig.backend.calls("pin_lock").len(), 0); + + rig.backend.push( + "pin_unlock", + Ok(json!({ "status": "unlocked", "legacy": false, "retryAfterMs": 0 })), + ); + rig.type_code("2580", 20); + rig.frame(20 + UNLOCK_SETTLE.as_millis() as u64); + assert!(!rig.screen.is_pin_locked()); + assert!(!rig.screen.is_backend_locked()); +} + +#[test] +fn a_legacy_hash_is_rehashed_and_persisted() { + let mut rig = Rig::new(pin_settings(LEGACY_1234, 0)); + rig.mount(); + let events = rig.type_code("1234", 0); + let hash = events + .iter() + .find_map(|event| match event { + SecureEvent::PersistPinHash(hash) => Some(hash.clone()), + _ => None, + }) + .expect("a PBKDF2 replacement"); + assert!(hash.contains(':')); + assert!(accshift_app::secure::pin::verify_pin_code("1234", &hash).matches); + + let mut stores = ClientStores::new(ClientStorage::new(), StoreContext::shipped()); + let mut current = stores.settings().clone(); + current.pin_enabled = true; + current.pin_hash = LEGACY_1234.to_string(); + current.inactivity_blur_seconds = 120; + stores.save_settings(¤t); + persist_pin_hash(&mut stores, &hash); + assert_eq!(stores.settings().pin_hash, hash); + // Only the hash travels. + assert_eq!(stores.settings().inactivity_blur_seconds, 120); +} + +#[test] +fn turning_the_pin_off_drops_the_lock() { + let mut rig = Rig::new(pin_settings(PBKDF2_HASH, 0)); + rig.mount(); + rig.screen.set_pin_attempt("12"); + rig.settings.pin_enabled = false; + rig.frame(5); + assert!(!rig.screen.is_pin_locked()); + assert_eq!(rig.screen.pin_attempt(), ""); +} + +#[test] +fn minimized_with_suspend_stops_drawing_and_closes_the_menu() { + let mut patch = pin_settings(PBKDF2_HASH, 0); + patch.suspend_graphics_when_minimized = true; + let mut rig = Rig::new(patch); + rig.mount(); + rig.window = WindowView { + foreground: false, + minimized: true, + page_visible: true, + }; + let events = rig.frame(5); + assert!(rig.screen.render_suspended()); + assert!(events.contains(&SecureEvent::CloseContextMenu)); + assert!(!rig.screen.pin_overlay_visible()); + assert!(!rig.screen.blur().is_listening()); + let cover = ScreenCover::compute(&rig.screen, false, false); + assert!(cover.render_suspended && cover.grid_hidden && !cover.frost_visible); + rig.window = WindowView::default(); + rig.frame(10); + assert!(rig.screen.blur().is_listening()); +} + +#[test] +fn streamer_mode_obscures_and_freezes_the_shell() { + let rig = Rig::new(settings(json!({}))); + let cover = ScreenCover::compute(&rig.screen, true, false); + assert!(cover.shell_obscured && cover.shell_inert && cover.keys_locked); + assert!(!cover.stage_locked && !cover.frost_visible); + assert_eq!( + ScreenCover::compute(&rig.screen, false, false), + ScreenCover::default() + ); +} + +// ------------------------------------------------------------ inactivity timer + +#[test] +fn the_pointer_resets_the_timer_at_most_every_200_ms() { + let t0 = Instant::now(); + let at = |ms: u64| t0 + Duration::from_millis(ms); + let mut blur = InactivityBlur::new(t0); + blur.start(t0, 1); + blur.attach_listeners(); + blur.on_pointer_move(at(150), 1); + // Throttled: the deadline still counts from 0. + assert_eq!(blur.next_deadline(), Some(at(1000))); + blur.on_pointer_move(at(300), 1); + assert_eq!(blur.next_deadline(), Some(at(1300))); + // Detached: nothing counts. + blur.detach_listeners(); + blur.on_key_down(at(900), 1); + assert!(blur.tick(at(1300), 1)); + assert!(blur.is_blurred()); +} + +#[test] +fn a_zero_threshold_never_blurs() { + let t0 = Instant::now(); + let mut blur = InactivityBlur::new(t0); + blur.start(t0, 0); + assert_eq!(blur.next_deadline(), None); + assert!(!blur.tick(t0 + Duration::from_secs(3600), 0)); + assert!(!blur.is_blurred()); +} + +// ------------------------------------------------------------ window activity + +#[test] +fn window_activity_drops_events_after_stop() { + let mut activity = WindowActivity::new(); + activity.on_focus_changed(false, true); + assert!(activity.is_focused(), "not started yet"); + activity.start(true); + assert!(activity.take_sync_request()); + activity.stop(); + activity.on_focus_changed(false, true); + activity.on_resized(); + assert!(activity.is_focused()); + assert!(!activity.take_sync_request()); + + // A second pass works as the first. + activity.start(true); + assert!(activity.take_sync_request()); + activity.on_focus_changed(false, true); + assert!(!activity.is_foreground()); + assert!(activity.take_sync_request()); + activity.apply_sync(true, Some(false), Some(true)); + assert!(activity.is_minimized()); + activity.on_focus_changed(true, true); + assert!(activity.is_foreground()); + assert!(!activity.is_minimized()); +} + +#[test] +fn window_activity_keeps_old_values_when_a_query_fails() { + let mut activity = WindowActivity::new(); + activity.start(true); + activity.apply_sync(true, Some(false), Some(true)); + activity.apply_sync(false, None, None); + assert!(!activity.is_focused()); + assert!(activity.is_minimized()); + assert!(!activity.is_page_visible()); + activity.on_visibility_changed(true); + assert!(activity.take_sync_request()); +} diff --git a/crates/accshift-app/tests/stores_roundtrip.rs b/crates/accshift-app/tests/stores_roundtrip.rs new file mode 100644 index 00000000..002a7c54 --- /dev/null +++ b/crates/accshift-app/tests/stores_roundtrip.rs @@ -0,0 +1,198 @@ +//! Round trip against the former TypeScript stores: every fixture of +//! `tests/data/stores/roundtrip.json` is a stored value, an operation and +//! what the TS store saved after it. The +//! Rust store loads the same value, runs the same operation and must save the +//! same JSON, byte for byte once serialized the way the backend writes it. + +use accshift_app::folders::ItemRef; +use accshift_app::personas::{PersonaAssignment, PersonaPatch}; +use accshift_app::storage::{ClientStorage, CLIENT_STORE_IDS}; +use accshift_app::stores::{ + ClientStores, DefaultGame, StoreContext, BUILT_IN_THEME_IDS, SHIPPED_PLATFORM_IDS, +}; +use accshift_app::types::{ClientStorageSnapshot, StorageManifest}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::Path; + +fn data(name: &str) -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/data/stores") + .join(name); + serde_json::from_str(&std::fs::read_to_string(&path).expect("fixture")).expect("json") +} + +fn strings(value: &Value) -> Vec { + value + .as_array() + .expect("array") + .iter() + .map(|item| item.as_str().expect("string").to_string()) + .collect() +} + +fn context() -> StoreContext { + let raw = data("context.json"); + StoreContext { + platform_ids: strings(&raw["platformIds"]), + theme_ids: strings(&raw["themeIds"]), + preferred_languages: strings(&raw["preferredLanguages"]), + } +} + +#[test] +fn shipped_lists_match_the_ts_registry() { + let ctx = context(); + assert_eq!(ctx.platform_ids, SHIPPED_PLATFORM_IDS.map(String::from)); + assert_eq!(ctx.theme_ids, BUILT_IN_THEME_IDS.map(String::from)); +} + +fn opt_str(value: &Value) -> Option<&str> { + value.as_str() +} + +fn item_ref(value: &Value) -> ItemRef { + serde_json::from_value(value.clone()).expect("item ref") +} + +fn run(stores: &mut ClientStores, op: &Value) { + let s = |key: &str| op[key].as_str().unwrap_or_default().to_string(); + match op["kind"].as_str().expect("kind") { + "saveSettings" => { + let settings = stores.settings().clone(); + stores.save_settings(&settings); + } + "reorderRoot" => { + let platform = s("platform"); + let items = stores.items_in_folder(None, &platform).to_vec(); + stores.reorder_items(None, &platform, items); + } + "moveItem" => stores.move_item( + &item_ref(&op["item"]), + opt_str(&op["from"]), + opt_str(&op["to"]), + &s("platform"), + op["index"].as_u64().map(|index| index as usize), + ), + "deleteFolder" => stores.delete_folder(&s("id")), + "renameFolder" => stores.rename_folder(&s("id"), &s("name")), + "syncAccounts" => stores.sync_accounts(&strings(&op["ids"]), &s("platform")), + "deletePersona" => stores.delete_persona(&s("id")), + "updatePersona" => { + let patch = &op["patch"]; + let assignments: Option> = patch + .get("assignments") + .map(|value| serde_json::from_value(value.clone()).expect("assignments")); + stores.update_persona( + &s("id"), + &PersonaPatch { + name: patch["name"].as_str().map(String::from), + color: patch["color"].as_str().map(String::from), + image: patch + .get("image") + .map(|image| image.as_str().map(String::from)), + assignments, + }, + ); + } + "clearNote" => stores.clear_account_card_note(&s("id")), + "setNote" => stores.set_account_card_note(&s("id"), &s("note")), + "setColor" => stores.set_account_card_color(&s("id"), &s("color")), + "setColors" => stores.set_account_card_colors(&strings(&op["ids"]), &s("color")), + "setFolderColor" => stores.set_folder_card_color(&s("id"), &s("color")), + "setDefaultGame" => { + let game: DefaultGame = serde_json::from_value(op["game"].clone()).expect("game"); + stores.set_default_game(&s("id"), &game); + } + "resaveViewMode" => { + let mode = stores.view_mode(); + stores.set_view_mode(mode); + } + other => panic!("unknown op {other}"), + } +} + +/// `value` with every object's keys sorted. Key order is not part of the +/// contract: the stores write their maps from `BTreeMap`s, where the TS wrote +/// insertion order, and serde_json keeps the fixture's order only when another +/// crate of the build turns on `preserve_order` (accshift-theme does). +fn sorted(value: &Value) -> Value { + match value { + Value::Object(map) => { + let sorted: BTreeMap<&String, Value> = + map.iter().map(|(k, v)| (k, sorted(v))).collect(); + Value::Object(sorted.into_iter().map(|(k, v)| (k.clone(), v)).collect()) + } + Value::Array(items) => Value::Array(items.iter().map(sorted).collect()), + other => other.clone(), + } +} + +#[test] +fn every_fixture_saves_what_the_ts_store_saved() { + let fixtures = data("roundtrip.json"); + let fixtures = fixtures.as_array().expect("fixtures"); + let ctx = context(); + let mut failures = Vec::new(); + let mut by_store: BTreeMap = BTreeMap::new(); + for fixture in fixtures { + let name = fixture["name"].as_str().expect("name"); + let store_id = fixture["storeId"].as_str().expect("store id"); + *by_store.entry(store_id.to_string()).or_default() += 1; + let snapshot = ClientStorageSnapshot { + manifest: StorageManifest { + schema_version: 1, + stores: BTreeMap::new(), + }, + stores: BTreeMap::from([(store_id.to_string(), fixture["input"].clone())]), + }; + let mut stores = ClientStores::new(ClientStorage::from_snapshot(snapshot), ctx.clone()); + run(&mut stores, &fixture["op"]); + let saves = stores.take_all_saves(); + let wrote = fixture["wrote"].as_bool().expect("wrote"); + let saved = saves.iter().find(|save| save.store_id == store_id); + match (wrote, saved) { + (false, None) => {} + (false, Some(save)) => failures.push(format!("{name}: wrote {}", save.value)), + (true, None) => failures.push(format!("{name}: wrote nothing")), + (true, Some(save)) => { + let expected = serde_json::to_string_pretty(&sorted(&fixture["expected"])).unwrap(); + let actual = serde_json::to_string_pretty(&sorted(&save.value)).unwrap(); + if expected != actual { + failures.push(format!("{name}:\n expected {expected}\n actual {actual}")); + } + } + } + assert!( + saves.iter().all(|save| save.store_id == store_id), + "{name}: wrote another store" + ); + } + eprintln!("round-trip fixtures: {}", fixtures.len()); + eprintln!("by store: {by_store:?}"); + assert!(failures.is_empty(), "{}", failures.join("\n")); + let typed = CLIENT_STORE_IDS.len() - 4; + assert_eq!(by_store.len(), typed, "every typed store has fixtures"); +} + +#[test] +fn a_fixture_with_no_write_is_listed() { + let fixtures = data("roundtrip.json"); + let silent: Vec<&str> = fixtures + .as_array() + .unwrap() + .iter() + .filter(|fixture| fixture["wrote"] == Value::Bool(false)) + .map(|fixture| fixture["name"].as_str().unwrap()) + .collect(); + assert_eq!( + silent, + [ + "folders move folder into its own subtree", + "folders delete missing folder", + "folders sync unchanged", + "colors set many empty list", + "default game invalid set", + ] + ); +} diff --git a/crates/accshift-app/tests/streamer_mode.rs b/crates/accshift-app/tests/streamer_mode.rs new file mode 100644 index 00000000..0afd54df --- /dev/null +++ b/crates/accshift-app/tests/streamer_mode.rs @@ -0,0 +1,178 @@ +//! `useStreamerMode.test.ts` and streamer mode against MockBackend: on, off, +//! dismissed, and what the cover takes away. + +mod support; + +use accshift_app::backend::Backend; +use accshift_app::jobs::Jobs; +use accshift_app::secure::{ScreenCover, SecureScreen}; +use accshift_app::settings::{sanitize_settings, StreamerMode}; +use accshift_app::storage::ClientStorage; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::streamer::{StreamerModeController, POLL_INTERVAL}; +use serde_json::json; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +struct Rig { + backend: Arc, + jobs: Jobs, + controller: StreamerModeController, + t0: Instant, +} + +impl Rig { + fn new() -> Self { + let backend = Arc::new(support::scripted("dev")); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let settings = sanitize_settings(&json!({}), &StoreContext::shipped()); + assert_eq!(settings.streamer_mode, StreamerMode::Auto); + Self { + backend, + jobs, + controller: StreamerModeController::new(&settings), + t0: Instant::now(), + } + } + + fn at(&self, ms: u64) -> Instant { + self.t0 + Duration::from_millis(ms) + } + + /// Ticks at `ms` until no detection is in flight. True when `active` + /// changed on the way. + fn tick(&mut self, ms: u64, hidden: bool) -> bool { + let now = self.at(ms); + let deadline = Instant::now() + Duration::from_secs(10); + let mut changed = self.controller.tick(&self.jobs, now, hidden); + while self.controller.is_busy() { + assert!(Instant::now() < deadline, "detection never finished"); + std::thread::sleep(Duration::from_millis(2)); + changed |= self.controller.tick(&self.jobs, now, hidden); + } + changed + } + + fn detections(&self) -> usize { + self.backend.calls("detect_streaming_software").len() + } + + fn answer(&self, detected: bool) { + self.backend + .push("detect_streaming_software", Ok(json!(detected))); + } +} + +#[test] +fn polls_every_few_seconds_while_visible_and_far_less_while_hidden() { + let mut rig = Rig::new(); + rig.controller.start(&rig.jobs, rig.at(0)); + rig.tick(0, false); + assert_eq!(rig.detections(), 1); + + let step = POLL_INTERVAL.as_millis() as u64; + let mut ms = 0; + for _ in 0..2 { + ms += step; + rig.tick(ms, false); + } + assert_eq!(rig.detections(), 3); + + // 28 s hidden: one more poll at most, against seven while visible. + for _ in 0..7 { + ms += step; + rig.tick(ms, true); + } + assert!(rig.detections() <= 4); + + ms += step; + rig.tick(ms, false); + assert!(rig.detections() >= 4); + rig.controller.stop(); + assert!(!rig.controller.is_running()); +} + +#[test] +fn detection_turns_the_cover_on_and_off_and_reports_once() { + let mut rig = Rig::new(); + let step = POLL_INTERVAL.as_millis() as u64; + rig.answer(true); + rig.controller.start(&rig.jobs, rig.at(0)); + assert!(rig.tick(0, false)); + assert!(rig.controller.active()); + + // What it hides: the whole shell, blurred, inert and without keys. + let screen = SecureScreen::new( + &sanitize_settings(&json!({}), &StoreContext::shipped()), + rig.at(0), + ); + let cover = ScreenCover::compute(&screen, rig.controller.active(), false); + assert!(cover.shell_obscured && cover.shell_inert && cover.keys_locked); + + // Still streaming: no change, no second report. + rig.answer(true); + assert!(!rig.tick(step, false)); + rig.jobs.run(|_| ()).wait(); + assert_eq!(rig.backend.calls("telemetry_track_streamer_mode").len(), 1); + + // The software quits: the cover goes. + rig.answer(false); + assert!(rig.tick(2 * step, false)); + assert!(!rig.controller.active()); + assert_eq!( + ScreenCover::compute(&screen, rig.controller.active(), false), + ScreenCover::default() + ); +} + +#[test] +fn dismiss_holds_until_the_stream_stops() { + let mut rig = Rig::new(); + let step = POLL_INTERVAL.as_millis() as u64; + rig.answer(true); + rig.controller.start(&rig.jobs, rig.at(0)); + rig.tick(0, false); + rig.controller.dismiss(); + assert!(!rig.controller.active()); + rig.answer(true); + rig.tick(step, false); + assert!(!rig.controller.active()); + // A pause in the stream re-arms it. + rig.answer(false); + rig.tick(2 * step, false); + rig.answer(true); + rig.tick(3 * step, false); + assert!(rig.controller.active()); +} + +#[test] +fn a_failed_detection_keeps_the_state() { + let mut rig = Rig::new(); + rig.answer(true); + rig.controller.start(&rig.jobs, rig.at(0)); + rig.tick(0, false); + rig.backend + .push("detect_streaming_software", Err("scan failed")); + assert!(!rig.tick(POLL_INTERVAL.as_millis() as u64, false)); + assert!(rig.controller.active()); +} + +#[test] +fn disable_permanently_saves_off_and_stops_detecting() { + let mut rig = Rig::new(); + let mut stores = ClientStores::new(ClientStorage::new(), StoreContext::shipped()); + rig.answer(true); + rig.controller.start(&rig.jobs, rig.at(0)); + rig.tick(0, false); + rig.controller.disable_permanently(&mut stores); + assert_eq!(stores.settings().streamer_mode, StreamerMode::Off); + assert!(!rig.controller.enabled()); + assert!(!rig.controller.active()); + assert!(!stores.take_all_saves().is_empty()); + + // Off: the interval runs but asks nothing. + rig.backend.clear_log(); + rig.tick(POLL_INTERVAL.as_millis() as u64, false); + assert_eq!(rig.detections(), 0); + assert!(!rig.controller.streaming_detected()); +} diff --git a/crates/accshift-app/tests/support/mod.rs b/crates/accshift-app/tests/support/mod.rs new file mode 100644 index 00000000..a4df5e85 --- /dev/null +++ b/crates/accshift-app/tests/support/mod.rs @@ -0,0 +1,24 @@ +//! Shared helpers of the platform model tests. +#![allow(dead_code)] + +pub mod scripted; + +use accshift_app::mock::MockBackend; +use std::path::{Path, PathBuf}; + +pub fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("repo root") + .to_path_buf() +} + +pub fn mock(scenario: &str) -> MockBackend { + let root = repo_root(); + MockBackend::load(&MockBackend::data_dir(&root), scenario, Some(&root)).expect("scenario") +} + +pub fn scripted(scenario: &str) -> scripted::Scripted { + scripted::Scripted::new(mock(scenario)) +} diff --git a/crates/accshift-app/tests/support/scripted.rs b/crates/accshift-app/tests/support/scripted.rs new file mode 100644 index 00000000..825045ef --- /dev/null +++ b/crates/accshift-app/tests/support/scripted.rs @@ -0,0 +1,741 @@ +//! A Backend for tests: every call is logged, a scripted answer (queued per +//! command) wins, anything else goes to the wrapped MockBackend. +//! +//! Generated from the Backend trait of src/backend.rs (73 methods); +//! regenerate when the trait changes. +#![allow(dead_code, clippy::all)] + +use accshift_app::mock::MockBackend; +use accshift_app::types::*; +use accshift_app::{Backend, BackendError, BackendResult}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::collections::{BTreeMap, VecDeque}; +use std::sync::Mutex; + +pub struct Scripted { + pub inner: MockBackend, + log: Mutex>, + script: Mutex>>>, +} + +impl Scripted { + pub fn new(inner: MockBackend) -> Self { + Self { + inner, + log: Mutex::new(Vec::new()), + script: Mutex::new(BTreeMap::new()), + } + } + + /// Queues one answer for `command`: `Ok(json)` or `Err(message)`. + pub fn push(&self, command: &str, answer: Result) { + self.script + .lock() + .unwrap() + .entry(command.to_string()) + .or_default() + .push_back(answer.map_err(str::to_string)); + } + + /// The Debug-printed arguments of every call to `command`, in order. + pub fn calls(&self, command: &str) -> Vec { + self.log + .lock() + .unwrap() + .iter() + .filter(|(name, _)| name == command) + .map(|(_, args)| args.clone()) + .collect() + } + + pub fn clear_log(&self) { + self.log.lock().unwrap().clear(); + } + + fn record(&self, command: &str, args: String) { + self.log.lock().unwrap().push((command.to_string(), args)); + } + + fn next(&self, command: &str) -> Option> { + let answer = self.script.lock().unwrap().get_mut(command)?.pop_front()?; + Some(match answer { + Ok(value) => serde_json::from_value(value) + .map_err(|e| BackendError::new(format!("scripted {command}: {e}"))), + Err(message) => Err(BackendError::new(message)), + }) + } +} + +impl Backend for Scripted { + fn get_boot_payload(&self) -> BackendResult { + self.record("get_boot_payload", String::new()); + if let Some(answer) = self.next("get_boot_payload") { + return answer; + } + self.inner.get_boot_payload() + } + fn get_runtime_os(&self) -> BackendResult { + self.record("get_runtime_os", String::new()); + if let Some(answer) = self.next("get_runtime_os") { + return answer; + } + self.inner.get_runtime_os() + } + fn detect_streaming_software(&self) -> BackendResult { + self.record("detect_streaming_software", String::new()); + if let Some(answer) = self.next("detect_streaming_software") { + return answer; + } + self.inner.detect_streaming_software() + } + fn migrate_legacy_config(&self) -> BackendResult { + self.record("migrate_legacy_config", String::new()); + if let Some(answer) = self.next("migrate_legacy_config") { + return answer; + } + self.inner.migrate_legacy_config() + } + fn load_client_storage_snapshot(&self) -> BackendResult { + self.record("load_client_storage_snapshot", String::new()); + if let Some(answer) = self.next("load_client_storage_snapshot") { + return answer; + } + self.inner.load_client_storage_snapshot() + } + fn save_client_storage_store( + &self, + store_id: &str, + value: &Value, + ) -> BackendResult> { + self.record( + "save_client_storage_store", + format!("{:?}", (&store_id, &value)), + ); + if let Some(answer) = self.next("save_client_storage_store") { + return answer; + } + self.inner.save_client_storage_store(store_id, value) + } + fn get_storage_manifest(&self) -> BackendResult { + self.record("get_storage_manifest", String::new()); + if let Some(answer) = self.next("get_storage_manifest") { + return answer; + } + self.inner.get_storage_manifest() + } + fn log_app_event( + &self, + level: &str, + source: &str, + message: &str, + details: Option<&str>, + ) -> BackendResult<()> { + self.record( + "log_app_event", + format!("{:?}", (&level, &source, &message, &details)), + ); + if let Some(answer) = self.next("log_app_event") { + return answer; + } + self.inner.log_app_event(level, source, message, details) + } + fn finish_boot(&self, source: &str, marks: Option<&Value>) -> BackendResult<()> { + self.record("finish_boot", format!("{:?}", (&source, &marks))); + if let Some(answer) = self.next("finish_boot") { + return answer; + } + self.inner.finish_boot(source, marks) + } + fn pin_unlock(&self, code: &str) -> BackendResult { + self.record("pin_unlock", format!("{:?}", (&code,))); + if let Some(answer) = self.next("pin_unlock") { + return answer; + } + self.inner.pin_unlock(code) + } + fn pin_lock(&self) -> BackendResult<()> { + self.record("pin_lock", String::new()); + if let Some(answer) = self.next("pin_lock") { + return answer; + } + self.inner.pin_lock() + } + fn platform_get_accounts(&self, platform_id: &str) -> BackendResult { + self.record("platform_get_accounts", format!("{:?}", (&platform_id,))); + if let Some(answer) = self.next("platform_get_accounts") { + return answer; + } + self.inner.platform_get_accounts(platform_id) + } + fn platform_get_startup_snapshot(&self, platform_id: &str) -> BackendResult { + self.record( + "platform_get_startup_snapshot", + format!("{:?}", (&platform_id,)), + ); + if let Some(answer) = self.next("platform_get_startup_snapshot") { + return answer; + } + self.inner.platform_get_startup_snapshot(platform_id) + } + fn platform_get_current_account(&self, platform_id: &str) -> BackendResult { + self.record( + "platform_get_current_account", + format!("{:?}", (&platform_id,)), + ); + if let Some(answer) = self.next("platform_get_current_account") { + return answer; + } + self.inner.platform_get_current_account(platform_id) + } + fn platform_switch_account( + &self, + platform_id: &str, + account_id: &str, + params: &Value, + ) -> BackendResult<()> { + self.record( + "platform_switch_account", + format!("{:?}", (&platform_id, &account_id, ¶ms)), + ); + if let Some(answer) = self.next("platform_switch_account") { + return answer; + } + self.inner + .platform_switch_account(platform_id, account_id, params) + } + fn platform_forget_account(&self, platform_id: &str, account_id: &str) -> BackendResult<()> { + self.record( + "platform_forget_account", + format!("{:?}", (&platform_id, &account_id)), + ); + if let Some(answer) = self.next("platform_forget_account") { + return answer; + } + self.inner.platform_forget_account(platform_id, account_id) + } + fn platform_begin_setup( + &self, + platform_id: &str, + params: &Value, + ) -> BackendResult { + self.record( + "platform_begin_setup", + format!("{:?}", (&platform_id, ¶ms)), + ); + if let Some(answer) = self.next("platform_begin_setup") { + return answer; + } + self.inner.platform_begin_setup(platform_id, params) + } + fn platform_get_setup_status( + &self, + platform_id: &str, + setup_id: &str, + ) -> BackendResult { + self.record( + "platform_get_setup_status", + format!("{:?}", (&platform_id, &setup_id)), + ); + if let Some(answer) = self.next("platform_get_setup_status") { + return answer; + } + self.inner.platform_get_setup_status(platform_id, setup_id) + } + fn platform_cancel_setup(&self, platform_id: &str, setup_id: &str) -> BackendResult<()> { + self.record( + "platform_cancel_setup", + format!("{:?}", (&platform_id, &setup_id)), + ); + if let Some(answer) = self.next("platform_cancel_setup") { + return answer; + } + self.inner.platform_cancel_setup(platform_id, setup_id) + } + fn platform_get_path(&self, platform_id: &str) -> BackendResult { + self.record("platform_get_path", format!("{:?}", (&platform_id,))); + if let Some(answer) = self.next("platform_get_path") { + return answer; + } + self.inner.platform_get_path(platform_id) + } + fn platform_set_path(&self, platform_id: &str, path: &str) -> BackendResult<()> { + self.record("platform_set_path", format!("{:?}", (&platform_id, &path))); + if let Some(answer) = self.next("platform_set_path") { + return answer; + } + self.inner.platform_set_path(platform_id, path) + } + fn platform_select_path(&self, platform_id: &str) -> BackendResult { + self.record("platform_select_path", format!("{:?}", (&platform_id,))); + if let Some(answer) = self.next("platform_select_path") { + return answer; + } + self.inner.platform_select_path(platform_id) + } + fn platform_detect_installed(&self) -> BackendResult> { + self.record("platform_detect_installed", String::new()); + if let Some(answer) = self.next("platform_detect_installed") { + return answer; + } + self.inner.platform_detect_installed() + } + fn platform_set_account_label( + &self, + platform_id: &str, + account_id: &str, + label: &str, + ) -> BackendResult<()> { + self.record( + "platform_set_account_label", + format!("{:?}", (&platform_id, &account_id, &label)), + ); + if let Some(answer) = self.next("platform_set_account_label") { + return answer; + } + self.inner + .platform_set_account_label(platform_id, account_id, label) + } + fn platform_dry_run(&self, platform_id: &str, account_id: &str) -> BackendResult { + self.record( + "platform_dry_run", + format!("{:?}", (&platform_id, &account_id)), + ); + if let Some(answer) = self.next("platform_dry_run") { + return answer; + } + self.inner.platform_dry_run(platform_id, account_id) + } + fn reload_user_platforms(&self) -> BackendResult { + self.record("reload_user_platforms", String::new()); + if let Some(answer) = self.next("reload_user_platforms") { + return answer; + } + self.inner.reload_user_platforms() + } + fn descriptor_select_file(&self) -> BackendResult { + self.record("descriptor_select_file", String::new()); + if let Some(answer) = self.next("descriptor_select_file") { + return answer; + } + self.inner.descriptor_select_file() + } + fn descriptor_preview_file(&self, path: &str) -> BackendResult { + self.record("descriptor_preview_file", format!("{:?}", (&path,))); + if let Some(answer) = self.next("descriptor_preview_file") { + return answer; + } + self.inner.descriptor_preview_file(path) + } + fn descriptor_install_file(&self, path: &str) -> BackendResult { + self.record("descriptor_install_file", format!("{:?}", (&path,))); + if let Some(answer) = self.next("descriptor_install_file") { + return answer; + } + self.inner.descriptor_install_file(path) + } + fn descriptor_remove(&self, platform_id: &str) -> BackendResult { + self.record("descriptor_remove", format!("{:?}", (&platform_id,))); + if let Some(answer) = self.next("descriptor_remove") { + return answer; + } + self.inner.descriptor_remove(platform_id) + } + fn open_descriptors_folder(&self) -> BackendResult<()> { + self.record("open_descriptors_folder", String::new()); + if let Some(answer) = self.next("open_descriptors_folder") { + return answer; + } + self.inner.open_descriptors_folder() + } + fn open_url(&self, url: &str) -> BackendResult<()> { + self.record("open_url", format!("{:?}", (&url,))); + if let Some(answer) = self.next("open_url") { + return answer; + } + self.inner.open_url(url) + } + fn open_logs_folder(&self) -> BackendResult<()> { + self.record("open_logs_folder", String::new()); + if let Some(answer) = self.next("open_logs_folder") { + return answer; + } + self.inner.open_logs_folder() + } + fn get_desktop_wallpaper(&self) -> BackendResult> { + self.record("get_desktop_wallpaper", String::new()); + if let Some(answer) = self.next("get_desktop_wallpaper") { + return answer; + } + self.inner.get_desktop_wallpaper() + } + fn steam_set_api_key(&self, key: &str) -> BackendResult<()> { + self.record("steam_set_api_key", format!("{:?}", (&key,))); + if let Some(answer) = self.next("steam_set_api_key") { + return answer; + } + self.inner.steam_set_api_key(key) + } + fn steam_has_api_key(&self) -> BackendResult { + self.record("steam_has_api_key", String::new()); + if let Some(answer) = self.next("steam_has_api_key") { + return answer; + } + self.inner.steam_has_api_key() + } + fn steam_open_api_key_page(&self) -> BackendResult<()> { + self.record("steam_open_api_key_page", String::new()); + if let Some(answer) = self.next("steam_open_api_key_page") { + return answer; + } + self.inner.steam_open_api_key_page() + } + fn steam_switch_account_and_launch_game( + &self, + username: &str, + app_id: &str, + run_as_admin: bool, + launch_options: &str, + shutdown_mode: &str, + ) -> BackendResult<()> { + self.record( + "steam_switch_account_and_launch_game", + format!( + "{:?}", + ( + &username, + &app_id, + &run_as_admin, + &launch_options, + &shutdown_mode + ) + ), + ); + if let Some(answer) = self.next("steam_switch_account_and_launch_game") { + return answer; + } + self.inner.steam_switch_account_and_launch_game( + username, + app_id, + run_as_admin, + launch_options, + shutdown_mode, + ) + } + fn steam_get_profile_info(&self, steam_id: &str) -> BackendResult> { + self.record("steam_get_profile_info", format!("{:?}", (&steam_id,))); + if let Some(answer) = self.next("steam_get_profile_info") { + return answer; + } + self.inner.steam_get_profile_info(steam_id) + } + fn steam_get_profile_infos( + &self, + steam_ids: &[String], + ) -> BackendResult> { + self.record("steam_get_profile_infos", format!("{:?}", (&steam_ids,))); + if let Some(answer) = self.next("steam_get_profile_infos") { + return answer; + } + self.inner.steam_get_profile_infos(steam_ids) + } + fn steam_get_player_bans(&self, steam_ids: &[String]) -> BackendResult> { + self.record("steam_get_player_bans", format!("{:?}", (&steam_ids,))); + if let Some(answer) = self.next("steam_get_player_bans") { + return answer; + } + self.inner.steam_get_player_bans(steam_ids) + } + fn steam_copy_game_settings( + &self, + from_steam_id: &str, + to_steam_id: &str, + app_id: &str, + ) -> BackendResult<()> { + self.record( + "steam_copy_game_settings", + format!("{:?}", (&from_steam_id, &to_steam_id, &app_id)), + ); + if let Some(answer) = self.next("steam_copy_game_settings") { + return answer; + } + self.inner + .steam_copy_game_settings(from_steam_id, to_steam_id, app_id) + } + fn steam_get_copyable_games( + &self, + from_steam_id: &str, + to_steam_id: &str, + ) -> BackendResult> { + self.record( + "steam_get_copyable_games", + format!("{:?}", (&from_steam_id, &to_steam_id)), + ); + if let Some(answer) = self.next("steam_get_copyable_games") { + return answer; + } + self.inner + .steam_get_copyable_games(from_steam_id, to_steam_id) + } + fn steam_open_userdata(&self, steam_id: &str) -> BackendResult<()> { + self.record("steam_open_userdata", format!("{:?}", (&steam_id,))); + if let Some(answer) = self.next("steam_open_userdata") { + return answer; + } + self.inner.steam_open_userdata(steam_id) + } + fn steam_clear_browser_cache(&self) -> BackendResult<()> { + self.record("steam_clear_browser_cache", String::new()); + if let Some(answer) = self.next("steam_clear_browser_cache") { + return answer; + } + self.inner.steam_clear_browser_cache() + } + fn steam_bulk_edit(&self, request: BulkEditRequest) -> BackendResult { + self.record("steam_bulk_edit", format!("{:?}", (&request,))); + if let Some(answer) = self.next("steam_bulk_edit") { + return answer; + } + self.inner.steam_bulk_edit(request) + } + fn steam_get_account_games(&self, steam_id: &str) -> BackendResult> { + self.record("steam_get_account_games", format!("{:?}", (&steam_id,))); + if let Some(answer) = self.next("steam_get_account_games") { + return answer; + } + self.inner.steam_get_account_games(steam_id) + } + fn cs2_bridge_get_settings(&self) -> BackendResult { + self.record("cs2_bridge_get_settings", String::new()); + if let Some(answer) = self.next("cs2_bridge_get_settings") { + return answer; + } + self.inner.cs2_bridge_get_settings() + } + fn cs2_bridge_set_settings( + &self, + enabled: bool, + url: &str, + token: Option<&str>, + ) -> BackendResult<()> { + self.record( + "cs2_bridge_set_settings", + format!("{:?}", (&enabled, &url, &token)), + ); + if let Some(answer) = self.next("cs2_bridge_set_settings") { + return answer; + } + self.inner.cs2_bridge_set_settings(enabled, url, token) + } + fn cs2_bridge_fetch(&self) -> BackendResult> { + self.record("cs2_bridge_fetch", String::new()); + if let Some(answer) = self.next("cs2_bridge_fetch") { + return answer; + } + self.inner.cs2_bridge_fetch() + } + fn cs2_bridge_check(&self, steam_id: &str) -> BackendResult> { + self.record("cs2_bridge_check", format!("{:?}", (&steam_id,))); + if let Some(answer) = self.next("cs2_bridge_check") { + return answer; + } + self.inner.cs2_bridge_check(steam_id) + } + fn cs2_bridge_test(&self) -> BackendResult { + self.record("cs2_bridge_test", String::new()); + if let Some(answer) = self.next("cs2_bridge_test") { + return answer; + } + self.inner.cs2_bridge_test() + } + fn riot_capture_profile(&self, profile_id: &str) -> BackendResult<()> { + self.record("riot_capture_profile", format!("{:?}", (&profile_id,))); + if let Some(answer) = self.next("riot_capture_profile") { + return answer; + } + self.inner.riot_capture_profile(profile_id) + } + fn roblox_add_account_by_cookie(&self, cookie: &str) -> BackendResult { + self.record("roblox_add_account_by_cookie", format!("{:?}", (&cookie,))); + if let Some(answer) = self.next("roblox_add_account_by_cookie") { + return answer; + } + self.inner.roblox_add_account_by_cookie(cookie) + } + fn roblox_get_profile_info(&self, user_id: &str) -> BackendResult { + self.record("roblox_get_profile_info", format!("{:?}", (&user_id,))); + if let Some(answer) = self.next("roblox_get_profile_info") { + return answer; + } + self.inner.roblox_get_profile_info(user_id) + } + fn roblox_check_sessions(&self) -> BackendResult> { + self.record("roblox_check_sessions", String::new()); + if let Some(answer) = self.next("roblox_check_sessions") { + return answer; + } + self.inner.roblox_check_sessions() + } + fn list_custom_themes(&self) -> BackendResult> { + self.record("list_custom_themes", String::new()); + if let Some(answer) = self.next("list_custom_themes") { + return answer; + } + self.inner.list_custom_themes() + } + fn save_custom_theme(&self, theme: &CustomTheme) -> BackendResult<()> { + self.record("save_custom_theme", format!("{:?}", (&theme,))); + if let Some(answer) = self.next("save_custom_theme") { + return answer; + } + self.inner.save_custom_theme(theme) + } + fn delete_custom_theme(&self, theme_id: &str) -> BackendResult<()> { + self.record("delete_custom_theme", format!("{:?}", (&theme_id,))); + if let Some(answer) = self.next("delete_custom_theme") { + return answer; + } + self.inner.delete_custom_theme(theme_id) + } + fn telemetry_get_state(&self) -> BackendResult { + self.record("telemetry_get_state", String::new()); + if let Some(answer) = self.next("telemetry_get_state") { + return answer; + } + self.inner.telemetry_get_state() + } + fn telemetry_set_mode_a(&self, enabled: bool) -> BackendResult<()> { + self.record("telemetry_set_mode_a", format!("{:?}", (&enabled,))); + if let Some(answer) = self.next("telemetry_set_mode_a") { + return answer; + } + self.inner.telemetry_set_mode_a(enabled) + } + fn telemetry_set_mode_b(&self, enabled: bool) -> BackendResult<()> { + self.record("telemetry_set_mode_b", format!("{:?}", (&enabled,))); + if let Some(answer) = self.next("telemetry_set_mode_b") { + return answer; + } + self.inner.telemetry_set_mode_b(enabled) + } + fn telemetry_retry_forget(&self) -> BackendResult<()> { + self.record("telemetry_retry_forget", String::new()); + if let Some(answer) = self.next("telemetry_retry_forget") { + return answer; + } + self.inner.telemetry_retry_forget() + } + fn telemetry_complete_onboarding( + &self, + mode_a_enabled: bool, + mode_b_enabled: bool, + ) -> BackendResult<()> { + self.record( + "telemetry_complete_onboarding", + format!("{:?}", (&mode_a_enabled, &mode_b_enabled)), + ); + if let Some(answer) = self.next("telemetry_complete_onboarding") { + return answer; + } + self.inner + .telemetry_complete_onboarding(mode_a_enabled, mode_b_enabled) + } + fn telemetry_track_persona_switch(&self, platforms: u64, succeeded: u64) -> BackendResult<()> { + self.record( + "telemetry_track_persona_switch", + format!("{:?}", (&platforms, &succeeded)), + ); + if let Some(answer) = self.next("telemetry_track_persona_switch") { + return answer; + } + self.inner + .telemetry_track_persona_switch(platforms, succeeded) + } + fn telemetry_track_account_added(&self, platform_id: &str) -> BackendResult<()> { + self.record( + "telemetry_track_account_added", + format!("{:?}", (&platform_id,)), + ); + if let Some(answer) = self.next("telemetry_track_account_added") { + return answer; + } + self.inner.telemetry_track_account_added(platform_id) + } + fn telemetry_track_account_add_started(&self, platform_id: &str) -> BackendResult<()> { + self.record( + "telemetry_track_account_add_started", + format!("{:?}", (&platform_id,)), + ); + if let Some(answer) = self.next("telemetry_track_account_add_started") { + return answer; + } + self.inner.telemetry_track_account_add_started(platform_id) + } + fn telemetry_track_account_add_cancelled(&self, platform_id: &str) -> BackendResult<()> { + self.record( + "telemetry_track_account_add_cancelled", + format!("{:?}", (&platform_id,)), + ); + if let Some(answer) = self.next("telemetry_track_account_add_cancelled") { + return answer; + } + self.inner + .telemetry_track_account_add_cancelled(platform_id) + } + fn telemetry_track_operation_failed( + &self, + operation: &str, + error_code: &str, + platform_id: Option<&str>, + ) -> BackendResult<()> { + self.record( + "telemetry_track_operation_failed", + format!("{:?}", (&operation, &error_code, &platform_id)), + ); + if let Some(answer) = self.next("telemetry_track_operation_failed") { + return answer; + } + self.inner + .telemetry_track_operation_failed(operation, error_code, platform_id) + } + fn telemetry_track_update( + &self, + stage: &str, + target_version: Option<&str>, + error_code: Option<&str>, + ) -> BackendResult<()> { + self.record( + "telemetry_track_update", + format!("{:?}", (&stage, &target_version, &error_code)), + ); + if let Some(answer) = self.next("telemetry_track_update") { + return answer; + } + self.inner + .telemetry_track_update(stage, target_version, error_code) + } + fn telemetry_track_settings_snapshot(&self, snapshot: &SettingsSnapshot) -> BackendResult<()> { + self.record( + "telemetry_track_settings_snapshot", + format!("{:?}", (&snapshot,)), + ); + if let Some(answer) = self.next("telemetry_track_settings_snapshot") { + return answer; + } + self.inner.telemetry_track_settings_snapshot(snapshot) + } + fn telemetry_track_streamer_mode(&self) -> BackendResult<()> { + self.record("telemetry_track_streamer_mode", String::new()); + if let Some(answer) = self.next("telemetry_track_streamer_mode") { + return answer; + } + self.inner.telemetry_track_streamer_mode() + } + fn telemetry_export(&self) -> BackendResult { + self.record("telemetry_export", String::new()); + if let Some(answer) = self.next("telemetry_export") { + return answer; + } + self.inner.telemetry_export() + } +} diff --git a/crates/accshift-app/tests/visible_priming.rs b/crates/accshift-app/tests/visible_priming.rs new file mode 100644 index 00000000..2c9dc720 --- /dev/null +++ b/crates/accshift-app/tests/visible_priming.rs @@ -0,0 +1,78 @@ +//! `src/lib/app/useVisiblePriming.test.ts`, ported. The fake timers become +//! instants passed to `poll`. + +use accshift_app::accounts::priming::{VisiblePriming, VISIBLE_PRIME_TTL}; +use std::time::{Duration, Instant}; + +fn ids(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() +} + +/// Feeds `visible`, lets 200 ms pass and returns what got primed. +fn step( + priming: &mut VisiblePriming, + now: &mut Instant, + visible: &[&str], + primed: &mut Vec>, +) { + priming.process_visible(&ids(visible), "steam", false, *now); + *now += Duration::from_millis(200); + if let Some(request) = priming.poll(*now) { + if !request.due_ids.is_empty() { + primed.push(request.due_ids); + } + } +} + +#[test] +fn does_not_prime_the_same_accounts_again_after_a_pause_within_the_ttl() { + let mut priming = VisiblePriming::new(); + let mut now = Instant::now(); + let mut primed = Vec::new(); + + step(&mut priming, &mut now, &["a", "b"], &mut primed); + assert_eq!(primed, [ids(&["a", "b"])]); + + // Alt-tab away and back. + priming.pause(); + step(&mut priming, &mut now, &["a", "b"], &mut primed); + assert_eq!(primed.len(), 1); + + step(&mut priming, &mut now, &["a", "b", "c"], &mut primed); + assert_eq!(primed[1], ids(&["c"])); +} + +#[test] +fn primes_again_once_the_ttl_has_passed_and_after_a_reset() { + let mut priming = VisiblePriming::new(); + let mut now = Instant::now(); + let mut primed = Vec::new(); + + step(&mut priming, &mut now, &["a"], &mut primed); + + priming.pause(); + now += VISIBLE_PRIME_TTL + Duration::from_millis(1); + step(&mut priming, &mut now, &["a"], &mut primed); + assert_eq!(primed, [ids(&["a"]), ids(&["a"])]); + + priming.reset(); + step(&mut priming, &mut now, &["a"], &mut primed); + assert_eq!(primed.len(), 3); +} + +#[test] +fn waits_for_the_debounce_before_firing() { + let mut priming = VisiblePriming::new(); + let start = Instant::now(); + priming.process_visible(&ids(&["a"]), "steam", false, start); + assert!(priming.poll(start + Duration::from_millis(119)).is_none()); + assert_eq!( + priming.next_deadline(), + Some(start + Duration::from_millis(120)) + ); + let request = priming + .poll(start + Duration::from_millis(120)) + .expect("fires"); + assert_eq!(request.visible_ids, ids(&["a"])); + assert!(priming.next_deadline().is_none()); +} diff --git a/crates/accshift-cli/Cargo.toml b/crates/accshift-cli/Cargo.toml index c1d184c1..4d24e2b9 100644 --- a/crates/accshift-cli/Cargo.toml +++ b/crates/accshift-cli/Cargo.toml @@ -17,3 +17,7 @@ is-terminal = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } unicode-width = { workspace = true } + +[build-dependencies] +serde_json = "1" +tauri-winres = "0.3" diff --git a/crates/accshift-cli/build.rs b/crates/accshift-cli/build.rs new file mode 100644 index 00000000..5c38aee3 --- /dev/null +++ b/crates/accshift-cli/build.rs @@ -0,0 +1,41 @@ +// Windows: the version resource the GUI also carries, with the same product +// name and version. Code signing restricts signed files to these values. +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + let windows = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows"); + let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); + if !(windows && msvc) { + return; + } + // The installer's config names the product; the workspace version is + // checked against it by a test. + let conf_path = std::path::Path::new("..") + .join("accshift-egui") + .join("tauri.conf.json"); + println!("cargo:rerun-if-changed={}", conf_path.display()); + let conf: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&conf_path).expect("tauri.conf.json")) + .expect("tauri.conf.json"); + let product = conf["productName"].as_str().expect("productName"); + let identifier = conf["identifier"].as_str().expect("identifier"); + let version = std::env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION"); + + let parts: Vec = version + .split('.') + .map(|p| p.parse().expect("numeric version")) + .collect(); + let packed = (parts[0] << 48) | (parts[1] << 32) | (parts[2] << 16); + let mut res = tauri_winres::WindowsResource::new(); + res.set_version_info(tauri_winres::VersionInfo::FILEVERSION, packed); + res.set_version_info(tauri_winres::VersionInfo::PRODUCTVERSION, packed); + res.set("FileVersion", &version); + res.set("ProductVersion", &version); + res.set("ProductName", product); + res.set( + "CompanyName", + identifier.split('.').nth(1).unwrap_or(identifier), + ); + res.set("FileDescription", &format!("{product} CLI")); + res.set("OriginalFilename", "accshift.exe"); + res.compile().expect("Windows resources"); +} diff --git a/crates/accshift-cli/src/context.rs b/crates/accshift-cli/src/context.rs index 941fdcff..43e05832 100644 --- a/crates/accshift-cli/src/context.rs +++ b/crates/accshift-cli/src/context.rs @@ -8,7 +8,7 @@ use accshift_core::AppContext; use directories::BaseDirs; use std::path::PathBuf; -/// Must match `identifier` in `src-tauri/tauri.conf.json`. +/// Must match `identifier` in `crates/accshift-egui/tauri.conf.json`. const BUNDLE_IDENTIFIER: &str = "com.accshift.desktop"; pub struct CliAppContext { diff --git a/crates/accshift-core/Cargo.toml b/crates/accshift-core/Cargo.toml index 18840fbe..acd3396c 100644 --- a/crates/accshift-core/Cargo.toml +++ b/crates/accshift-core/Cargo.toml @@ -9,6 +9,7 @@ authors.workspace = true serde = { workspace = true } serde_json = { workspace = true } reqwest = { workspace = true } +rustls = { workspace = true } thiserror = { workspace = true } rusqlite = { workspace = true } uuid = { workspace = true } diff --git a/crates/accshift-core/src/diagnostics/levels.rs b/crates/accshift-core/src/diagnostics/levels.rs index 6a95b33b..840d3d86 100644 --- a/crates/accshift-core/src/diagnostics/levels.rs +++ b/crates/accshift-core/src/diagnostics/levels.rs @@ -390,12 +390,14 @@ mod tests { #[test] fn temporary_window_reverts_on_its_own() { - let mut config = LevelConfig::default(); - config.temporary = Some(TemporaryDebug { - level: Level::Trace, - modules: vec![], - until_ms: 1_000, - }); + let config = LevelConfig { + temporary: Some(TemporaryDebug { + level: Level::Trace, + modules: vec![], + until_ms: 1_000, + }), + ..LevelConfig::default() + }; assert_eq!(config.effective_for("platform", 999), Level::Trace); // One millisecond past the deadline the normal level is back, with no @@ -406,12 +408,14 @@ mod tests { #[test] fn temporary_window_can_target_one_module() { - let mut config = LevelConfig::default(); - config.temporary = Some(TemporaryDebug { - level: Level::Debug, - modules: vec!["platform.riot".into()], - until_ms: 1_000, - }); + let config = LevelConfig { + temporary: Some(TemporaryDebug { + level: Level::Debug, + modules: vec!["platform.riot".into()], + until_ms: 1_000, + }), + ..LevelConfig::default() + }; assert_eq!(config.effective_for("platform.riot", 0), Level::Debug); assert_eq!(config.effective_for("platform.steam", 0), Level::Info); diff --git a/crates/accshift-core/src/http.rs b/crates/accshift-core/src/http.rs new file mode 100644 index 00000000..08878a86 --- /dev/null +++ b/crates/accshift-core/src/http.rs @@ -0,0 +1,36 @@ +//! Where every HTTP client starts. reqwest 0.13 is built with +//! `rustls-no-provider`, so ring is installed as the process-wide rustls +//! provider before the first client, the provider reqwest 0.12's +//! `rustls-tls` built in, rather than reqwest 0.13's default aws-lc-rs. + +use std::sync::Once; + +fn install_crypto_provider() { + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| { + // Err only when another provider is already the default: kept. + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +/// `reqwest::Client::builder()` once the crypto provider is installed. +pub fn client_builder() -> reqwest::ClientBuilder { + install_crypto_provider(); + reqwest::Client::builder() +} + +/// `reqwest::blocking::Client::builder()` once the crypto provider is +/// installed. +pub fn blocking_client_builder() -> reqwest::blocking::ClientBuilder { + install_crypto_provider(); + reqwest::blocking::Client::builder() +} + +#[cfg(test)] +mod tests { + #[test] + fn both_builders_build_a_client() { + super::client_builder().build().unwrap(); + super::blocking_client_builder().build().unwrap(); + } +} diff --git a/crates/accshift-core/src/lib.rs b/crates/accshift-core/src/lib.rs index 9678172b..c5635d77 100644 --- a/crates/accshift-core/src/lib.rs +++ b/crates/accshift-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod context; pub mod diagnostics; pub mod error; pub mod fs_utils; +pub mod http; pub mod lock; pub mod logging; pub mod os; diff --git a/crates/accshift-core/src/platforms/descriptor/engine/tests.rs b/crates/accshift-core/src/platforms/descriptor/engine/tests.rs index c2f67e6a..44ad8554 100644 --- a/crates/accshift-core/src/platforms/descriptor/engine/tests.rs +++ b/crates/accshift-core/src/platforms/descriptor/engine/tests.rs @@ -1,3 +1,4 @@ +#[cfg(windows)] use super::super::schema::RegistryHive; use super::*; use std::sync::Arc; diff --git a/crates/accshift-core/src/platforms/descriptor/library.rs b/crates/accshift-core/src/platforms/descriptor/library.rs index 8b7ee14f..dd172ea4 100644 --- a/crates/accshift-core/src/platforms/descriptor/library.rs +++ b/crates/accshift-core/src/platforms/descriptor/library.rs @@ -14,11 +14,11 @@ use super::plan::DryRunPlan; use super::schema::{Descriptor, DescriptorError, Os}; use super::user_dir; use crate::context::AppContext; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; /// A descriptor file judged without installing it. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DescriptorPreview { /// The file the user picked, as they picked it. diff --git a/crates/accshift-core/src/platforms/mod.rs b/crates/accshift-core/src/platforms/mod.rs index e23de4a6..22ba5adc 100644 --- a/crates/accshift-core/src/platforms/mod.rs +++ b/crates/accshift-core/src/platforms/mod.rs @@ -1,7 +1,7 @@ use crate::context::{AppContext, AppCtx}; use crate::error::PlatformError; use descriptor::{Descriptor, DescriptorOrigin, DescriptorService}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::path::Path; @@ -156,7 +156,7 @@ pub(crate) fn make_setup_status( } /// Common setup status returned by all platforms. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetupStatus { pub setup_id: String, @@ -298,7 +298,7 @@ fn user_registry() -> &'static RwLock = OnceLock::new() pub(super) fn riot_local_client() -> &'static reqwest::Client { RIOT_LOCAL_CLIENT.get_or_init(|| { - reqwest::Client::builder() + crate::http::client_builder() .danger_accept_invalid_certs(true) .connect_timeout(Duration::from_secs(2)) .timeout(Duration::from_secs(5)) diff --git a/crates/accshift-core/src/platforms/roblox/api.rs b/crates/accshift-core/src/platforms/roblox/api.rs index 1e1cc28e..90953e16 100644 --- a/crates/accshift-core/src/platforms/roblox/api.rs +++ b/crates/accshift-core/src/platforms/roblox/api.rs @@ -37,7 +37,7 @@ pub(super) struct ThumbnailResponse { pub(super) fn blocking_client() -> Result<&'static reqwest::blocking::Client, String> { static CLIENT: OnceLock> = OnceLock::new(); let result = CLIENT.get_or_init(|| { - reqwest::blocking::Client::builder() + crate::http::blocking_client_builder() .timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(5)) .build() diff --git a/crates/accshift-core/src/platforms/steam/bans.rs b/crates/accshift-core/src/platforms/steam/bans.rs index a7b44125..3fd96ad5 100644 --- a/crates/accshift-core/src/platforms/steam/bans.rs +++ b/crates/accshift-core/src/platforms/steam/bans.rs @@ -6,7 +6,7 @@ const STEAM_BAN_IDS_PER_REQUEST: usize = 100; /// final, inutile (et risque d'OOM sur flux malveillant) de tout aspirer. const MAX_ERROR_PREVIEW_BODY: usize = 16 * 1024; -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct BanInfo { pub steam_id: String, pub community_banned: bool, diff --git a/crates/accshift-core/src/platforms/steam/bulk_edit.rs b/crates/accshift-core/src/platforms/steam/bulk_edit.rs index 98cd0a42..6edcb048 100644 --- a/crates/accshift-core/src/platforms/steam/bulk_edit.rs +++ b/crates/accshift-core/src/platforms/steam/bulk_edit.rs @@ -24,14 +24,14 @@ pub struct BulkEditRequest { pub launch_options: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BulkEditFailure { pub steam_id: String, pub error: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BulkEditResult { pub succeeded: u32, diff --git a/crates/accshift-core/src/platforms/steam/cs2_bridge.rs b/crates/accshift-core/src/platforms/steam/cs2_bridge.rs index f4ff9a44..b8e755b4 100644 --- a/crates/accshift-core/src/platforms/steam/cs2_bridge.rs +++ b/crates/accshift-core/src/platforms/steam/cs2_bridge.rs @@ -24,7 +24,7 @@ const MAX_IDS_PER_REQUEST: usize = 300; /// Cette limite par requete remplace le timeout global de 10 s du client. const CHECK_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct Cs2BridgeSettings { pub enabled: bool, @@ -52,7 +52,7 @@ pub struct Cs2BridgeAccount { /// Resultat du bouton "tester" des settings : jamais d'erreur Tauri, l'echec /// est une donnee (dot rouge + message), pas une exception. -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct Cs2BridgeTestResult { pub ok: bool, @@ -627,7 +627,7 @@ mod tests { } fn client() -> reqwest::Client { - reqwest::Client::builder() + crate::http::client_builder() .timeout(Duration::from_secs(3)) .build() .unwrap() diff --git a/crates/accshift-core/src/storage.rs b/crates/accshift-core/src/storage.rs index bc3a8dc4..53843513 100644 --- a/crates/accshift-core/src/storage.rs +++ b/crates/accshift-core/src/storage.rs @@ -1,6 +1,6 @@ use crate::context::AppContext; use crate::fs_utils; -use serde::{de::DeserializeOwned, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; @@ -52,14 +52,14 @@ pub const TARGET_DISCORD_SNAPSHOTS: &str = "platform.discord.snapshots"; const DEV_SCOPE_DIR: &str = "dev"; -#[derive(Debug, Serialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] #[serde(rename_all = "camelCase")] pub struct StorageManifest { pub schema_version: u32, pub stores: BTreeMap, } -#[derive(Debug, Serialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] #[serde(rename_all = "camelCase")] pub struct ClientStorageSnapshot { pub manifest: StorageManifest, diff --git a/crates/accshift-core/src/telemetry/queue.rs b/crates/accshift-core/src/telemetry/queue.rs index de648196..69657548 100644 --- a/crates/accshift-core/src/telemetry/queue.rs +++ b/crates/accshift-core/src/telemetry/queue.rs @@ -234,7 +234,7 @@ fn run( dropped: Arc, sending: Arc>, ) { - let http = match reqwest::blocking::Client::builder() + let http = match crate::http::blocking_client_builder() .user_agent(client::user_agent(&ctx.app_version)) .timeout(Duration::from_secs(10)) // Without an explicit connect timeout, a TCP connect behind a @@ -536,7 +536,7 @@ mod tests { // Snapshot events are documented as Mode B only (events.rs). Under // Mode A they must be dropped rather than sent, even though Mode A // itself is enabled and would otherwise flush fine. - let http = reqwest::blocking::Client::new(); + let http = crate::http::blocking_client_builder().build().unwrap(); let ctx = test_ctx(); let consent = Arc::new(Mutex::new(ConsentState { mode_a: true, @@ -579,7 +579,7 @@ mod tests { #[test] fn a_mode_b_flush_drops_events_recorded_before_that_consent() { - let http = reqwest::blocking::Client::new(); + let http = crate::http::blocking_client_builder().build().unwrap(); let consent = Arc::new(Mutex::new(ConsentState { mode_a: true, mode_b: true, @@ -607,7 +607,7 @@ mod tests { #[test] fn flush_without_consent_drops_and_never_pings() { - let http = reqwest::blocking::Client::new(); + let http = crate::http::blocking_client_builder().build().unwrap(); let consent = Arc::new(Mutex::new(ConsentState::default())); let dropped = Arc::new(AtomicU64::new(0)); let mut state = WorkerState { @@ -635,7 +635,7 @@ mod tests { #[test] fn a_failed_flush_keeps_the_batch_for_the_next_attempt() { - let http = reqwest::blocking::Client::builder() + let http = crate::http::blocking_client_builder() .timeout(Duration::from_millis(200)) .connect_timeout(Duration::from_millis(200)) .build() diff --git a/crates/accshift-egui/Cargo.toml b/crates/accshift-egui/Cargo.toml new file mode 100644 index 00000000..1fb7fc81 --- /dev/null +++ b/crates/accshift-egui/Cargo.toml @@ -0,0 +1,128 @@ +[package] +name = "accshift-egui" +description = "The accshift desktop app, drawn with egui" +version.workspace = true +edition.workspace = true +publish = false + +[features] +# Bench build: the window is never shown, boot marks go to +# %TEMP%\accshift-egui-bench\.jsonl. The harness refuses any exe that +# lacks the marker string, so a bench run can never pop a window. +startup-bench = [] +# Renderer, pick one. `software`: egui meshes rasterized on the CPU and blitted +# with softbuffer (GDI on Windows), no GPU driver loaded. `glow`: eframe on +# OpenGL, the first version of the spike. +software = ["dep:egui-winit", "dep:softbuffer", "dep:egui_software_backend", "dep:winit"] +glow = ["dep:eframe"] +default = ["software"] + +[dependencies] +accshift-core = { path = "../accshift-core" } +# The UI-free app logic (backend trait, mock, real backend, stores, i18n) and +# the theme engine the Svelte frontend applies. +accshift-app = { path = "../accshift-app" } +accshift-theme = { path = "../accshift-theme" } +# Same backend choice as spotifast for the `glow` path: glow keeps the binary +# and the tree small, wgpu buys nothing for a 2D grid. +eframe = { version = "0.37", optional = true, default-features = false, features = [ + "accesskit", + "glow", + "default_fonts", +] } +# The software path: the window and input layer eframe uses (egui-winit +# re-exports winit and accesskit_winit), a CPU rasterizer, a GDI blit. +egui-winit = { version = "0.37", optional = true, default-features = false, features = [ + "accesskit", + "clipboard", + "links", +] } +softbuffer = { version = "0.4", optional = true, default-features = false } +egui_software_backend = { path = "vendor/egui_software_backend", optional = true, features = [ + "rayon", +] } +# No bundled fonts on Windows: the app draws with Segoe UI read from the +# system, and the 1.2 MB of egui's defaults cost startup time (a bigger exe +# reaches `main` later, see build.rs). Other OSes keep them as the only fonts. +egui = { version = "0.37", default-features = false } +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +# The same resvg egui_extras rasterizes with, for its options type. +resvg = { version = "0.45", default-features = false } +raw-window-handle = "0.6" +# Face metrics (ascent, descent, line gap, weights) for the CSS line box: the +# same skrifa epaint draws glyphs with. +skrifa = "0.44" +# System font files mapped instead of read: file-backed pages the OS shares +# with its file cache, loaded as glyphs need them, outside private memory (a +# CJK face is 12 to 35 MB). +memmap2 = "0.9" +# Scene runner only (`--scene`): scene files in, PNG shots out. Neither is +# touched on the normal startup path. +serde_json = "1" +png = "0.18" +# Steam's CDN serves avatars as JPEG; the same image build accshift-app +# already links (zune-jpeg underneath). A persona's custom image is any of +# these, re-encoded as a JPEG cover; png and image-webp are linked already. +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "bmp"] } +# The persona image picker (``) and its data URL: the +# versions accshift-app already builds. +rfd = "0.17" +base64 = "0.23" +# The onboarding's two looping clips, shipped as animated WebP. +image-webp = "0.2" + +[target.'cfg(not(windows))'.dependencies] +egui = { version = "0.37", features = ["default_fonts"] } + +# Linux: the X11 and Wayland backends `default-features = false` drops above, +# with libxcb and the Wayland client opened at run time, as by default. +[target.'cfg(target_os = "linux")'.dependencies] +egui-winit = { version = "0.37", optional = true, default-features = false, features = [ + "x11", + "wayland", +] } +winit = { version = "0.30", optional = true, default-features = false, features = [ + "wayland-dlopen", +] } +softbuffer = { version = "0.4", optional = true, default-features = false, features = [ + "x11", + "x11-dlopen", + "wayland", + "wayland-dlopen", +] } + +[target.'cfg(windows)'.dependencies] +# The OS acrylic behind a transparent window: what Glass Dark uses in Tauri. +window-vibrancy = "0.8" +# DWM cloak for the bench build: the window exists and renders, but is never +# composited on screen. UI Automation: warmed up off the UI thread at start. +# The main window's frame (window/frame.rs): tao's insets, hit test and +# minimum size, the monitors and their DPI, the auto-hidden taskbar. +windows-sys = { version = "0.61", features = [ + "Wdk_Graphics_Direct3D", + "Win32_Foundation", + "Win32_Graphics_Dwm", + "Win32_Graphics_Gdi", + "Win32_System_LibraryLoader", + "Win32_System_Power", + "Win32_System_Registry", + "Win32_UI_Accessibility", + "Win32_UI_Controls_Dialogs", + "Win32_UI_HiDpi", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } +# DirectWrite glyph coverage (ui::text::dwrite): COM interfaces, which +# windows-sys does not bind. Same version accesskit_windows and sysinfo +# already pull in, so no second binding version. +windows = { version = "0.62", default-features = false, features = [ + "Win32_Foundation", + "Win32_Graphics_DirectWrite", +] } + +# build.rs: the Windows resources tauri-build gives `accshift-gui.exe`, built +# by the same crate from the same tauri.conf.json. +[build-dependencies] +serde_json = "1" +tauri-winres = "0.3" diff --git a/crates/accshift-egui/README.md b/crates/accshift-egui/README.md new file mode 100644 index 00000000..11a65a3e --- /dev/null +++ b/crates/accshift-egui/README.md @@ -0,0 +1,43 @@ +# accshift-egui + +The accshift desktop app, drawn with [egui](https://github.com/emilk/egui) 0.36. It builds `accshift-egui`, which the installers ship as `accshift-gui` (`bundle/bundle.mjs`), so an install of a v1.0.x Tauri build updates in place: same identifier, folders, data, deep links and updater. + +## Renderer + +Feature `software` (default): egui meshes rasterized on the CPU (`vendor/egui_software_backend`) and blitted with softbuffer, GDI on Windows. No GPU driver is loaded, and only the damaged tiles are redrawn. `vendor/epaint` adds one hook to epaint: a font can supply its own glyph coverage, so text is rasterized by DirectWrite on Windows. Feature `glow` is the older eframe on OpenGL path. + +## Run + +```sh +cargo run -p accshift-egui # the app, on the real data +cargo run -p accshift-egui -- --scene parity/scenes/.json --out # scripted shots, hidden window, mock data +node bundle/bundle.mjs --build [--no-updater] # installers for the host OS +``` + +`parity/README.md` describes the scene format. The scenes run on the mock data of `parity/data` and write one PNG per shot; `parity/diff.mjs` compares two sets of shots. + +## Startup and memory + +Interleaved A/B on Windows 11 against the v1.0.5 Tauri build, hidden windows, real backend on a copy of the same data (`.agents/perf/bench-egui.ps1` in the maintainer checkout). Boot runs from process creation to the first presented frame (Tauri: its `Boot completed` line). + +| | Tauri 1.0.5 + WebView2 | egui | +| ------------------ | -------------------------------------- | -------------- | +| Boot | 414 ms | 136 ms | +| Working set | 482 MB | 134 MB | +| Private bytes | 406 MB | 121 MB | +| Processes | 7 | 1 | +| CPU, boot + 5 s | 2.34 s | 1.20 s | +| Shipped files | exe + 11.9 MB DLL, plus WebView2 | one 16.5 MB exe | + +## Layout + +| Path | Role | +| ---------------- | ----------------------------------------------------------------------- | +| `src/app/` | App state, actions, dialogs, the backend jobs | +| `src/screens/` | The workspace, settings, personas and onboarding screens | +| `src/ui/` | Widgets, the CSS box painter, text, animations | +| `src/window/` | The native window: frame, title bar, DPI, acrylic | +| `src/harness/` | The scene runner | +| `src/soft.rs` | The software runner: winit loop, damaged-tiles present, pacing | +| `tauri.conf.json`, `icons/`, `bundle/` | Installer config, read by the Tauri bundler and `build.rs` | +| `vendor/` | egui_software_backend and epaint, patched | diff --git a/crates/accshift-egui/assets/chromium/LICENSE.md b/crates/accshift-egui/assets/chromium/LICENSE.md new file mode 100644 index 00000000..7a019d7a --- /dev/null +++ b/crates/accshift-egui/assets/chromium/LICENSE.md @@ -0,0 +1,36 @@ +# Chromium resources + +`search_cancel.png` and `search_cancel_dark_mode.png` are copied unchanged from +`third_party/blink/public/default_100_percent/blink/` in the Chromium sources. WebView2 +draws them as the clear button of an ``, so the egui search field +draws the same pixels. They ship under Chromium's BSD license: + +``` +Copyright 2015 The Chromium Authors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` diff --git a/crates/accshift-egui/assets/chromium/search_cancel.png b/crates/accshift-egui/assets/chromium/search_cancel.png new file mode 100644 index 00000000..1291bf84 Binary files /dev/null and b/crates/accshift-egui/assets/chromium/search_cancel.png differ diff --git a/crates/accshift-egui/assets/chromium/search_cancel_dark_mode.png b/crates/accshift-egui/assets/chromium/search_cancel_dark_mode.png new file mode 100644 index 00000000..a2df359f Binary files /dev/null and b/crates/accshift-egui/assets/chromium/search_cancel_dark_mode.png differ diff --git a/crates/accshift-egui/assets/fonts/LICENSE.md b/crates/accshift-egui/assets/fonts/LICENSE.md new file mode 100644 index 00000000..b7d8a395 --- /dev/null +++ b/crates/accshift-egui/assets/fonts/LICENSE.md @@ -0,0 +1,119 @@ +# Bundled font licenses + +These are the faces of `public/fonts` decompressed from WOFF2 to TrueType (egui reads +no WOFF2), glyphs unchanged: `fontTools` `TTFont(path); font.flavor = None; font.save(...)`. +They ship under the SIL Open Font License 1.1, whose full text is reproduced below, +unmodified except for the glyph subsetting described under Noto Sans SC. + +## VT323-Regular.ttf + +Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com) + +Used for the pixel lettering of the AFK and streamer overlays. Latin only, see +the unicode-range on its @font-face in src/app.css. + +## NotoSansSC-Subset-Regular.ttf, NotoSansSC-Subset-Bold.ttf + +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +Last-resort Han coverage for systems that ship no CJK font, which happens on +minimal Linux installs. Both files are subsets of Noto Sans SC cut down to the +489 characters the Chinese dictionary can put on screen, so they weigh ~70 KB +each instead of several megabytes. Regenerate the WOFF2 files in `public/fonts` after adding Chinese strings, then convert them again: +collect every non-ASCII character of src/lib/i18n/messages.zh.ts and request +https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400&text= +with a browser User-Agent, then download the woff2 the CSS points at. + +## SIL Open Font License 1.1 + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + +--- + +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1. Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + +2. Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + +3. No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + +4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + +5. The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/accshift-egui/assets/fonts/NotoSansSC-Subset-Bold.ttf b/crates/accshift-egui/assets/fonts/NotoSansSC-Subset-Bold.ttf new file mode 100644 index 00000000..94b45f35 Binary files /dev/null and b/crates/accshift-egui/assets/fonts/NotoSansSC-Subset-Bold.ttf differ diff --git a/crates/accshift-egui/assets/fonts/NotoSansSC-Subset-Regular.ttf b/crates/accshift-egui/assets/fonts/NotoSansSC-Subset-Regular.ttf new file mode 100644 index 00000000..2846f1ef Binary files /dev/null and b/crates/accshift-egui/assets/fonts/NotoSansSC-Subset-Regular.ttf differ diff --git a/crates/accshift-egui/assets/fonts/VT323-Regular.ttf b/crates/accshift-egui/assets/fonts/VT323-Regular.ttf new file mode 100644 index 00000000..91501495 Binary files /dev/null and b/crates/accshift-egui/assets/fonts/VT323-Regular.ttf differ diff --git a/public/logo.svg b/crates/accshift-egui/assets/logo.svg similarity index 100% rename from public/logo.svg rename to crates/accshift-egui/assets/logo.svg diff --git a/crates/accshift-egui/assets/onboarding/no-thanks.webp b/crates/accshift-egui/assets/onboarding/no-thanks.webp new file mode 100644 index 00000000..c87e6d3d Binary files /dev/null and b/crates/accshift-egui/assets/onboarding/no-thanks.webp differ diff --git a/crates/accshift-egui/assets/onboarding/trade-offer.webp b/crates/accshift-egui/assets/onboarding/trade-offer.webp new file mode 100644 index 00000000..0b3c0797 Binary files /dev/null and b/crates/accshift-egui/assets/onboarding/trade-offer.webp differ diff --git a/crates/accshift-egui/build.rs b/crates/accshift-egui/build.rs new file mode 100644 index 00000000..7cfb6ad7 --- /dev/null +++ b/crates/accshift-egui/build.rs @@ -0,0 +1,123 @@ +// Windows startup: the loader maps every statically imported DLL before +// `main`. A bare Rust exe reaches `main` in about 13 ms here; this one took 31. +// The DLLs below are only needed for accessibility clients (UI Automation), +// links and COM, never for the first frame, so they load on first call. +// +// The exe also gets what `tauri build` gives `accshift-gui.exe`: the VC +// runtime linked statically, and its icon, version and manifest resources. +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + let windows = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows"); + let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); + if !(windows && msvc) { + return; + } + for dll in [ + "uiautomationcore.dll", + "shell32.dll", + "shlwapi.dll", + "ole32.dll", + "oleaut32.dll", + "combase.dll", + ] { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}"); + } + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + // Release only, as `tauri build` did: the stub library's search path + // reaches every link of the build, the doctests of the other workspace + // crates included, which then lose the C++ frame handler. + if std::env::var("PROFILE").as_deref() == Ok("release") { + static_vcruntime(); + } + resources(); +} + +/// tauri-build's `static_vcruntime::build`, which the Tauri CLI turns on +/// for every build (`STATIC_VCRUNTIME=true`): vcruntime linked in, the UCRT +/// left to Windows, so the exe starts without the VC++ redistributable. +fn static_vcruntime() { + // The hard-coded msvcrt.lib, replaced by a (mostly) empty object file. + let machine: &[u8] = match std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() { + Ok("x86_64") => &[0x64, 0x86], + Ok("x86") => &[0x4C, 0x01], + _ => return, + }; + let bytes: &[u8] = &[ + 1, 0, 94, 3, 96, 98, 60, 0, 0, 0, 1, 0, 0, 0, 0, 0, 132, 1, 46, 100, 114, 101, 99, 116, + 118, 101, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 10, 16, 0, 46, 100, 114, 101, 99, 116, 118, 101, 0, 0, 0, 0, 1, 0, 0, 0, 3, 0, 4, 0, + 0, 0, + ]; + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR"); + let lib = std::path::Path::new(&out_dir).join("msvcrt.lib"); + std::fs::write(&lib, [machine, bytes].concat()).expect("msvcrt.lib"); + println!("cargo:rustc-link-search=native={out_dir}"); + for lib in [ + "libvcruntimed.lib", + "vcruntime.lib", + "vcruntimed.lib", + "libcmtd.lib", + "msvcrt.lib", + "msvcrtd.lib", + "libucrt.lib", + "libucrtd.lib", + ] { + println!("cargo:rustc-link-arg=/NODEFAULTLIB:{lib}"); + } + for lib in ["libcmt.lib", "libvcruntime.lib", "ucrt.lib"] { + println!("cargo:rustc-link-arg=/DEFAULTLIB:{lib}"); + } +} + +/// The resource file tauri-build gave the Tauri app, from the tauri.conf.json +/// the installer is bundled with: the icon as resource 32512, the version +/// info and the Common Controls 6 manifest. +fn resources() { + let conf_path = std::path::Path::new("tauri.conf.json"); + let icon = std::path::Path::new("icons").join("icon.ico"); + println!("cargo:rerun-if-changed={}", conf_path.display()); + println!("cargo:rerun-if-changed={}", icon.display()); + let conf: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(conf_path).expect("tauri.conf.json")) + .expect("tauri.conf.json"); + let text = |key: &str| conf[key].as_str().expect(key).to_string(); + let (version, product, identifier) = (text("version"), text("productName"), text("identifier")); + let mut res = tauri_winres::WindowsResource::new(); + res.set_manifest(MANIFEST); + // `to_winres_version`: no build metadata in the app's versions. + let parts: Vec = version + .split('.') + .map(|p| p.parse().expect("numeric version")) + .collect(); + let packed = (parts[0] << 48) | (parts[1] << 32) | (parts[2] << 16); + res.set_version_info(tauri_winres::VersionInfo::FILEVERSION, packed); + res.set_version_info(tauri_winres::VersionInfo::PRODUCTVERSION, packed); + res.set("FileVersion", &version); + res.set("ProductVersion", &version); + res.set("ProductName", &product); + // No `bundle.publisher`: the identifier's second part. + res.set( + "CompanyName", + identifier.split('.').nth(1).unwrap_or(&identifier), + ); + res.set("FileDescription", &product); + res.set_icon_with_id(&icon.display().to_string(), "32512"); + res.compile().expect("Windows resources"); +} + +/// tauri-build's windows-app-manifest.xml. +const MANIFEST: &str = r#" + + + + + + +"#; diff --git a/crates/accshift-egui/bundle/bundle.mjs b/crates/accshift-egui/bundle/bundle.mjs new file mode 100644 index 00000000..0574939d --- /dev/null +++ b/crates/accshift-egui/bundle/bundle.mjs @@ -0,0 +1,169 @@ +// Builds the installers of the app with the Tauri bundler (the Tauri CLI +// alone, no Tauri runtime) and ../tauri.conf.json: same product name, +// identifier, install dir, registry keys, shortcuts and updater artifacts as +// the v1.0.x Tauri builds, so installing over one is an ordinary update. +// +// node crates/accshift-egui/bundle/bundle.mjs [--build] [--bundles ] [--no-updater] +// +// --build first builds the GUI and the CLI in release, with --locked. +// Without it both must already be built for the host. --bundles defaults to +// the installers of the host OS: nsis on Windows, appimage,deb,rpm on Linux, +// app,dmg on macOS. --no-updater skips the updater artifacts, which need +// TAURI_SIGNING_PRIVATE_KEY. +// +// The bundler packs `//release/accshift-gui[.exe]` when given +// a target triple: the egui binary is copied there under the name the +// installers and the updater know. The CLI goes next to it (`externalBin`) +// from `binaries/accshift-[.exe]`. +import { execFileSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const crate = join(here, ".."); +const root = join(crate, "..", ".."); + +const args = process.argv.slice(2); +const flag = (name) => args.includes(name); +const option = (name) => { + const at = args.indexOf(name); + return at >= 0 ? args[at + 1] : undefined; +}; + +const DEFAULT_BUNDLES = { win32: "nsis", linux: "appimage,deb,rpm", darwin: "app,dmg" }; +const bundles = option("--bundles") ?? DEFAULT_BUNDLES[process.platform]; +if (!bundles) throw new Error(`no installer for ${process.platform}`); + +function hostTriple() { + const out = execFileSync("rustc", ["-vV"], { encoding: "utf8" }); + const match = out.match(/^host: (\S+)$/m); + if (!match) throw new Error("could not parse the host triple from `rustc -vV`"); + return match[1]; +} + +// `/target` is only the default: CARGO_TARGET_DIR or a cargo config +// can move it. +function targetDirectory() { + const out = execFileSync("cargo", ["metadata", "--format-version", "1", "--no-deps"], { + encoding: "utf8", + cwd: root, + maxBuffer: 32 * 1024 * 1024, + }); + return JSON.parse(out).target_directory; +} + +if (flag("--build")) { + execFileSync( + "cargo", + ["build", "--release", "--locked", "-p", "accshift-egui", "-p", "accshift-cli"], + { cwd: root, stdio: "inherit" }, + ); +} + +const triple = hostTriple(); +const ext = process.platform === "win32" ? ".exe" : ""; +const target = targetDirectory(); +const built = join(target, "release"); + +const release = join(target, triple, "release"); +mkdirSync(release, { recursive: true }); +const gui = join(release, `accshift-gui${ext}`); +copyFileSync(join(built, `accshift-egui${ext}`), gui); +console.log(`staged GUI: ${gui}`); + +const binaries = join(crate, "binaries"); +mkdirSync(binaries, { recursive: true }); +const cli = join(binaries, `accshift-${triple}${ext}`); +copyFileSync(join(built, `accshift${ext}`), cli); +console.log(`staged CLI: ${cli}`); + +const configs = []; +if (flag("--no-updater")) { + configs.push(JSON.stringify({ bundle: { createUpdaterArtifacts: false } })); +} + +// The bundler downloads linuxdeploy's AppRun into its tool cache with mode +// 0770 and copies it into the AppDir as is, where linuxdeploy renames it +// AppRun.wrapped. When the kernel mounts the image instead of FUSE (firejail +// --appimage, the AppImage catalog test), a user other than root cannot run +// it: "AppRun.wrapped: Permission denied". The bundler only downloads a +// missing file, so a copy left there with mode 0755 is the one it packs. +const appimage = process.platform === "linux" && bundles.split(",").includes("appimage"); +const toolsArch = /^arm.*hf$/.test(triple) ? "armhf" : triple.split("-")[0]; + +async function seedAppRun() { + const cache = process.env.XDG_CACHE_HOME || join(homedir(), ".cache"); + const apprun = join(cache, "tauri", `AppRun-${toolsArch}`); + if (!existsSync(apprun)) { + const url = `https://github.com/tauri-apps/binary-releases/releases/download/apprun-old/AppRun-${toolsArch}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`could not download ${url}: HTTP ${response.status}`); + mkdirSync(dirname(apprun), { recursive: true }); + writeFileSync(apprun, Buffer.from(await response.arrayBuffer())); + } + chmodSync(apprun, 0o755); + console.log(`seeded AppRun: ${apprun}`); +} + +// Fails the build when the bundler stops packing the seeded copy (a new cache +// path or file name): every user must be able to run the image's entry points. +function checkAppImage(file) { + const dir = mkdtempSync(join(tmpdir(), "accshift-appimage-")); + try { + execFileSync(file, ["--appimage-extract", "AppRun*"], { cwd: dir, stdio: "ignore" }); + for (const name of ["AppRun", "AppRun.wrapped"]) { + const path = join(dir, "squashfs-root", name); + if (!existsSync(path)) continue; + const mode = statSync(path).mode & 0o777; + if ((mode & 0o005) !== 0o005) { + throw new Error(`${file}: ${name} has mode ${mode.toString(8)}, other users cannot run it`); + } + } + console.log(`checked AppImage entry points: ${file}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +if (appimage) await seedAppRun(); + +const tauri = createRequire(join(root, "package.json")).resolve("@tauri-apps/cli/tauri.js"); +execFileSync( + process.execPath, + [ + tauri, + "bundle", + "--bundles", + bundles, + "--target", + triple, + ...configs.flatMap((config) => ["--config", config]), + ], + // The folder holding tauri.conf.json, found without a walk from the root. + { cwd: root, stdio: "inherit", env: { ...process.env, TAURI_APP_PATH: crate } }, +); + +const out = join(release, "bundle"); +for (const kind of readdirSync(out)) { + for (const name of readdirSync(join(out, kind))) console.log(`bundle: ${join(out, kind, name)}`); +} + +if (appimage) { + const dir = join(out, "appimage"); + for (const name of readdirSync(dir).filter((name) => name.endsWith(".AppImage"))) { + checkAppImage(join(dir, name)); + } +} diff --git a/crates/accshift-egui/bundle/hooks.nsh b/crates/accshift-egui/bundle/hooks.nsh new file mode 100644 index 00000000..5cfd70b0 --- /dev/null +++ b/crates/accshift-egui/bundle/hooks.nsh @@ -0,0 +1,23 @@ +; NSIS installer hooks (tauri.conf.json). +; +; accshift-gui.exe is the whole app. An install over v1.0.x removes the DLL +; the Tauri versions left beside the exe; path.nsh adds the CLI to the user +; PATH. + +!include "${__FILEDIR__}\path.nsh" + +!macro NSIS_HOOK_POSTINSTALL + ; Runs once the installer has closed any running Accshift, so the old DLL + ; is not locked. + Delete "$INSTDIR\accshift_gui_lib.dll" + + !insertmacro ACCSHIFT_PATH_ADD +!macroend + +!macro NSIS_HOOK_POSTUNINSTALL + ; An install the post-install step could not clean. + Delete "$INSTDIR\accshift_gui_lib.dll" + RMDir "$INSTDIR" + + !insertmacro ACCSHIFT_PATH_REMOVE +!macroend diff --git a/crates/accshift-egui/bundle/path-add.ps1 b/crates/accshift-egui/bundle/path-add.ps1 new file mode 100644 index 00000000..05e54251 --- /dev/null +++ b/crates/accshift-egui/bundle/path-add.ps1 @@ -0,0 +1,31 @@ +# Adds the install directory to the user PATH (path.nsh, ACCSHIFT_PATH_ADD). +# The directory comes from the ACCSHIFT_INSTALL_DIR process variable, never +# from the command line: install directories may legally contain apostrophes +# or other shell metacharacters. Records the entry under +# HKCU\Software\Accshift\Installer so the uninstaller removes only what this +# installer added. +$ErrorActionPreference = 'Stop' +try { + $dir = [Environment]::GetEnvironmentVariable('ACCSHIFT_INSTALL_DIR', 'Process') + if ([string]::IsNullOrWhiteSpace($dir) -or $dir.Contains(';')) { throw 'Invalid install directory' } + $k = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) + if ($null -eq $k) { throw 'Could not open user environment key' } + $p = [string]$k.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $n = $dir.TrimEnd('\') + $exists = @($p -split ';' | Where-Object { $_ -and [string]::Equals($_.TrimEnd('\'), $n, [StringComparison]::OrdinalIgnoreCase) }).Count -gt 0 + $m = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Accshift\Installer') + $owned = [string]$m.GetValue('PathEntry', '') + if (-not $exists) { + $kind = if ($k.GetValueNames() -contains 'Path') { $k.GetValueKind('Path') } else { [Microsoft.Win32.RegistryValueKind]::ExpandString } + $k.SetValue('Path', ($p.TrimEnd(';') + ';' + $dir).TrimStart(';'), $kind) + $m.SetValue('PathEntry', $dir, [Microsoft.Win32.RegistryValueKind]::String) + } elseif (-not [string]::Equals($owned, $dir, [StringComparison]::OrdinalIgnoreCase)) { + # An entry that existed before installation is not ours to remove later. + $m.DeleteValue('PathEntry', $false) + } + $m.Close() + $k.Close() +} catch { + Write-Error $_ + exit 1 +} diff --git a/crates/accshift-egui/bundle/path-remove.ps1 b/crates/accshift-egui/bundle/path-remove.ps1 new file mode 100644 index 00000000..dab46460 --- /dev/null +++ b/crates/accshift-egui/bundle/path-remove.ps1 @@ -0,0 +1,32 @@ +# Removes the install directory from the user PATH (path.nsh, +# ACCSHIFT_PATH_REMOVE), only when HKCU\Software\Accshift\Installer records +# it as added by the installer. A PATH entry that existed before installation +# is deliberately left untouched. The directory comes from the +# ACCSHIFT_INSTALL_DIR process variable, never from the command line. +$ErrorActionPreference = 'Stop' +try { + $dir = [Environment]::GetEnvironmentVariable('ACCSHIFT_INSTALL_DIR', 'Process') + if ([string]::IsNullOrWhiteSpace($dir)) { throw 'Missing install directory' } + $m = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software\Accshift\Installer', $true) + if ($null -ne $m) { + $owned = [string]$m.GetValue('PathEntry', '') + if ([string]::Equals($owned, $dir, [StringComparison]::OrdinalIgnoreCase)) { + $k = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) + if ($null -eq $k) { throw 'Could not open user environment key' } + $p = [string]$k.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $n = $dir.TrimEnd('\') + $all = @($p -split ';' | Where-Object { $_ }) + $e = @($all | Where-Object { -not [string]::Equals($_.TrimEnd('\'), $n, [StringComparison]::OrdinalIgnoreCase) }) + if ($e.Count -ne $all.Count) { + $kind = if ($k.GetValueNames() -contains 'Path') { $k.GetValueKind('Path') } else { [Microsoft.Win32.RegistryValueKind]::ExpandString } + $k.SetValue('Path', ($e -join ';'), $kind) + } + $k.Close() + $m.DeleteValue('PathEntry', $false) + } + $m.Close() + } +} catch { + Write-Error $_ + exit 1 +} diff --git a/crates/accshift-egui/bundle/path.nsh b/crates/accshift-egui/bundle/path.nsh new file mode 100644 index 00000000..28b23a2c --- /dev/null +++ b/crates/accshift-egui/bundle/path.nsh @@ -0,0 +1,70 @@ +; User PATH integration of the Windows installer, included by +; crates/accshift-egui/bundle/hooks.nsh. +; +; Adds the install directory to the user PATH so the bundled accshift CLI +; (shipped via externalBin) is callable from any shell, and removes it again +; on uninstall. The installer runs with installMode "currentUser", so the +; user-scoped HKCU\Environment key is the right target (no admin rights +; needed). If installMode ever changes to perMachine, this must move to +; HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment. +; +; The PATH read-modify-write is delegated to PowerShell. NSIS MUST NOT touch +; the value itself: ReadRegStr returns an empty string for values longer than +; the NSIS build-time string limit (1024 chars on the standard build), so a +; naive read-append-write silently WIPES a long PATH. PowerShell has no such +; limit and lets us do an exact entry-wise match instead of substring games. +; The scripts run from files (path-add.ps1, path-remove.ps1): the same limit +; cuts any command line longer than 1024 chars, and the inline commands they +; replace never ran whole. + +!include "WinMessages.nsh" +!include "LogicLib.nsh" + +; This file's directory, fixed here: a macro body sees the caller's. +!define ACCSHIFT_PATH_DIR "${__FILEDIR__}" + +!macro ACCSHIFT_PATH_ADD + ; Pass the path as process state, never as PowerShell source code. Install + ; directories may legally contain apostrophes or other shell metacharacters. + System::Call 'Kernel32::SetEnvironmentVariable(t "ACCSHIFT_INSTALL_DIR", t "$INSTDIR") i .r1' + ${If} $1 = 0 + DetailPrint "Could not prepare Accshift PATH integration" + SetErrors + ${Else} + InitPluginsDir + File "/oname=$PLUGINSDIR\accshift-path-add.ps1" "${ACCSHIFT_PATH_DIR}\path-add.ps1" + nsExec::ExecToLog 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\accshift-path-add.ps1"' + Pop $0 + System::Call 'Kernel32::SetEnvironmentVariable(t "ACCSHIFT_INSTALL_DIR", t 0)' + ${If} $0 = 0 + ; Tell running apps (Explorer included) the environment changed, so new + ; shells pick up the PATH without a logout. + SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000 + ${Else} + DetailPrint "Could not add Accshift to the user PATH (PowerShell exit code $0)" + SetErrors + ${EndIf} + ${EndIf} +!macroend + +!macro ACCSHIFT_PATH_REMOVE + System::Call 'Kernel32::SetEnvironmentVariable(t "ACCSHIFT_INSTALL_DIR", t "$INSTDIR") i .r1' + ${If} $1 = 0 + DetailPrint "Could not prepare Accshift PATH cleanup" + SetErrors + ${Else} + ; Remove only the exact entry this installer recorded as its own. A PATH + ; entry that existed before installation is deliberately left untouched. + InitPluginsDir + File "/oname=$PLUGINSDIR\accshift-path-remove.ps1" "${ACCSHIFT_PATH_DIR}\path-remove.ps1" + nsExec::ExecToLog 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\accshift-path-remove.ps1"' + Pop $0 + System::Call 'Kernel32::SetEnvironmentVariable(t "ACCSHIFT_INSTALL_DIR", t 0)' + ${If} $0 = 0 + SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000 + ${Else} + DetailPrint "Could not remove Accshift from the user PATH (PowerShell exit code $0)" + SetErrors + ${EndIf} + ${EndIf} +!macroend diff --git a/src-tauri/icons/128x128.png b/crates/accshift-egui/icons/128x128.png similarity index 100% rename from src-tauri/icons/128x128.png rename to crates/accshift-egui/icons/128x128.png diff --git a/src-tauri/icons/128x128@2x.png b/crates/accshift-egui/icons/128x128@2x.png similarity index 100% rename from src-tauri/icons/128x128@2x.png rename to crates/accshift-egui/icons/128x128@2x.png diff --git a/src-tauri/icons/32x32.png b/crates/accshift-egui/icons/32x32.png similarity index 100% rename from src-tauri/icons/32x32.png rename to crates/accshift-egui/icons/32x32.png diff --git a/src-tauri/icons/icon.icns b/crates/accshift-egui/icons/icon.icns similarity index 100% rename from src-tauri/icons/icon.icns rename to crates/accshift-egui/icons/icon.icns diff --git a/src-tauri/icons/icon.ico b/crates/accshift-egui/icons/icon.ico similarity index 100% rename from src-tauri/icons/icon.ico rename to crates/accshift-egui/icons/icon.ico diff --git a/src-tauri/icons/icon.png b/crates/accshift-egui/icons/icon.png similarity index 100% rename from src-tauri/icons/icon.png rename to crates/accshift-egui/icons/icon.png diff --git a/src-tauri/icons/installer/nsis-header.bmp b/crates/accshift-egui/icons/installer/nsis-header.bmp similarity index 100% rename from src-tauri/icons/installer/nsis-header.bmp rename to crates/accshift-egui/icons/installer/nsis-header.bmp diff --git a/src-tauri/icons/installer/nsis-sidebar.bmp b/crates/accshift-egui/icons/installer/nsis-sidebar.bmp similarity index 100% rename from src-tauri/icons/installer/nsis-sidebar.bmp rename to crates/accshift-egui/icons/installer/nsis-sidebar.bmp diff --git a/src-tauri/icons/installer/wix-banner.bmp b/crates/accshift-egui/icons/installer/wix-banner.bmp similarity index 100% rename from src-tauri/icons/installer/wix-banner.bmp rename to crates/accshift-egui/icons/installer/wix-banner.bmp diff --git a/src-tauri/icons/installer/wix-dialog.bmp b/crates/accshift-egui/icons/installer/wix-dialog.bmp similarity index 100% rename from src-tauri/icons/installer/wix-dialog.bmp rename to crates/accshift-egui/icons/installer/wix-dialog.bmp diff --git a/crates/accshift-egui/parity/README.md b/crates/accshift-egui/parity/README.md new file mode 100644 index 00000000..fa782716 --- /dev/null +++ b/crates/accshift-egui/parity/README.md @@ -0,0 +1,108 @@ +# Parity scenes + +Every screen of the egui app was compared, pixel by pixel, with the same screen of the Tauri app on WebView2. The reference side (`ref.mjs`, a capture build of the Tauri app, `snap/measure.mjs`, the mock data export) left with the Tauri app; what it measured stays here: + +- `scenes/*.json`, the scenes, played by the egui runner for regression shots; +- `data/*.json`, the mock data the scenes run on, and `avatars/`, the mock avatars it points to; +- `fragments/*.html`, the static CSS boxes of the `primitives-*` scenes; +- `diff.mjs`, the pixel diff of two shot folders (Node 22+ and ffmpeg on `PATH`); +- the rules below, measured on WebView2, which the egui painter follows. + +Mentions of `ref.mjs` below describe how the reference side played a scene. + +## Run it + +```sh +cargo run --release -p accshift-egui -- --scene crates/accshift-egui/parity/scenes/*.json --out +node crates/accshift-egui/parity/diff.mjs --batch --out +``` + +## Scene format + +`scenes/.json`, the contract between `ref.mjs` and the egui runner (`accshift-egui --scene`): + +```json +{ + "id": "main-grid", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "seed": { "SETTINGS": { "viewMode": "grid" } }, + "steps": [ + { "wait": 600 }, + { "move": [425, 220] }, + { "click": [315, 133] }, + { "dblclick": [260, 17] }, + { "rclick": [315, 133] }, + { "key": "Ctrl+K" }, + { "text": "ra" }, + { "scroll": [430, 300, -120] }, + { "shot": "hover", "alias": "egui-0-hover" } + ] +} +``` + +- Coordinates are CSS pixels of the viewport (1 CSS px = 1 egui point). Output: `//.png`, viewport times scale. +- `scenario` names a file of `data/`; `os` sets the mock's runtime OS. +- `streaming` (optional, `true`) makes `detect_streaming_software` report streaming software running (`__mock.streaming`), so a scene seeded with `streamerMode: "auto"` reaches the streamer overlay. +- `cs2` (optional, an array of `Cs2BridgeAccount` rows) enables the CS2 bridge and answers `cs2_bridge_fetch` and `cs2_bridge_check` with these rows (`__mock.cs2`), so Steam cards show the CS2 badge and section. +- `fail` (optional, an object) maps commands to the error message they fail with (`__mock.fail`), on both sides: `{ "platform_switch_account": "..." }` makes every switch fail. +- `setup` (optional, an array of setup statuses with `setupId`, `state`, `accountId`, `accountDisplayName` and `errorMessage`) is the add-account setup the mock runs on both sides: `platform_begin_setup` answers the first status, each `platform_get_setup_status` the next one, the last one repeating, and `platform_cancel_setup` succeeds. +- `clipboard` (optional, a string) is the text the clipboard holds: `ref.mjs` makes `navigator.clipboard.readText` answer it, the egui runner answers the app's paste request with it. A scene never reads the machine's clipboard; an empty string reads as a clipboard with no text. +- `theme` and `locale` patch `client.settings` (`themeId`, `language`). A `seed` key is a store id (`client.view-mode`) or the suffix of its `CLIENT_STORE_` constant in `src/lib/storage/storeIds.ts` (`SETTINGS`); an object value is merged over the scenario's own store, anything else replaces it. A seeded string under `crates/accshift-egui/parity/avatars/` (a `cache.steam.profiles` URL) is the file there: `ref.mjs` turns it into a same-origin URL, egui reads the file. +- `down` presses the primary button at a point and holds it; every `move` until the next `up` (which moves there, then releases) reports the held button, so a scene can drag (the bulk edit paint gesture). +- `key` takes `Ctrl`, `Shift`, `Alt` and `Meta` prefixes joined with `+`, then a character or a key name (`Enter`, `Escape`, `Backspace`, `Tab`, `Delete`, `Home`, `End`, arrow keys, `Space`, `ContextMenu`). egui has no context menu key: the egui side sends it as `CONTEXT_MENU_KEY` (F35), as the software runner does for the real key. `text` types character by character. `scroll` is `[x, y, deltaY]`, with an optional fourth item naming the modifiers held (`"Ctrl"`, joined with `+`). A synthetic Ctrl+wheel from CDP does not reach the app's zoom handler in WebView2, so the `zoom-*` scenes zoom with `Ctrl+=` and `Ctrl+-`. +- A shot waits for nothing: put a `wait` before it, long enough for transitions to end. +- `html` (optional) names a fragment file, relative to the scene file. `ref.mjs` replaces the page body with it once the scene has settled; the egui runner draws the same file with `ui::paint` instead of the app. The `primitives-*` scenes use `fragments/*.html` this way to calibrate the CSS box painter at 1, 1.25 and 1.5 pixels per point. Only absolutely positioned `
`s and `

`s with inline `style` are drawn on the egui side: a `

` is a box (`ui::paint`), a `

` one line of text (`ui::text`). The `typography-*` scenes calibrate `ui::text` with every text style of the app. +- `probe` (optional, with `html`): `ref.mjs` also measures every `

` of the fragment and writes `//probe.json` (computed font values, line box, text box, baseline in CSS px, and the faces `CSS.getPlatformFontsForNode` reports). The ignored test `screens::primitives::tests::text_matches_webview2_probe` compares `ui::text` against it: `ACCSHIFT_TEXT_PROBE= cargo test --release -- --ignored probe`. +- `dom` is a step, `{ "dom": "", "select": "" }`: `ref.mjs` writes `//.dom.json`, every matching element with its border box in CSS px, its focus, `:focus-visible` and `:hover` state, and the computed values listed in `DOM_PROPS`. The egui runner skips it. +- `deeplink` is a step, `{ "deeplink": "accshift://switch//" }`: the URL a second launch hands over. `ref.mjs` emits the deep-link plugin's `deep-link://new-url` event with it through the event plugin; the egui runner gives it to the app's deep link controller. +- `alias` on a shot is optional: the file name, without extension, of the same shot in an older capture set (the spike's `--capture` PAMs). `diff.mjs --batch` uses it when `//` does not exist. + +Before each scene, the page is reloaded into its mock state, and runs once every image is decoded and no avatar is blurred. + +## What both sides must reproduce + +- Clock: `Date.now()` and an argument-less `new Date()` start at the mock's `NOW` (1_791_000_000 s) when the document starts, and advance with `performance.now()`. +- Glass themes: a transparent window has nothing behind it, so the page paints a stand-in desktop as the `html` background, the one of the README recorder (`WALLPAPER` in `.agents/scripts/record-demo.mjs`). The desktop is the viewport plus 36 CSS px on each side, with the window at 36,36; the page sees it shifted by -36,-36. `src/backdrop.rs` draws the same thing. Layers, top first: + - `radial-gradient(48% 62% at 16% 20%, rgba(109,40,217,.42), transparent 72%)` + - `radial-gradient(46% 60% at 86% 84%, rgba(29,78,216,.40), transparent 72%)` + - `radial-gradient(34% 44% at 72% 10%, rgba(190,24,93,.22), transparent 72%)` + - `radial-gradient(38% 48% at 28% 96%, rgba(8,145,178,.22), transparent 72%)` + - `linear-gradient(160deg, #10121f, #0a0c16)` +- `prefers-reduced-motion` is emulated as `no-preference`; the mock forces `animations: "on"` anyway. +- UI zoom (`uiScalePercent` seeded in `SETTINGS`): `.app-stage` is scaled from its top left, its width divided by the zoom and its height the window's (it is `flex: 1`). The stage rastered during the boot entrance is drawn bilinearly scaled until the zoom changes; with no entrance, or after a change, it is rastered sharp at its final size. `src/zoom.rs` reproduces both. The `zoom-*` scenes cover 90, 110 and 125 at 1 and 1.5 and the shortcuts; a shortcut leaves Chromium's hover on the element under the pointer before the change, so those scenes park the pointer on the title bar. + +## Diff metrics + +Both images are decoded to RGBA by ffmpeg (PNG or PAM) and composited over black. Per shot: mean absolute error over RGB (0 to 255), PSNR over RGB in dB (99 when identical), percent of pixels whose largest channel delta exceeds 8 and 24, the worst 16x16 tile with its mean error, a heat map (`-heat.png`, largest channel delta times 4 on a black, red, yellow, white ramp) and a side-by-side (`-side.png`, reference on top). Batch mode writes `report.json` and `report.md`, one row per shot. + +`--region x,y,w,h` limits every number and both images to one rectangle, in CSS px of the viewport. Batch mode scales it by each scene's `viewport.scale` (read from `scenes/.json`, a scene without a file is skipped), a single pair by `--scale ` (default 1); edges are rounded, so `0,0,860,36` is 54 device rows at 1.5. The worst tile is reported in image pixels of the full frame. The title bar alone: `node parity/diff.mjs --batch --out

--region 0,0,860,36`. + +`ref.mjs` rewrites `/manifest.json` with the scenes of the current run only: capturing more scenes into an existing reference folder drops the earlier ones from the manifest. Use a fresh folder per run, or delete the manifest so `diff.mjs` pairs every PNG it finds. + +## Device-pixel snapping + +Where WebView2 puts a box at a real device scale factor is measured, not assumed: `snap/measure.mjs` renders a fragment of 25 cases (backgrounds, borders, radii, shadows, translations, `will-change`, flex centring, SVG roots, `img`, `opacity`) at six fractional CSS offsets, in the real app at each scale, and reads the painted edges from the frame. + +It writes `cases.json` (layout rect and painted edges per case, offset and scale), `report.md`, one `frame.png` per scale and `titlebar.json` (the title bar's boxes on the `demo` scenario). `--dsf emulate` measures the CDP emulation instead, which is what the references were before 2026-10-12. Same safety as `ref.mjs`. + +The rules below come from `measure.mjs` at 1, 1.25, 1.5 and 1.75. Boxes, borders, shadows, `img`, SVG roots, `opacity` and flex centring match on all 24 samples per case; the translated cases match up to the 2-decimal rounding of `cases.json`, and `shadow-rounded` is read across the corner arc, so its scan is not an edge. `src/ui/snap.rs` implements the rules, and `ui::paint` and the components use them. Layout runs in device px with 1/64 px units, so a position is `css * scale` quantized to 1/64 before any rounding. + +| Case | Rule | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Background, rounded box, `box-shadow` base, `img`, inline `svg` root, `opacity`, flex-centred child | Each edge rounded half up on its own: `round(x)` to `round(x + w)` (`snap::rect`). | +| `border-width` | Floored to whole device px, 1 minimum when non-zero: 1 CSS px is 1 device px at 1.25, 1.5 and 1.75, 2 CSS px is 2, 3 and 3 (`snap::border_device`). | +| `box-shadow` | The snapped border box plus `offset * scale`, blurred; a 4 px offset is whole at every scale tested. | +| `transform: translate()`, `will-change: transform`, running animation | The untransformed box snapped, then the translation added exactly: a fractional translation paints anti-aliased edges (`snap::translated`). | +| `will-change: transform` without translation | Origin rounded, size rounded from the origin. | +| Contents of a scroll container with real overflow (the title bar `.tabs`, whose tooltips overflow) | Composited: the whole offset, translation included, rounded (`snap::composited`). Measured on the title bar at 1.25, 1.5 and 1.75. | +| Flex centring | Free space halved in 1/64 px units, then the child snapped as a box (`snap::centre`). | + +Ban warnings only reach the first scene of each scale: in the same app run, later scenes show no ban outline and no warning section, though each scene reloads the page on a fresh mock. The cause is still open. Capture a scene that shows bans alone (`ref.mjs --out `); the `cardext-*` scenes need it. + +## Mock data + +`data/.json`, format 1: `spec` (label, runtime OS, switch delay, Steam path, API key flag, current account per platform), `commands` (per command, a list of `{ args, result }` or `{ args, error }`), `skipped` (commands with state that are modelled from `spec` instead: the switch and the PIN session). Platform commands are called once per platform id, per-account commands once per account. Avatar URLs are repo-relative paths under `crates/accshift-egui/parity/avatars/`, cache timestamps are exactly `NOW * 1000`. diff --git a/src/lib/mock/avatars/1.svg b/crates/accshift-egui/parity/avatars/1.svg similarity index 100% rename from src/lib/mock/avatars/1.svg rename to crates/accshift-egui/parity/avatars/1.svg diff --git a/src/lib/mock/avatars/10.svg b/crates/accshift-egui/parity/avatars/10.svg similarity index 100% rename from src/lib/mock/avatars/10.svg rename to crates/accshift-egui/parity/avatars/10.svg diff --git a/src/lib/mock/avatars/11.svg b/crates/accshift-egui/parity/avatars/11.svg similarity index 100% rename from src/lib/mock/avatars/11.svg rename to crates/accshift-egui/parity/avatars/11.svg diff --git a/src/lib/mock/avatars/12.svg b/crates/accshift-egui/parity/avatars/12.svg similarity index 100% rename from src/lib/mock/avatars/12.svg rename to crates/accshift-egui/parity/avatars/12.svg diff --git a/src/lib/mock/avatars/13.svg b/crates/accshift-egui/parity/avatars/13.svg similarity index 100% rename from src/lib/mock/avatars/13.svg rename to crates/accshift-egui/parity/avatars/13.svg diff --git a/src/lib/mock/avatars/14.svg b/crates/accshift-egui/parity/avatars/14.svg similarity index 100% rename from src/lib/mock/avatars/14.svg rename to crates/accshift-egui/parity/avatars/14.svg diff --git a/src/lib/mock/avatars/15.svg b/crates/accshift-egui/parity/avatars/15.svg similarity index 100% rename from src/lib/mock/avatars/15.svg rename to crates/accshift-egui/parity/avatars/15.svg diff --git a/src/lib/mock/avatars/16.svg b/crates/accshift-egui/parity/avatars/16.svg similarity index 100% rename from src/lib/mock/avatars/16.svg rename to crates/accshift-egui/parity/avatars/16.svg diff --git a/src/lib/mock/avatars/17.svg b/crates/accshift-egui/parity/avatars/17.svg similarity index 100% rename from src/lib/mock/avatars/17.svg rename to crates/accshift-egui/parity/avatars/17.svg diff --git a/src/lib/mock/avatars/18.svg b/crates/accshift-egui/parity/avatars/18.svg similarity index 100% rename from src/lib/mock/avatars/18.svg rename to crates/accshift-egui/parity/avatars/18.svg diff --git a/src/lib/mock/avatars/19.svg b/crates/accshift-egui/parity/avatars/19.svg similarity index 100% rename from src/lib/mock/avatars/19.svg rename to crates/accshift-egui/parity/avatars/19.svg diff --git a/src/lib/mock/avatars/2.svg b/crates/accshift-egui/parity/avatars/2.svg similarity index 100% rename from src/lib/mock/avatars/2.svg rename to crates/accshift-egui/parity/avatars/2.svg diff --git a/src/lib/mock/avatars/20.svg b/crates/accshift-egui/parity/avatars/20.svg similarity index 100% rename from src/lib/mock/avatars/20.svg rename to crates/accshift-egui/parity/avatars/20.svg diff --git a/src/lib/mock/avatars/21.svg b/crates/accshift-egui/parity/avatars/21.svg similarity index 100% rename from src/lib/mock/avatars/21.svg rename to crates/accshift-egui/parity/avatars/21.svg diff --git a/src/lib/mock/avatars/3.svg b/crates/accshift-egui/parity/avatars/3.svg similarity index 100% rename from src/lib/mock/avatars/3.svg rename to crates/accshift-egui/parity/avatars/3.svg diff --git a/src/lib/mock/avatars/4.svg b/crates/accshift-egui/parity/avatars/4.svg similarity index 100% rename from src/lib/mock/avatars/4.svg rename to crates/accshift-egui/parity/avatars/4.svg diff --git a/src/lib/mock/avatars/5.svg b/crates/accshift-egui/parity/avatars/5.svg similarity index 100% rename from src/lib/mock/avatars/5.svg rename to crates/accshift-egui/parity/avatars/5.svg diff --git a/src/lib/mock/avatars/6.svg b/crates/accshift-egui/parity/avatars/6.svg similarity index 100% rename from src/lib/mock/avatars/6.svg rename to crates/accshift-egui/parity/avatars/6.svg diff --git a/src/lib/mock/avatars/7.svg b/crates/accshift-egui/parity/avatars/7.svg similarity index 100% rename from src/lib/mock/avatars/7.svg rename to crates/accshift-egui/parity/avatars/7.svg diff --git a/src/lib/mock/avatars/8.svg b/crates/accshift-egui/parity/avatars/8.svg similarity index 100% rename from src/lib/mock/avatars/8.svg rename to crates/accshift-egui/parity/avatars/8.svg diff --git a/src/lib/mock/avatars/9.svg b/crates/accshift-egui/parity/avatars/9.svg similarity index 100% rename from src/lib/mock/avatars/9.svg rename to crates/accshift-egui/parity/avatars/9.svg diff --git a/crates/accshift-egui/parity/avatars/photo.jpg b/crates/accshift-egui/parity/avatars/photo.jpg new file mode 100644 index 00000000..0b8aa57c Binary files /dev/null and b/crates/accshift-egui/parity/avatars/photo.jpg differ diff --git a/crates/accshift-egui/parity/data/demo.json b/crates/accshift-egui/parity/data/demo.json new file mode 100644 index 00000000..0370aa6c --- /dev/null +++ b/crates/accshift-egui/parity/data/demo.json @@ -0,0 +1,1677 @@ +{ + "format": 1, + "scenario": "demo", + "now": 1791000000, + "spec": { + "label": "README recording dataset (frozen)", + "runtimeOs": "windows", + "switchDelayMs": 900, + "steamPath": "C:\\Program Files (x86)\\Steam", + "hasSteamApiKey": true, + "currentSteamAccount": "76561198000000001", + "currentRiotProfile": "riot-1", + "currentRobloxAccount": "1000000001" + }, + "commands": { + "cs2_bridge_get_settings": [ + { + "args": {}, + "result": { + "enabled": false, + "url": "", + "apiKey": "" + } + } + ], + "descriptor_install_file": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_preview_file": [ + { + "args": {}, + "result": { + "source": "C:\\Users\\player\\Downloads\\acme.json", + "descriptor": { + "id": "acme", + "schemaVersion": 1, + "name": "Acme Launcher", + "shortName": "Acme", + "os": { + "windows": { + "roots": { + "files": ["${APPDATA}/Acme Launcher"] + }, + "detect": { + "executableResolves": true + }, + "executable": { + "fileName": "AcmeLauncher.exe", + "candidates": [ + { + "kind": "path", + "template": "${ProgramFiles}/Acme/AcmeLauncher.exe" + } + ] + }, + "identity": { + "source": { + "kind": "synthetic" + }, + "format": { + "charset": "alphanumeric", + "maxLength": 64 + }, + "current": "config" + }, + "state": { + "files": [ + { + "live": "${APPDATA}/Acme Launcher/session.json", + "snapshot": "session.json", + "snapshotMarker": true + } + ] + }, + "close": { + "processes": ["AcmeLauncher.exe"] + }, + "launch": {} + } + } + }, + "fileName": "acme.json", + "replaces": false, + "blocked": "", + "plan": { + "platformId": "acme", + "operation": "switch", + "accountId": "preview", + "applied": false, + "roots": ["C:\\Users\\player\\AppData\\Roaming\\Acme Launcher"], + "steps": [ + { + "action": "close", + "kind": "process", + "target": "AcmeLauncher.exe" + }, + { + "action": "capture", + "kind": "file", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\session.json", + "snapshot": "acme\\current\\session.json" + }, + { + "action": "delete", + "kind": "directory", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\cache", + "note": "skipped: not there" + }, + { + "action": "restore", + "kind": "file", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\session.json", + "snapshot": "acme\\preview\\session.json" + }, + { + "action": "launch", + "kind": "executable", + "target": "C:\\Program Files\\Acme\\AcmeLauncher.exe" + } + ], + "warnings": ["No snapshot for this account yet: add it through setup first."] + }, + "planProblem": "" + } + } + ], + "descriptor_remove": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_select_file": [ + { + "args": {}, + "result": "C:\\Users\\player\\Downloads\\acme.json" + } + ], + "detect_streaming_software": [ + { + "args": {}, + "result": false + } + ], + "get_boot_payload": [ + { + "args": {}, + "result": { + "migration": "skipped", + "runtimeOs": "windows", + "storageSnapshot": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "themeId": "glass-dark", + "enabledPlatforms": ["steam", "riot", "roblox"] + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": { + "76561198000000001": "#8b5cf6", + "76561198000000005": "#f97316", + "76561198000000009": "#ec4899" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198000000001": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198000000002": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "bro's account", + "timestamp": 1791000000000 + }, + "76561198000000003": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "alt", + "timestamp": 1791000000000 + }, + "76561198000000004": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "trading", + "timestamp": 1791000000000 + }, + "76561198000000005": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "faceit", + "timestamp": 1791000000000 + }, + "76561198000000006": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "ranked grind", + "timestamp": 1791000000000 + }, + "76561198000000007": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "chill", + "timestamp": 1791000000000 + }, + "76561198000000008": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "lan party", + "timestamp": 1791000000000 + }, + "76561198000000009": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "streams", + "timestamp": 1791000000000 + }, + "76561198000000010": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "esea", + "timestamp": 1791000000000 + }, + "76561198000000011": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "tournaments", + "timestamp": 1791000000000 + }, + "76561198000000012": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "family", + "timestamp": 1791000000000 + }, + "76561198000000013": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "practice", + "timestamp": 1791000000000 + }, + "76561198000000014": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "smurf 1", + "timestamp": 1791000000000 + }, + "76561198000000015": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "smurf 2", + "timestamp": 1791000000000 + }, + "76561198000000016": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "smurf 3", + "timestamp": 1791000000000 + }, + "76561198000000017": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "smurf 4", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": { + "1000000001": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "timestamp": 1791000000000 + }, + "1000000002": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "timestamp": 1791000000000 + }, + "1000000003": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "timestamp": 1791000000000 + }, + "1000000004": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "timestamp": 1791000000000 + } + }, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + }, + "customThemes": [], + "userPlatforms": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + } + ], + "get_desktop_wallpaper": [ + { + "args": {}, + "result": null + } + ], + "get_runtime_os": [ + { + "args": {}, + "result": "windows" + } + ], + "get_storage_manifest": [ + { + "args": {}, + "result": { + "schemaVersion": 1, + "stores": {} + } + } + ], + "list_custom_themes": [ + { + "args": {}, + "result": [] + } + ], + "load_client_storage_snapshot": [ + { + "args": {}, + "result": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "themeId": "glass-dark", + "enabledPlatforms": ["steam", "riot", "roblox"] + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": { + "76561198000000001": "#8b5cf6", + "76561198000000005": "#f97316", + "76561198000000009": "#ec4899" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198000000001": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198000000002": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "bro's account", + "timestamp": 1791000000000 + }, + "76561198000000003": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "alt", + "timestamp": 1791000000000 + }, + "76561198000000004": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "trading", + "timestamp": 1791000000000 + }, + "76561198000000005": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "faceit", + "timestamp": 1791000000000 + }, + "76561198000000006": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "ranked grind", + "timestamp": 1791000000000 + }, + "76561198000000007": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "chill", + "timestamp": 1791000000000 + }, + "76561198000000008": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "lan party", + "timestamp": 1791000000000 + }, + "76561198000000009": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "streams", + "timestamp": 1791000000000 + }, + "76561198000000010": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "esea", + "timestamp": 1791000000000 + }, + "76561198000000011": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "tournaments", + "timestamp": 1791000000000 + }, + "76561198000000012": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "family", + "timestamp": 1791000000000 + }, + "76561198000000013": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "practice", + "timestamp": 1791000000000 + }, + "76561198000000014": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "smurf 1", + "timestamp": 1791000000000 + }, + "76561198000000015": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "smurf 2", + "timestamp": 1791000000000 + }, + "76561198000000016": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "smurf 3", + "timestamp": 1791000000000 + }, + "76561198000000017": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "smurf 4", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": { + "1000000001": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "timestamp": 1791000000000 + }, + "1000000002": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "timestamp": 1791000000000 + }, + "1000000003": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "timestamp": 1791000000000 + }, + "1000000004": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "timestamp": 1791000000000 + } + }, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + } + } + ], + "migrate_legacy_config": [ + { + "args": {}, + "result": "skipped" + } + ], + "open_descriptors_folder": [ + { + "args": {}, + "result": null + } + ], + "open_logs_folder": [ + { + "args": {}, + "result": null + } + ], + "open_url": [ + { + "args": {}, + "result": null + } + ], + "platform_detect_installed": [ + { + "args": {}, + "result": ["steam", "riot", "roblox"] + } + ], + "platform_dry_run": [ + { + "args": { + "platformId": "steam", + "accountId": "76561198000000001" + }, + "result": { + "platformId": "steam", + "operation": "switch", + "accountId": "76561198000000001", + "applied": false, + "roots": [], + "steps": [], + "warnings": [] + } + } + ], + "platform_get_accounts": [ + { + "args": { + "platformId": "steam" + }, + "result": [ + { + "steam_id": "76561198000000001", + "account_name": "", + "persona_name": "main", + "last_login_at": 1790992800 + }, + { + "steam_id": "76561198000000002", + "account_name": "", + "persona_name": "bro's account", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561198000000003", + "account_name": "", + "persona_name": "alt", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561198000000004", + "account_name": "", + "persona_name": "trading", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561198000000005", + "account_name": "", + "persona_name": "faceit", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198000000006", + "account_name": "", + "persona_name": "ranked grind", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198000000007", + "account_name": "", + "persona_name": "chill", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198000000008", + "account_name": "", + "persona_name": "lan party", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561198000000009", + "account_name": "", + "persona_name": "streams", + "last_login_at": 1790985600 + }, + { + "steam_id": "76561198000000010", + "account_name": "", + "persona_name": "esea", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198000000011", + "account_name": "", + "persona_name": "tournaments", + "last_login_at": 1785816000 + }, + { + "steam_id": "76561198000000012", + "account_name": "", + "persona_name": "family", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198000000013", + "account_name": "", + "persona_name": "practice", + "last_login_at": 1790978400 + }, + { + "steam_id": "76561198000000014", + "account_name": "", + "persona_name": "smurf 1", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561198000000015", + "account_name": "", + "persona_name": "smurf 2", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561198000000016", + "account_name": "", + "persona_name": "smurf 3", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561198000000017", + "account_name": "", + "persona_name": "smurf 4", + "last_login_at": null + } + ] + }, + { + "args": { + "platformId": "riot" + }, + "result": [ + { + "id": "riot-1", + "label": "main", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "smurf", + "snapshot_state": "ready", + "last_used_at": 1790481600000 + }, + { + "id": "riot-3", + "label": "duo account", + "snapshot_state": "ready", + "last_used_at": 1789358400000 + } + ] + }, + { + "args": { + "platformId": "roblox" + }, + "result": [ + { + "userId": "1000000001", + "username": "Builderman_42", + "displayName": "builder", + "lastLoginAt": 1790992800000 + }, + { + "userId": "1000000002", + "username": "obby_speedrun", + "displayName": "obby runs", + "lastLoginAt": 1790740800000 + }, + { + "userId": "1000000003", + "username": "tycoon_alt", + "displayName": "tycoon alt", + "lastLoginAt": 1790049600000 + }, + { + "userId": "1000000004", + "username": "blox_trader", + "displayName": "trading", + "lastLoginAt": 1788840000000 + } + ] + } + ], + "platform_get_current_account": [ + { + "args": { + "platformId": "steam" + }, + "result": "76561198000000001" + }, + { + "args": { + "platformId": "riot" + }, + "result": "riot-1" + }, + { + "args": { + "platformId": "roblox" + }, + "result": "1000000001" + } + ], + "platform_get_path": [ + { + "args": {}, + "result": "C:\\Program Files (x86)\\Steam" + } + ], + "platform_get_startup_snapshot": [ + { + "args": { + "platformId": "steam" + }, + "result": { + "accounts": [ + { + "steam_id": "76561198000000001", + "account_name": "", + "persona_name": "main", + "last_login_at": 1790992800 + }, + { + "steam_id": "76561198000000002", + "account_name": "", + "persona_name": "bro's account", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561198000000003", + "account_name": "", + "persona_name": "alt", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561198000000004", + "account_name": "", + "persona_name": "trading", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561198000000005", + "account_name": "", + "persona_name": "faceit", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198000000006", + "account_name": "", + "persona_name": "ranked grind", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198000000007", + "account_name": "", + "persona_name": "chill", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198000000008", + "account_name": "", + "persona_name": "lan party", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561198000000009", + "account_name": "", + "persona_name": "streams", + "last_login_at": 1790985600 + }, + { + "steam_id": "76561198000000010", + "account_name": "", + "persona_name": "esea", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198000000011", + "account_name": "", + "persona_name": "tournaments", + "last_login_at": 1785816000 + }, + { + "steam_id": "76561198000000012", + "account_name": "", + "persona_name": "family", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198000000013", + "account_name": "", + "persona_name": "practice", + "last_login_at": 1790978400 + }, + { + "steam_id": "76561198000000014", + "account_name": "", + "persona_name": "smurf 1", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561198000000015", + "account_name": "", + "persona_name": "smurf 2", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561198000000016", + "account_name": "", + "persona_name": "smurf 3", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561198000000017", + "account_name": "", + "persona_name": "smurf 4", + "last_login_at": null + } + ], + "currentAccount": "76561198000000001" + } + }, + { + "args": { + "platformId": "riot" + }, + "result": { + "profiles": [ + { + "id": "riot-1", + "label": "main", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "smurf", + "snapshot_state": "ready", + "last_used_at": 1790481600000 + }, + { + "id": "riot-3", + "label": "duo account", + "snapshot_state": "ready", + "last_used_at": 1789358400000 + } + ], + "currentProfile": "riot-1" + } + }, + { + "args": { + "platformId": "roblox" + }, + "result": { + "accounts": [ + { + "userId": "1000000001", + "username": "Builderman_42", + "displayName": "builder", + "lastLoginAt": 1790992800000 + }, + { + "userId": "1000000002", + "username": "obby_speedrun", + "displayName": "obby runs", + "lastLoginAt": 1790740800000 + }, + { + "userId": "1000000003", + "username": "tycoon_alt", + "displayName": "tycoon alt", + "lastLoginAt": 1790049600000 + }, + { + "userId": "1000000004", + "username": "blox_trader", + "displayName": "trading", + "lastLoginAt": 1788840000000 + } + ], + "currentAccount": "1000000001" + } + } + ], + "platform_set_account_label": [ + { + "args": {}, + "result": null + } + ], + "reload_user_platforms": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "roblox_check_sessions": [ + { + "args": {}, + "result": [] + } + ], + "roblox_get_profile_info": [ + { + "args": { + "userId": "1000000001" + }, + "result": { + "avatarUrl": "crates/accshift-egui/parity/avatars/18.svg" + } + }, + { + "args": { + "userId": "1000000002" + }, + "result": { + "avatarUrl": "crates/accshift-egui/parity/avatars/19.svg" + } + }, + { + "args": { + "userId": "1000000003" + }, + "result": { + "avatarUrl": "crates/accshift-egui/parity/avatars/20.svg" + } + }, + { + "args": { + "userId": "1000000004" + }, + "result": { + "avatarUrl": "crates/accshift-egui/parity/avatars/21.svg" + } + } + ], + "save_client_storage_store": [ + { + "args": {}, + "result": null + } + ], + "steam_get_account_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_copyable_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_player_bans": [ + { + "args": { + "steamIds": [ + "76561198000000001", + "76561198000000002", + "76561198000000003", + "76561198000000004", + "76561198000000005", + "76561198000000006", + "76561198000000007", + "76561198000000008", + "76561198000000009", + "76561198000000010", + "76561198000000011", + "76561198000000012", + "76561198000000013", + "76561198000000014", + "76561198000000015", + "76561198000000016", + "76561198000000017" + ] + }, + "result": [ + { + "steam_id": "76561198000000001", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000002", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000003", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000004", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000005", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000006", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000007", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000008", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000009", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000010", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000011", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000012", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000013", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000014", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000015", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000016", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000017", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + } + ] + } + ], + "steam_get_profile_info": [ + { + "args": { + "accountId": "76561198000000001" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000002" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "bro's account", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000003" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "alt", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000004" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "trading", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000005" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "faceit", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000006" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "ranked grind", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000007" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "chill", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000008" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "lan party", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000009" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "streams", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000010" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "esea", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000011" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "tournaments", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000012" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "family", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000013" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "practice", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000014" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "smurf 1", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000015" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "smurf 2", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000016" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "smurf 3", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000017" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "smurf 4", + "vac_banned": false, + "trade_ban_state": "none" + } + } + ], + "steam_get_profile_infos": [ + { + "args": { + "accountIds": [ + "76561198000000001", + "76561198000000002", + "76561198000000003", + "76561198000000004", + "76561198000000005", + "76561198000000006", + "76561198000000007", + "76561198000000008", + "76561198000000009", + "76561198000000010", + "76561198000000011", + "76561198000000012", + "76561198000000013", + "76561198000000014", + "76561198000000015", + "76561198000000016", + "76561198000000017" + ] + }, + "result": { + "76561198000000001": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000002": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "bro's account", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000003": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "alt", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000004": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "trading", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000005": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "faceit", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000006": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "ranked grind", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000007": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "chill", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000008": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "lan party", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000009": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "streams", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000010": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "esea", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000011": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "tournaments", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000012": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "family", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000013": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "practice", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000014": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "smurf 1", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000015": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "smurf 2", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000016": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "smurf 3", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000017": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "smurf 4", + "vac_banned": false, + "trade_ban_state": "none" + } + } + } + ], + "steam_has_api_key": [ + { + "args": {}, + "result": true + } + ], + "steam_open_api_key_page": [ + { + "args": {}, + "result": null + } + ], + "steam_open_userdata": [ + { + "args": {}, + "result": null + } + ], + "telemetry_get_state": [ + { + "args": {}, + "result": { + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": true + } + } + ] + }, + "skipped": { + "pin_lock": "PIN session state", + "pin_unlock": "PIN session state, reads the live settings store", + "platform_switch_account": "changes the current account after switchDelayMs; modelled from spec" + } +} diff --git a/crates/accshift-egui/parity/data/dev.json b/crates/accshift-egui/parity/data/dev.json new file mode 100644 index 00000000..284c08d3 --- /dev/null +++ b/crates/accshift-egui/parity/data/dev.json @@ -0,0 +1,1076 @@ +{ + "format": 1, + "scenario": "dev", + "now": 1791000000, + "spec": { + "label": "agent playground — edge cases, safe to change", + "runtimeOs": "windows", + "switchDelayMs": 900, + "steamPath": "C:\\Program Files (x86)\\Steam", + "hasSteamApiKey": true, + "currentSteamAccount": "76561198100000001", + "currentRiotProfile": "riot-1", + "currentRobloxAccount": "" + }, + "commands": { + "cs2_bridge_get_settings": [ + { + "args": {}, + "result": { + "enabled": false, + "url": "", + "apiKey": "" + } + } + ], + "descriptor_install_file": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_preview_file": [ + { + "args": {}, + "result": { + "source": "C:\\Users\\player\\Downloads\\acme.json", + "descriptor": { + "id": "acme", + "schemaVersion": 1, + "name": "Acme Launcher", + "shortName": "Acme", + "os": { + "windows": { + "roots": { + "files": ["${APPDATA}/Acme Launcher"] + }, + "detect": { + "executableResolves": true + }, + "executable": { + "fileName": "AcmeLauncher.exe", + "candidates": [ + { + "kind": "path", + "template": "${ProgramFiles}/Acme/AcmeLauncher.exe" + } + ] + }, + "identity": { + "source": { + "kind": "synthetic" + }, + "format": { + "charset": "alphanumeric", + "maxLength": 64 + }, + "current": "config" + }, + "state": { + "files": [ + { + "live": "${APPDATA}/Acme Launcher/session.json", + "snapshot": "session.json", + "snapshotMarker": true + } + ] + }, + "close": { + "processes": ["AcmeLauncher.exe"] + }, + "launch": {} + } + } + }, + "fileName": "acme.json", + "replaces": false, + "blocked": "", + "plan": { + "platformId": "acme", + "operation": "switch", + "accountId": "preview", + "applied": false, + "roots": ["C:\\Users\\player\\AppData\\Roaming\\Acme Launcher"], + "steps": [ + { + "action": "close", + "kind": "process", + "target": "AcmeLauncher.exe" + }, + { + "action": "capture", + "kind": "file", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\session.json", + "snapshot": "acme\\current\\session.json" + }, + { + "action": "delete", + "kind": "directory", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\cache", + "note": "skipped: not there" + }, + { + "action": "restore", + "kind": "file", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\session.json", + "snapshot": "acme\\preview\\session.json" + }, + { + "action": "launch", + "kind": "executable", + "target": "C:\\Program Files\\Acme\\AcmeLauncher.exe" + } + ], + "warnings": ["No snapshot for this account yet: add it through setup first."] + }, + "planProblem": "" + } + } + ], + "descriptor_remove": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_select_file": [ + { + "args": {}, + "result": "C:\\Users\\player\\Downloads\\acme.json" + } + ], + "detect_streaming_software": [ + { + "args": {}, + "result": false + } + ], + "get_boot_payload": [ + { + "args": {}, + "result": { + "migration": "skipped", + "runtimeOs": "windows", + "storageSnapshot": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam", "riot"], + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": true + } + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + }, + "client.account-card-colors": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198100000001": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198100000003": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "vac banned", + "timestamp": 1791000000000 + }, + "76561198100000004": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 1791000000000 + }, + "76561198100000005": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 1791000000000 + }, + "76561198100000006": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 1791000000000 + }, + "76561198100000007": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "nested account", + "timestamp": 1791000000000 + }, + "76561198100000008": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + }, + "customThemes": [], + "userPlatforms": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + } + ], + "get_desktop_wallpaper": [ + { + "args": {}, + "result": null + } + ], + "get_runtime_os": [ + { + "args": {}, + "result": "windows" + } + ], + "get_storage_manifest": [ + { + "args": {}, + "result": { + "schemaVersion": 1, + "stores": {} + } + } + ], + "list_custom_themes": [ + { + "args": {}, + "result": [] + } + ], + "load_client_storage_snapshot": [ + { + "args": {}, + "result": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam", "riot"], + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": true + } + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + }, + "client.account-card-colors": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198100000001": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198100000003": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "vac banned", + "timestamp": 1791000000000 + }, + "76561198100000004": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 1791000000000 + }, + "76561198100000005": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 1791000000000 + }, + "76561198100000006": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 1791000000000 + }, + "76561198100000007": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "nested account", + "timestamp": 1791000000000 + }, + "76561198100000008": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + } + } + ], + "migrate_legacy_config": [ + { + "args": {}, + "result": "skipped" + } + ], + "open_descriptors_folder": [ + { + "args": {}, + "result": null + } + ], + "open_logs_folder": [ + { + "args": {}, + "result": null + } + ], + "open_url": [ + { + "args": {}, + "result": null + } + ], + "platform_detect_installed": [ + { + "args": {}, + "result": ["steam", "riot"] + } + ], + "platform_dry_run": [ + { + "args": { + "platformId": "steam", + "accountId": "76561198100000001" + }, + "result": { + "platformId": "steam", + "operation": "switch", + "accountId": "76561198100000001", + "applied": false, + "roots": [], + "steps": [], + "warnings": [] + } + } + ], + "platform_get_accounts": [ + { + "args": { + "platformId": "steam" + }, + "result": [ + { + "steam_id": "76561198100000001", + "account_name": "mainacct", + "persona_name": "main", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198100000002", + "account_name": "no_avatar_here", + "persona_name": "no avatar", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198100000003", + "account_name": "banned_one", + "persona_name": "vac banned", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198100000004", + "account_name": "trade_locked", + "persona_name": "trade banned", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198100000005", + "account_name": "overflow_test_account_name", + "persona_name": "a persona name long enough to overflow every card layout", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198100000006", + "account_name": "never_used", + "persona_name": "never logged in", + "last_login_at": null + }, + { + "steam_id": "76561198100000007", + "account_name": "nested_one", + "persona_name": "nested account", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561198100000008", + "account_name": "nested_two", + "persona_name": "deeply nested", + "last_login_at": 1787198400 + } + ] + }, + { + "args": { + "platformId": "riot" + }, + "result": [ + { + "id": "riot-1", + "label": "ready", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "capturing", + "snapshot_state": "capturing", + "last_used_at": null + }, + { + "id": "riot-3", + "label": "awaiting", + "snapshot_state": "awaiting_capture", + "last_used_at": null + }, + { + "id": "riot-4", + "label": "setup pending", + "snapshot_state": "setup_pending", + "last_used_at": null + } + ] + }, + { + "args": { + "platformId": "roblox" + }, + "result": [] + } + ], + "platform_get_current_account": [ + { + "args": { + "platformId": "steam" + }, + "result": "76561198100000001" + }, + { + "args": { + "platformId": "riot" + }, + "result": "riot-1" + }, + { + "args": { + "platformId": "roblox" + }, + "result": "" + } + ], + "platform_get_path": [ + { + "args": {}, + "result": "C:\\Program Files (x86)\\Steam" + } + ], + "platform_get_startup_snapshot": [ + { + "args": { + "platformId": "steam" + }, + "result": { + "accounts": [ + { + "steam_id": "76561198100000001", + "account_name": "mainacct", + "persona_name": "main", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198100000002", + "account_name": "no_avatar_here", + "persona_name": "no avatar", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198100000003", + "account_name": "banned_one", + "persona_name": "vac banned", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198100000004", + "account_name": "trade_locked", + "persona_name": "trade banned", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198100000005", + "account_name": "overflow_test_account_name", + "persona_name": "a persona name long enough to overflow every card layout", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198100000006", + "account_name": "never_used", + "persona_name": "never logged in", + "last_login_at": null + }, + { + "steam_id": "76561198100000007", + "account_name": "nested_one", + "persona_name": "nested account", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561198100000008", + "account_name": "nested_two", + "persona_name": "deeply nested", + "last_login_at": 1787198400 + } + ], + "currentAccount": "76561198100000001" + } + }, + { + "args": { + "platformId": "riot" + }, + "result": { + "profiles": [ + { + "id": "riot-1", + "label": "ready", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "capturing", + "snapshot_state": "capturing", + "last_used_at": null + }, + { + "id": "riot-3", + "label": "awaiting", + "snapshot_state": "awaiting_capture", + "last_used_at": null + }, + { + "id": "riot-4", + "label": "setup pending", + "snapshot_state": "setup_pending", + "last_used_at": null + } + ], + "currentProfile": "riot-1" + } + }, + { + "args": { + "platformId": "roblox" + }, + "result": { + "accounts": [], + "currentAccount": "" + } + } + ], + "platform_set_account_label": [ + { + "args": {}, + "result": null + } + ], + "reload_user_platforms": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "roblox_check_sessions": [ + { + "args": {}, + "result": [] + } + ], + "roblox_get_profile_info": [], + "save_client_storage_store": [ + { + "args": {}, + "result": null + } + ], + "steam_get_account_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_copyable_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_player_bans": [ + { + "args": { + "steamIds": [ + "76561198100000001", + "76561198100000002", + "76561198100000003", + "76561198100000004", + "76561198100000005", + "76561198100000006", + "76561198100000007", + "76561198100000008" + ] + }, + "result": [ + { + "steam_id": "76561198100000001", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000002", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000003", + "community_banned": false, + "vac_banned": true, + "number_of_vac_bans": 1, + "days_since_last_ban": 412, + "number_of_game_bans": 1, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000004", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "banned" + }, + { + "steam_id": "76561198100000005", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000006", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000007", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000008", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + } + ] + } + ], + "steam_get_profile_info": [ + { + "args": { + "accountId": "76561198100000001" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000002" + }, + "result": { + "avatar_url": null, + "display_name": "no avatar", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000003" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "vac banned", + "vac_banned": true, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000004" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "trade banned", + "vac_banned": false, + "trade_ban_state": "banned" + } + }, + { + "args": { + "accountId": "76561198100000005" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "a persona name long enough to overflow every card layout", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000006" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "never logged in", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000007" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "nested account", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000008" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "deeply nested", + "vac_banned": false, + "trade_ban_state": "none" + } + } + ], + "steam_get_profile_infos": [ + { + "args": { + "accountIds": [ + "76561198100000001", + "76561198100000002", + "76561198100000003", + "76561198100000004", + "76561198100000005", + "76561198100000006", + "76561198100000007", + "76561198100000008" + ] + }, + "result": { + "76561198100000001": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000002": { + "avatar_url": null, + "display_name": "no avatar", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000003": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "vac banned", + "vac_banned": true, + "trade_ban_state": "none" + }, + "76561198100000004": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "trade banned", + "vac_banned": false, + "trade_ban_state": "banned" + }, + "76561198100000005": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "a persona name long enough to overflow every card layout", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000006": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "never logged in", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000007": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "nested account", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000008": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "deeply nested", + "vac_banned": false, + "trade_ban_state": "none" + } + } + } + ], + "steam_has_api_key": [ + { + "args": {}, + "result": true + } + ], + "steam_open_api_key_page": [ + { + "args": {}, + "result": null + } + ], + "steam_open_userdata": [ + { + "args": {}, + "result": null + } + ], + "telemetry_get_state": [ + { + "args": {}, + "result": { + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": true + } + } + ] + }, + "skipped": { + "pin_lock": "PIN session state", + "pin_unlock": "PIN session state, reads the live settings store", + "platform_switch_account": "changes the current account after switchDelayMs; modelled from spec" + } +} diff --git a/crates/accshift-egui/parity/data/empty.json b/crates/accshift-egui/parity/data/empty.json new file mode 100644 index 00000000..0bf6a7e2 --- /dev/null +++ b/crates/accshift-egui/parity/data/empty.json @@ -0,0 +1,469 @@ +{ + "format": 1, + "scenario": "empty", + "now": 1791000000, + "spec": { + "label": "no account anywhere — empty states and first-run UI", + "runtimeOs": "windows", + "switchDelayMs": 900, + "steamPath": "", + "hasSteamApiKey": false, + "currentSteamAccount": "", + "currentRiotProfile": "", + "currentRobloxAccount": "" + }, + "commands": { + "cs2_bridge_get_settings": [ + { + "args": {}, + "result": { + "enabled": false, + "url": "", + "apiKey": "" + } + } + ], + "descriptor_install_file": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_preview_file": [ + { + "args": {}, + "result": { + "source": "C:\\Users\\player\\Downloads\\acme.json", + "descriptor": { + "id": "acme", + "schemaVersion": 1, + "name": "Acme Launcher", + "shortName": "Acme", + "os": { + "windows": { + "roots": { + "files": ["${APPDATA}/Acme Launcher"] + }, + "detect": { + "executableResolves": true + }, + "executable": { + "fileName": "AcmeLauncher.exe", + "candidates": [ + { + "kind": "path", + "template": "${ProgramFiles}/Acme/AcmeLauncher.exe" + } + ] + }, + "identity": { + "source": { + "kind": "synthetic" + }, + "format": { + "charset": "alphanumeric", + "maxLength": 64 + }, + "current": "config" + }, + "state": { + "files": [ + { + "live": "${APPDATA}/Acme Launcher/session.json", + "snapshot": "session.json", + "snapshotMarker": true + } + ] + }, + "close": { + "processes": ["AcmeLauncher.exe"] + }, + "launch": {} + } + } + }, + "fileName": "acme.json", + "replaces": false, + "blocked": "", + "plan": { + "platformId": "acme", + "operation": "switch", + "accountId": "preview", + "applied": false, + "roots": ["C:\\Users\\player\\AppData\\Roaming\\Acme Launcher"], + "steps": [ + { + "action": "close", + "kind": "process", + "target": "AcmeLauncher.exe" + }, + { + "action": "capture", + "kind": "file", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\session.json", + "snapshot": "acme\\current\\session.json" + }, + { + "action": "delete", + "kind": "directory", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\cache", + "note": "skipped: not there" + }, + { + "action": "restore", + "kind": "file", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\session.json", + "snapshot": "acme\\preview\\session.json" + }, + { + "action": "launch", + "kind": "executable", + "target": "C:\\Program Files\\Acme\\AcmeLauncher.exe" + } + ], + "warnings": ["No snapshot for this account yet: add it through setup first."] + }, + "planProblem": "" + } + } + ], + "descriptor_remove": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_select_file": [ + { + "args": {}, + "result": "C:\\Users\\player\\Downloads\\acme.json" + } + ], + "detect_streaming_software": [ + { + "args": {}, + "result": false + } + ], + "get_boot_payload": [ + { + "args": {}, + "result": { + "migration": "skipped", + "runtimeOs": "windows", + "storageSnapshot": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam"] + }, + "client.folders": { + "version": 1, + "folders": [], + "itemOrder": {} + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": {}, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": {}, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + }, + "customThemes": [], + "userPlatforms": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + } + ], + "get_desktop_wallpaper": [ + { + "args": {}, + "result": null + } + ], + "get_runtime_os": [ + { + "args": {}, + "result": "windows" + } + ], + "get_storage_manifest": [ + { + "args": {}, + "result": { + "schemaVersion": 1, + "stores": {} + } + } + ], + "list_custom_themes": [ + { + "args": {}, + "result": [] + } + ], + "load_client_storage_snapshot": [ + { + "args": {}, + "result": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam"] + }, + "client.folders": { + "version": 1, + "folders": [], + "itemOrder": {} + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": {}, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": {}, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + } + } + ], + "migrate_legacy_config": [ + { + "args": {}, + "result": "skipped" + } + ], + "open_descriptors_folder": [ + { + "args": {}, + "result": null + } + ], + "open_logs_folder": [ + { + "args": {}, + "result": null + } + ], + "open_url": [ + { + "args": {}, + "result": null + } + ], + "platform_detect_installed": [ + { + "args": {}, + "result": ["steam"] + } + ], + "platform_dry_run": [], + "platform_get_accounts": [ + { + "args": { + "platformId": "steam" + }, + "result": [] + }, + { + "args": { + "platformId": "riot" + }, + "result": [] + }, + { + "args": { + "platformId": "roblox" + }, + "result": [] + } + ], + "platform_get_current_account": [ + { + "args": { + "platformId": "steam" + }, + "result": "" + }, + { + "args": { + "platformId": "riot" + }, + "result": "" + }, + { + "args": { + "platformId": "roblox" + }, + "result": "" + } + ], + "platform_get_path": [ + { + "args": {}, + "result": "" + } + ], + "platform_get_startup_snapshot": [ + { + "args": { + "platformId": "steam" + }, + "result": { + "accounts": [], + "currentAccount": "" + } + }, + { + "args": { + "platformId": "riot" + }, + "result": { + "profiles": [], + "currentProfile": "" + } + }, + { + "args": { + "platformId": "roblox" + }, + "result": { + "accounts": [], + "currentAccount": "" + } + } + ], + "platform_set_account_label": [ + { + "args": {}, + "result": null + } + ], + "reload_user_platforms": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "roblox_check_sessions": [ + { + "args": {}, + "result": [] + } + ], + "roblox_get_profile_info": [], + "save_client_storage_store": [ + { + "args": {}, + "result": null + } + ], + "steam_get_account_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_copyable_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_player_bans": [ + { + "args": { + "steamIds": [] + }, + "result": [] + } + ], + "steam_get_profile_info": [], + "steam_get_profile_infos": [ + { + "args": { + "accountIds": [] + }, + "result": {} + } + ], + "steam_has_api_key": [ + { + "args": {}, + "result": false + } + ], + "steam_open_api_key_page": [ + { + "args": {}, + "result": null + } + ], + "steam_open_userdata": [ + { + "args": {}, + "result": null + } + ], + "telemetry_get_state": [ + { + "args": {}, + "result": { + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": true + } + } + ] + }, + "skipped": { + "pin_lock": "PIN session state", + "pin_unlock": "PIN session state, reads the live settings store", + "platform_switch_account": "changes the current account after switchDelayMs; modelled from spec" + } +} diff --git a/crates/accshift-egui/parity/data/huge.json b/crates/accshift-egui/parity/data/huge.json new file mode 100644 index 00000000..7ab48b70 --- /dev/null +++ b/crates/accshift-egui/parity/data/huge.json @@ -0,0 +1,10494 @@ +{ + "format": 1, + "scenario": "huge", + "now": 1791000000, + "spec": { + "label": "200 accounts — scroll, grid padding, virtualization", + "runtimeOs": "windows", + "switchDelayMs": 900, + "steamPath": "C:\\Program Files (x86)\\Steam", + "hasSteamApiKey": true, + "currentSteamAccount": "76561199000000000", + "currentRiotProfile": "", + "currentRobloxAccount": "" + }, + "commands": { + "cs2_bridge_get_settings": [ + { + "args": {}, + "result": { + "enabled": false, + "url": "", + "apiKey": "" + } + } + ], + "descriptor_install_file": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_preview_file": [ + { + "args": {}, + "result": { + "source": "C:\\Users\\player\\Downloads\\acme.json", + "descriptor": { + "id": "acme", + "schemaVersion": 1, + "name": "Acme Launcher", + "shortName": "Acme", + "os": { + "windows": { + "roots": { + "files": ["${APPDATA}/Acme Launcher"] + }, + "detect": { + "executableResolves": true + }, + "executable": { + "fileName": "AcmeLauncher.exe", + "candidates": [ + { + "kind": "path", + "template": "${ProgramFiles}/Acme/AcmeLauncher.exe" + } + ] + }, + "identity": { + "source": { + "kind": "synthetic" + }, + "format": { + "charset": "alphanumeric", + "maxLength": 64 + }, + "current": "config" + }, + "state": { + "files": [ + { + "live": "${APPDATA}/Acme Launcher/session.json", + "snapshot": "session.json", + "snapshotMarker": true + } + ] + }, + "close": { + "processes": ["AcmeLauncher.exe"] + }, + "launch": {} + } + } + }, + "fileName": "acme.json", + "replaces": false, + "blocked": "", + "plan": { + "platformId": "acme", + "operation": "switch", + "accountId": "preview", + "applied": false, + "roots": ["C:\\Users\\player\\AppData\\Roaming\\Acme Launcher"], + "steps": [ + { + "action": "close", + "kind": "process", + "target": "AcmeLauncher.exe" + }, + { + "action": "capture", + "kind": "file", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\session.json", + "snapshot": "acme\\current\\session.json" + }, + { + "action": "delete", + "kind": "directory", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\cache", + "note": "skipped: not there" + }, + { + "action": "restore", + "kind": "file", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\session.json", + "snapshot": "acme\\preview\\session.json" + }, + { + "action": "launch", + "kind": "executable", + "target": "C:\\Program Files\\Acme\\AcmeLauncher.exe" + } + ], + "warnings": ["No snapshot for this account yet: add it through setup first."] + }, + "planProblem": "" + } + } + ], + "descriptor_remove": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_select_file": [ + { + "args": {}, + "result": "C:\\Users\\player\\Downloads\\acme.json" + } + ], + "detect_streaming_software": [ + { + "args": {}, + "result": false + } + ], + "get_boot_payload": [ + { + "args": {}, + "result": { + "migration": "skipped", + "runtimeOs": "windows", + "storageSnapshot": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam"] + }, + "client.folders": { + "version": 1, + "folders": [], + "itemOrder": {} + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": {}, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561199000000000": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 1", + "timestamp": 1791000000000 + }, + "76561199000000001": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 2", + "timestamp": 1791000000000 + }, + "76561199000000002": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 3", + "timestamp": 1791000000000 + }, + "76561199000000003": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 4", + "timestamp": 1791000000000 + }, + "76561199000000004": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 5", + "timestamp": 1791000000000 + }, + "76561199000000005": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 6", + "timestamp": 1791000000000 + }, + "76561199000000006": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 7", + "timestamp": 1791000000000 + }, + "76561199000000007": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 8", + "timestamp": 1791000000000 + }, + "76561199000000008": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 9", + "timestamp": 1791000000000 + }, + "76561199000000009": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 10", + "timestamp": 1791000000000 + }, + "76561199000000010": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 11", + "timestamp": 1791000000000 + }, + "76561199000000011": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 12", + "timestamp": 1791000000000 + }, + "76561199000000012": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 13", + "timestamp": 1791000000000 + }, + "76561199000000013": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 14", + "timestamp": 1791000000000 + }, + "76561199000000014": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 15", + "timestamp": 1791000000000 + }, + "76561199000000015": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 16", + "timestamp": 1791000000000 + }, + "76561199000000016": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 17", + "timestamp": 1791000000000 + }, + "76561199000000017": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 18", + "timestamp": 1791000000000 + }, + "76561199000000018": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 19", + "timestamp": 1791000000000 + }, + "76561199000000019": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 20", + "timestamp": 1791000000000 + }, + "76561199000000020": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 21", + "timestamp": 1791000000000 + }, + "76561199000000021": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 22", + "timestamp": 1791000000000 + }, + "76561199000000022": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 23", + "timestamp": 1791000000000 + }, + "76561199000000023": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 24", + "timestamp": 1791000000000 + }, + "76561199000000024": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 25", + "timestamp": 1791000000000 + }, + "76561199000000025": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 26", + "timestamp": 1791000000000 + }, + "76561199000000026": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 27", + "timestamp": 1791000000000 + }, + "76561199000000027": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 28", + "timestamp": 1791000000000 + }, + "76561199000000028": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 29", + "timestamp": 1791000000000 + }, + "76561199000000029": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 30", + "timestamp": 1791000000000 + }, + "76561199000000030": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 31", + "timestamp": 1791000000000 + }, + "76561199000000031": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 32", + "timestamp": 1791000000000 + }, + "76561199000000032": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 33", + "timestamp": 1791000000000 + }, + "76561199000000033": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 34", + "timestamp": 1791000000000 + }, + "76561199000000034": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 35", + "timestamp": 1791000000000 + }, + "76561199000000035": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 36", + "timestamp": 1791000000000 + }, + "76561199000000036": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 37", + "timestamp": 1791000000000 + }, + "76561199000000037": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 38", + "timestamp": 1791000000000 + }, + "76561199000000038": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 39", + "timestamp": 1791000000000 + }, + "76561199000000039": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 40", + "timestamp": 1791000000000 + }, + "76561199000000040": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 41", + "timestamp": 1791000000000 + }, + "76561199000000041": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 42", + "timestamp": 1791000000000 + }, + "76561199000000042": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 43", + "timestamp": 1791000000000 + }, + "76561199000000043": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 44", + "timestamp": 1791000000000 + }, + "76561199000000044": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 45", + "timestamp": 1791000000000 + }, + "76561199000000045": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 46", + "timestamp": 1791000000000 + }, + "76561199000000046": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 47", + "timestamp": 1791000000000 + }, + "76561199000000047": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 48", + "timestamp": 1791000000000 + }, + "76561199000000048": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 49", + "timestamp": 1791000000000 + }, + "76561199000000049": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 50", + "timestamp": 1791000000000 + }, + "76561199000000050": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 51", + "timestamp": 1791000000000 + }, + "76561199000000051": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 52", + "timestamp": 1791000000000 + }, + "76561199000000052": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 53", + "timestamp": 1791000000000 + }, + "76561199000000053": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 54", + "timestamp": 1791000000000 + }, + "76561199000000054": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 55", + "timestamp": 1791000000000 + }, + "76561199000000055": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 56", + "timestamp": 1791000000000 + }, + "76561199000000056": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 57", + "timestamp": 1791000000000 + }, + "76561199000000057": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 58", + "timestamp": 1791000000000 + }, + "76561199000000058": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 59", + "timestamp": 1791000000000 + }, + "76561199000000059": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 60", + "timestamp": 1791000000000 + }, + "76561199000000060": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 61", + "timestamp": 1791000000000 + }, + "76561199000000061": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 62", + "timestamp": 1791000000000 + }, + "76561199000000062": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 63", + "timestamp": 1791000000000 + }, + "76561199000000063": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 64", + "timestamp": 1791000000000 + }, + "76561199000000064": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 65", + "timestamp": 1791000000000 + }, + "76561199000000065": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 66", + "timestamp": 1791000000000 + }, + "76561199000000066": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 67", + "timestamp": 1791000000000 + }, + "76561199000000067": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 68", + "timestamp": 1791000000000 + }, + "76561199000000068": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 69", + "timestamp": 1791000000000 + }, + "76561199000000069": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 70", + "timestamp": 1791000000000 + }, + "76561199000000070": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 71", + "timestamp": 1791000000000 + }, + "76561199000000071": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 72", + "timestamp": 1791000000000 + }, + "76561199000000072": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 73", + "timestamp": 1791000000000 + }, + "76561199000000073": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 74", + "timestamp": 1791000000000 + }, + "76561199000000074": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 75", + "timestamp": 1791000000000 + }, + "76561199000000075": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 76", + "timestamp": 1791000000000 + }, + "76561199000000076": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 77", + "timestamp": 1791000000000 + }, + "76561199000000077": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 78", + "timestamp": 1791000000000 + }, + "76561199000000078": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 79", + "timestamp": 1791000000000 + }, + "76561199000000079": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 80", + "timestamp": 1791000000000 + }, + "76561199000000080": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 81", + "timestamp": 1791000000000 + }, + "76561199000000081": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 82", + "timestamp": 1791000000000 + }, + "76561199000000082": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 83", + "timestamp": 1791000000000 + }, + "76561199000000083": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 84", + "timestamp": 1791000000000 + }, + "76561199000000084": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 85", + "timestamp": 1791000000000 + }, + "76561199000000085": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 86", + "timestamp": 1791000000000 + }, + "76561199000000086": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 87", + "timestamp": 1791000000000 + }, + "76561199000000087": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 88", + "timestamp": 1791000000000 + }, + "76561199000000088": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 89", + "timestamp": 1791000000000 + }, + "76561199000000089": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 90", + "timestamp": 1791000000000 + }, + "76561199000000090": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 91", + "timestamp": 1791000000000 + }, + "76561199000000091": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 92", + "timestamp": 1791000000000 + }, + "76561199000000092": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 93", + "timestamp": 1791000000000 + }, + "76561199000000093": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 94", + "timestamp": 1791000000000 + }, + "76561199000000094": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 95", + "timestamp": 1791000000000 + }, + "76561199000000095": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 96", + "timestamp": 1791000000000 + }, + "76561199000000096": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 97", + "timestamp": 1791000000000 + }, + "76561199000000097": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 98", + "timestamp": 1791000000000 + }, + "76561199000000098": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 99", + "timestamp": 1791000000000 + }, + "76561199000000099": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 100", + "timestamp": 1791000000000 + }, + "76561199000000100": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 101", + "timestamp": 1791000000000 + }, + "76561199000000101": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 102", + "timestamp": 1791000000000 + }, + "76561199000000102": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 103", + "timestamp": 1791000000000 + }, + "76561199000000103": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 104", + "timestamp": 1791000000000 + }, + "76561199000000104": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 105", + "timestamp": 1791000000000 + }, + "76561199000000105": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 106", + "timestamp": 1791000000000 + }, + "76561199000000106": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 107", + "timestamp": 1791000000000 + }, + "76561199000000107": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 108", + "timestamp": 1791000000000 + }, + "76561199000000108": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 109", + "timestamp": 1791000000000 + }, + "76561199000000109": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 110", + "timestamp": 1791000000000 + }, + "76561199000000110": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 111", + "timestamp": 1791000000000 + }, + "76561199000000111": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 112", + "timestamp": 1791000000000 + }, + "76561199000000112": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 113", + "timestamp": 1791000000000 + }, + "76561199000000113": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 114", + "timestamp": 1791000000000 + }, + "76561199000000114": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 115", + "timestamp": 1791000000000 + }, + "76561199000000115": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 116", + "timestamp": 1791000000000 + }, + "76561199000000116": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 117", + "timestamp": 1791000000000 + }, + "76561199000000117": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 118", + "timestamp": 1791000000000 + }, + "76561199000000118": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 119", + "timestamp": 1791000000000 + }, + "76561199000000119": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 120", + "timestamp": 1791000000000 + }, + "76561199000000120": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 121", + "timestamp": 1791000000000 + }, + "76561199000000121": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 122", + "timestamp": 1791000000000 + }, + "76561199000000122": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 123", + "timestamp": 1791000000000 + }, + "76561199000000123": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 124", + "timestamp": 1791000000000 + }, + "76561199000000124": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 125", + "timestamp": 1791000000000 + }, + "76561199000000125": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 126", + "timestamp": 1791000000000 + }, + "76561199000000126": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 127", + "timestamp": 1791000000000 + }, + "76561199000000127": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 128", + "timestamp": 1791000000000 + }, + "76561199000000128": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 129", + "timestamp": 1791000000000 + }, + "76561199000000129": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 130", + "timestamp": 1791000000000 + }, + "76561199000000130": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 131", + "timestamp": 1791000000000 + }, + "76561199000000131": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 132", + "timestamp": 1791000000000 + }, + "76561199000000132": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 133", + "timestamp": 1791000000000 + }, + "76561199000000133": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 134", + "timestamp": 1791000000000 + }, + "76561199000000134": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 135", + "timestamp": 1791000000000 + }, + "76561199000000135": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 136", + "timestamp": 1791000000000 + }, + "76561199000000136": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 137", + "timestamp": 1791000000000 + }, + "76561199000000137": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 138", + "timestamp": 1791000000000 + }, + "76561199000000138": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 139", + "timestamp": 1791000000000 + }, + "76561199000000139": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 140", + "timestamp": 1791000000000 + }, + "76561199000000140": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 141", + "timestamp": 1791000000000 + }, + "76561199000000141": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 142", + "timestamp": 1791000000000 + }, + "76561199000000142": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 143", + "timestamp": 1791000000000 + }, + "76561199000000143": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 144", + "timestamp": 1791000000000 + }, + "76561199000000144": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 145", + "timestamp": 1791000000000 + }, + "76561199000000145": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 146", + "timestamp": 1791000000000 + }, + "76561199000000146": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 147", + "timestamp": 1791000000000 + }, + "76561199000000147": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 148", + "timestamp": 1791000000000 + }, + "76561199000000148": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 149", + "timestamp": 1791000000000 + }, + "76561199000000149": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 150", + "timestamp": 1791000000000 + }, + "76561199000000150": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 151", + "timestamp": 1791000000000 + }, + "76561199000000151": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 152", + "timestamp": 1791000000000 + }, + "76561199000000152": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 153", + "timestamp": 1791000000000 + }, + "76561199000000153": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 154", + "timestamp": 1791000000000 + }, + "76561199000000154": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 155", + "timestamp": 1791000000000 + }, + "76561199000000155": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 156", + "timestamp": 1791000000000 + }, + "76561199000000156": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 157", + "timestamp": 1791000000000 + }, + "76561199000000157": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 158", + "timestamp": 1791000000000 + }, + "76561199000000158": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 159", + "timestamp": 1791000000000 + }, + "76561199000000159": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 160", + "timestamp": 1791000000000 + }, + "76561199000000160": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 161", + "timestamp": 1791000000000 + }, + "76561199000000161": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 162", + "timestamp": 1791000000000 + }, + "76561199000000162": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 163", + "timestamp": 1791000000000 + }, + "76561199000000163": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 164", + "timestamp": 1791000000000 + }, + "76561199000000164": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 165", + "timestamp": 1791000000000 + }, + "76561199000000165": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 166", + "timestamp": 1791000000000 + }, + "76561199000000166": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 167", + "timestamp": 1791000000000 + }, + "76561199000000167": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 168", + "timestamp": 1791000000000 + }, + "76561199000000168": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 169", + "timestamp": 1791000000000 + }, + "76561199000000169": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 170", + "timestamp": 1791000000000 + }, + "76561199000000170": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 171", + "timestamp": 1791000000000 + }, + "76561199000000171": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 172", + "timestamp": 1791000000000 + }, + "76561199000000172": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 173", + "timestamp": 1791000000000 + }, + "76561199000000173": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 174", + "timestamp": 1791000000000 + }, + "76561199000000174": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 175", + "timestamp": 1791000000000 + }, + "76561199000000175": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 176", + "timestamp": 1791000000000 + }, + "76561199000000176": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 177", + "timestamp": 1791000000000 + }, + "76561199000000177": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 178", + "timestamp": 1791000000000 + }, + "76561199000000178": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 179", + "timestamp": 1791000000000 + }, + "76561199000000179": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 180", + "timestamp": 1791000000000 + }, + "76561199000000180": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 181", + "timestamp": 1791000000000 + }, + "76561199000000181": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 182", + "timestamp": 1791000000000 + }, + "76561199000000182": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 183", + "timestamp": 1791000000000 + }, + "76561199000000183": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 184", + "timestamp": 1791000000000 + }, + "76561199000000184": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 185", + "timestamp": 1791000000000 + }, + "76561199000000185": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 186", + "timestamp": 1791000000000 + }, + "76561199000000186": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 187", + "timestamp": 1791000000000 + }, + "76561199000000187": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 188", + "timestamp": 1791000000000 + }, + "76561199000000188": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 189", + "timestamp": 1791000000000 + }, + "76561199000000189": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 190", + "timestamp": 1791000000000 + }, + "76561199000000190": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 191", + "timestamp": 1791000000000 + }, + "76561199000000191": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 192", + "timestamp": 1791000000000 + }, + "76561199000000192": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 193", + "timestamp": 1791000000000 + }, + "76561199000000193": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 194", + "timestamp": 1791000000000 + }, + "76561199000000194": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 195", + "timestamp": 1791000000000 + }, + "76561199000000195": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 196", + "timestamp": 1791000000000 + }, + "76561199000000196": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 197", + "timestamp": 1791000000000 + }, + "76561199000000197": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 198", + "timestamp": 1791000000000 + }, + "76561199000000198": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 199", + "timestamp": 1791000000000 + }, + "76561199000000199": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 200", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + }, + "customThemes": [], + "userPlatforms": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + } + ], + "get_desktop_wallpaper": [ + { + "args": {}, + "result": null + } + ], + "get_runtime_os": [ + { + "args": {}, + "result": "windows" + } + ], + "get_storage_manifest": [ + { + "args": {}, + "result": { + "schemaVersion": 1, + "stores": {} + } + } + ], + "list_custom_themes": [ + { + "args": {}, + "result": [] + } + ], + "load_client_storage_snapshot": [ + { + "args": {}, + "result": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam"] + }, + "client.folders": { + "version": 1, + "folders": [], + "itemOrder": {} + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": {}, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561199000000000": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 1", + "timestamp": 1791000000000 + }, + "76561199000000001": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 2", + "timestamp": 1791000000000 + }, + "76561199000000002": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 3", + "timestamp": 1791000000000 + }, + "76561199000000003": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 4", + "timestamp": 1791000000000 + }, + "76561199000000004": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 5", + "timestamp": 1791000000000 + }, + "76561199000000005": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 6", + "timestamp": 1791000000000 + }, + "76561199000000006": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 7", + "timestamp": 1791000000000 + }, + "76561199000000007": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 8", + "timestamp": 1791000000000 + }, + "76561199000000008": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 9", + "timestamp": 1791000000000 + }, + "76561199000000009": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 10", + "timestamp": 1791000000000 + }, + "76561199000000010": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 11", + "timestamp": 1791000000000 + }, + "76561199000000011": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 12", + "timestamp": 1791000000000 + }, + "76561199000000012": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 13", + "timestamp": 1791000000000 + }, + "76561199000000013": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 14", + "timestamp": 1791000000000 + }, + "76561199000000014": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 15", + "timestamp": 1791000000000 + }, + "76561199000000015": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 16", + "timestamp": 1791000000000 + }, + "76561199000000016": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 17", + "timestamp": 1791000000000 + }, + "76561199000000017": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 18", + "timestamp": 1791000000000 + }, + "76561199000000018": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 19", + "timestamp": 1791000000000 + }, + "76561199000000019": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 20", + "timestamp": 1791000000000 + }, + "76561199000000020": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 21", + "timestamp": 1791000000000 + }, + "76561199000000021": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 22", + "timestamp": 1791000000000 + }, + "76561199000000022": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 23", + "timestamp": 1791000000000 + }, + "76561199000000023": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 24", + "timestamp": 1791000000000 + }, + "76561199000000024": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 25", + "timestamp": 1791000000000 + }, + "76561199000000025": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 26", + "timestamp": 1791000000000 + }, + "76561199000000026": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 27", + "timestamp": 1791000000000 + }, + "76561199000000027": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 28", + "timestamp": 1791000000000 + }, + "76561199000000028": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 29", + "timestamp": 1791000000000 + }, + "76561199000000029": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 30", + "timestamp": 1791000000000 + }, + "76561199000000030": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 31", + "timestamp": 1791000000000 + }, + "76561199000000031": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 32", + "timestamp": 1791000000000 + }, + "76561199000000032": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 33", + "timestamp": 1791000000000 + }, + "76561199000000033": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 34", + "timestamp": 1791000000000 + }, + "76561199000000034": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 35", + "timestamp": 1791000000000 + }, + "76561199000000035": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 36", + "timestamp": 1791000000000 + }, + "76561199000000036": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 37", + "timestamp": 1791000000000 + }, + "76561199000000037": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 38", + "timestamp": 1791000000000 + }, + "76561199000000038": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 39", + "timestamp": 1791000000000 + }, + "76561199000000039": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 40", + "timestamp": 1791000000000 + }, + "76561199000000040": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 41", + "timestamp": 1791000000000 + }, + "76561199000000041": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 42", + "timestamp": 1791000000000 + }, + "76561199000000042": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 43", + "timestamp": 1791000000000 + }, + "76561199000000043": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 44", + "timestamp": 1791000000000 + }, + "76561199000000044": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 45", + "timestamp": 1791000000000 + }, + "76561199000000045": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 46", + "timestamp": 1791000000000 + }, + "76561199000000046": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 47", + "timestamp": 1791000000000 + }, + "76561199000000047": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 48", + "timestamp": 1791000000000 + }, + "76561199000000048": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 49", + "timestamp": 1791000000000 + }, + "76561199000000049": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 50", + "timestamp": 1791000000000 + }, + "76561199000000050": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 51", + "timestamp": 1791000000000 + }, + "76561199000000051": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 52", + "timestamp": 1791000000000 + }, + "76561199000000052": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 53", + "timestamp": 1791000000000 + }, + "76561199000000053": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 54", + "timestamp": 1791000000000 + }, + "76561199000000054": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 55", + "timestamp": 1791000000000 + }, + "76561199000000055": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 56", + "timestamp": 1791000000000 + }, + "76561199000000056": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 57", + "timestamp": 1791000000000 + }, + "76561199000000057": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 58", + "timestamp": 1791000000000 + }, + "76561199000000058": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 59", + "timestamp": 1791000000000 + }, + "76561199000000059": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 60", + "timestamp": 1791000000000 + }, + "76561199000000060": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 61", + "timestamp": 1791000000000 + }, + "76561199000000061": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 62", + "timestamp": 1791000000000 + }, + "76561199000000062": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 63", + "timestamp": 1791000000000 + }, + "76561199000000063": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 64", + "timestamp": 1791000000000 + }, + "76561199000000064": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 65", + "timestamp": 1791000000000 + }, + "76561199000000065": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 66", + "timestamp": 1791000000000 + }, + "76561199000000066": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 67", + "timestamp": 1791000000000 + }, + "76561199000000067": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 68", + "timestamp": 1791000000000 + }, + "76561199000000068": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 69", + "timestamp": 1791000000000 + }, + "76561199000000069": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 70", + "timestamp": 1791000000000 + }, + "76561199000000070": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 71", + "timestamp": 1791000000000 + }, + "76561199000000071": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 72", + "timestamp": 1791000000000 + }, + "76561199000000072": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 73", + "timestamp": 1791000000000 + }, + "76561199000000073": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 74", + "timestamp": 1791000000000 + }, + "76561199000000074": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 75", + "timestamp": 1791000000000 + }, + "76561199000000075": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 76", + "timestamp": 1791000000000 + }, + "76561199000000076": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 77", + "timestamp": 1791000000000 + }, + "76561199000000077": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 78", + "timestamp": 1791000000000 + }, + "76561199000000078": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 79", + "timestamp": 1791000000000 + }, + "76561199000000079": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 80", + "timestamp": 1791000000000 + }, + "76561199000000080": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 81", + "timestamp": 1791000000000 + }, + "76561199000000081": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 82", + "timestamp": 1791000000000 + }, + "76561199000000082": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 83", + "timestamp": 1791000000000 + }, + "76561199000000083": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 84", + "timestamp": 1791000000000 + }, + "76561199000000084": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 85", + "timestamp": 1791000000000 + }, + "76561199000000085": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 86", + "timestamp": 1791000000000 + }, + "76561199000000086": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 87", + "timestamp": 1791000000000 + }, + "76561199000000087": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 88", + "timestamp": 1791000000000 + }, + "76561199000000088": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 89", + "timestamp": 1791000000000 + }, + "76561199000000089": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 90", + "timestamp": 1791000000000 + }, + "76561199000000090": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 91", + "timestamp": 1791000000000 + }, + "76561199000000091": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 92", + "timestamp": 1791000000000 + }, + "76561199000000092": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 93", + "timestamp": 1791000000000 + }, + "76561199000000093": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 94", + "timestamp": 1791000000000 + }, + "76561199000000094": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 95", + "timestamp": 1791000000000 + }, + "76561199000000095": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 96", + "timestamp": 1791000000000 + }, + "76561199000000096": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 97", + "timestamp": 1791000000000 + }, + "76561199000000097": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 98", + "timestamp": 1791000000000 + }, + "76561199000000098": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 99", + "timestamp": 1791000000000 + }, + "76561199000000099": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 100", + "timestamp": 1791000000000 + }, + "76561199000000100": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 101", + "timestamp": 1791000000000 + }, + "76561199000000101": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 102", + "timestamp": 1791000000000 + }, + "76561199000000102": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 103", + "timestamp": 1791000000000 + }, + "76561199000000103": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 104", + "timestamp": 1791000000000 + }, + "76561199000000104": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 105", + "timestamp": 1791000000000 + }, + "76561199000000105": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 106", + "timestamp": 1791000000000 + }, + "76561199000000106": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 107", + "timestamp": 1791000000000 + }, + "76561199000000107": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 108", + "timestamp": 1791000000000 + }, + "76561199000000108": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 109", + "timestamp": 1791000000000 + }, + "76561199000000109": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 110", + "timestamp": 1791000000000 + }, + "76561199000000110": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 111", + "timestamp": 1791000000000 + }, + "76561199000000111": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 112", + "timestamp": 1791000000000 + }, + "76561199000000112": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 113", + "timestamp": 1791000000000 + }, + "76561199000000113": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 114", + "timestamp": 1791000000000 + }, + "76561199000000114": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 115", + "timestamp": 1791000000000 + }, + "76561199000000115": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 116", + "timestamp": 1791000000000 + }, + "76561199000000116": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 117", + "timestamp": 1791000000000 + }, + "76561199000000117": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 118", + "timestamp": 1791000000000 + }, + "76561199000000118": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 119", + "timestamp": 1791000000000 + }, + "76561199000000119": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 120", + "timestamp": 1791000000000 + }, + "76561199000000120": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 121", + "timestamp": 1791000000000 + }, + "76561199000000121": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 122", + "timestamp": 1791000000000 + }, + "76561199000000122": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 123", + "timestamp": 1791000000000 + }, + "76561199000000123": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 124", + "timestamp": 1791000000000 + }, + "76561199000000124": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 125", + "timestamp": 1791000000000 + }, + "76561199000000125": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 126", + "timestamp": 1791000000000 + }, + "76561199000000126": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 127", + "timestamp": 1791000000000 + }, + "76561199000000127": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 128", + "timestamp": 1791000000000 + }, + "76561199000000128": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 129", + "timestamp": 1791000000000 + }, + "76561199000000129": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 130", + "timestamp": 1791000000000 + }, + "76561199000000130": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 131", + "timestamp": 1791000000000 + }, + "76561199000000131": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 132", + "timestamp": 1791000000000 + }, + "76561199000000132": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 133", + "timestamp": 1791000000000 + }, + "76561199000000133": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 134", + "timestamp": 1791000000000 + }, + "76561199000000134": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 135", + "timestamp": 1791000000000 + }, + "76561199000000135": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 136", + "timestamp": 1791000000000 + }, + "76561199000000136": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 137", + "timestamp": 1791000000000 + }, + "76561199000000137": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 138", + "timestamp": 1791000000000 + }, + "76561199000000138": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 139", + "timestamp": 1791000000000 + }, + "76561199000000139": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 140", + "timestamp": 1791000000000 + }, + "76561199000000140": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 141", + "timestamp": 1791000000000 + }, + "76561199000000141": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 142", + "timestamp": 1791000000000 + }, + "76561199000000142": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 143", + "timestamp": 1791000000000 + }, + "76561199000000143": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 144", + "timestamp": 1791000000000 + }, + "76561199000000144": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 145", + "timestamp": 1791000000000 + }, + "76561199000000145": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 146", + "timestamp": 1791000000000 + }, + "76561199000000146": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 147", + "timestamp": 1791000000000 + }, + "76561199000000147": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 148", + "timestamp": 1791000000000 + }, + "76561199000000148": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 149", + "timestamp": 1791000000000 + }, + "76561199000000149": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 150", + "timestamp": 1791000000000 + }, + "76561199000000150": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 151", + "timestamp": 1791000000000 + }, + "76561199000000151": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 152", + "timestamp": 1791000000000 + }, + "76561199000000152": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 153", + "timestamp": 1791000000000 + }, + "76561199000000153": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 154", + "timestamp": 1791000000000 + }, + "76561199000000154": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 155", + "timestamp": 1791000000000 + }, + "76561199000000155": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 156", + "timestamp": 1791000000000 + }, + "76561199000000156": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 157", + "timestamp": 1791000000000 + }, + "76561199000000157": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 158", + "timestamp": 1791000000000 + }, + "76561199000000158": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 159", + "timestamp": 1791000000000 + }, + "76561199000000159": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 160", + "timestamp": 1791000000000 + }, + "76561199000000160": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 161", + "timestamp": 1791000000000 + }, + "76561199000000161": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 162", + "timestamp": 1791000000000 + }, + "76561199000000162": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 163", + "timestamp": 1791000000000 + }, + "76561199000000163": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 164", + "timestamp": 1791000000000 + }, + "76561199000000164": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 165", + "timestamp": 1791000000000 + }, + "76561199000000165": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 166", + "timestamp": 1791000000000 + }, + "76561199000000166": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 167", + "timestamp": 1791000000000 + }, + "76561199000000167": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 168", + "timestamp": 1791000000000 + }, + "76561199000000168": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 169", + "timestamp": 1791000000000 + }, + "76561199000000169": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 170", + "timestamp": 1791000000000 + }, + "76561199000000170": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 171", + "timestamp": 1791000000000 + }, + "76561199000000171": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 172", + "timestamp": 1791000000000 + }, + "76561199000000172": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 173", + "timestamp": 1791000000000 + }, + "76561199000000173": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 174", + "timestamp": 1791000000000 + }, + "76561199000000174": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 175", + "timestamp": 1791000000000 + }, + "76561199000000175": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 176", + "timestamp": 1791000000000 + }, + "76561199000000176": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 177", + "timestamp": 1791000000000 + }, + "76561199000000177": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 178", + "timestamp": 1791000000000 + }, + "76561199000000178": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 179", + "timestamp": 1791000000000 + }, + "76561199000000179": { + "url": "crates/accshift-egui/parity/avatars/12.svg", + "displayName": "account 180", + "timestamp": 1791000000000 + }, + "76561199000000180": { + "url": "crates/accshift-egui/parity/avatars/13.svg", + "displayName": "account 181", + "timestamp": 1791000000000 + }, + "76561199000000181": { + "url": "crates/accshift-egui/parity/avatars/14.svg", + "displayName": "account 182", + "timestamp": 1791000000000 + }, + "76561199000000182": { + "url": "crates/accshift-egui/parity/avatars/15.svg", + "displayName": "account 183", + "timestamp": 1791000000000 + }, + "76561199000000183": { + "url": "crates/accshift-egui/parity/avatars/16.svg", + "displayName": "account 184", + "timestamp": 1791000000000 + }, + "76561199000000184": { + "url": "crates/accshift-egui/parity/avatars/17.svg", + "displayName": "account 185", + "timestamp": 1791000000000 + }, + "76561199000000185": { + "url": "crates/accshift-egui/parity/avatars/18.svg", + "displayName": "account 186", + "timestamp": 1791000000000 + }, + "76561199000000186": { + "url": "crates/accshift-egui/parity/avatars/19.svg", + "displayName": "account 187", + "timestamp": 1791000000000 + }, + "76561199000000187": { + "url": "crates/accshift-egui/parity/avatars/20.svg", + "displayName": "account 188", + "timestamp": 1791000000000 + }, + "76561199000000188": { + "url": "crates/accshift-egui/parity/avatars/21.svg", + "displayName": "account 189", + "timestamp": 1791000000000 + }, + "76561199000000189": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "account 190", + "timestamp": 1791000000000 + }, + "76561199000000190": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "account 191", + "timestamp": 1791000000000 + }, + "76561199000000191": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "account 192", + "timestamp": 1791000000000 + }, + "76561199000000192": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "account 193", + "timestamp": 1791000000000 + }, + "76561199000000193": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "account 194", + "timestamp": 1791000000000 + }, + "76561199000000194": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "account 195", + "timestamp": 1791000000000 + }, + "76561199000000195": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "account 196", + "timestamp": 1791000000000 + }, + "76561199000000196": { + "url": "crates/accshift-egui/parity/avatars/8.svg", + "displayName": "account 197", + "timestamp": 1791000000000 + }, + "76561199000000197": { + "url": "crates/accshift-egui/parity/avatars/9.svg", + "displayName": "account 198", + "timestamp": 1791000000000 + }, + "76561199000000198": { + "url": "crates/accshift-egui/parity/avatars/10.svg", + "displayName": "account 199", + "timestamp": 1791000000000 + }, + "76561199000000199": { + "url": "crates/accshift-egui/parity/avatars/11.svg", + "displayName": "account 200", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + } + } + ], + "migrate_legacy_config": [ + { + "args": {}, + "result": "skipped" + } + ], + "open_descriptors_folder": [ + { + "args": {}, + "result": null + } + ], + "open_logs_folder": [ + { + "args": {}, + "result": null + } + ], + "open_url": [ + { + "args": {}, + "result": null + } + ], + "platform_detect_installed": [ + { + "args": {}, + "result": ["steam"] + } + ], + "platform_dry_run": [ + { + "args": { + "platformId": "steam", + "accountId": "76561199000000000" + }, + "result": { + "platformId": "steam", + "operation": "switch", + "accountId": "76561199000000000", + "applied": false, + "roots": [], + "steps": [], + "warnings": [] + } + } + ], + "platform_get_accounts": [ + { + "args": { + "platformId": "steam" + }, + "result": [ + { + "steam_id": "76561199000000000", + "account_name": "filler_1", + "persona_name": "account 1", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561199000000001", + "account_name": "filler_2", + "persona_name": "account 2", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000002", + "account_name": "filler_3", + "persona_name": "account 3", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000003", + "account_name": "filler_4", + "persona_name": "account 4", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000004", + "account_name": "filler_5", + "persona_name": "account 5", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000005", + "account_name": "filler_6", + "persona_name": "account 6", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000006", + "account_name": "filler_7", + "persona_name": "account 7", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000007", + "account_name": "filler_8", + "persona_name": "account 8", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000008", + "account_name": "filler_9", + "persona_name": "account 9", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000009", + "account_name": "filler_10", + "persona_name": "account 10", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000010", + "account_name": "filler_11", + "persona_name": "account 11", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000011", + "account_name": "filler_12", + "persona_name": "account 12", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000012", + "account_name": "filler_13", + "persona_name": "account 13", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000013", + "account_name": "filler_14", + "persona_name": "account 14", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000014", + "account_name": "filler_15", + "persona_name": "account 15", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000015", + "account_name": "filler_16", + "persona_name": "account 16", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000016", + "account_name": "filler_17", + "persona_name": "account 17", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000017", + "account_name": "filler_18", + "persona_name": "account 18", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000018", + "account_name": "filler_19", + "persona_name": "account 19", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000019", + "account_name": "filler_20", + "persona_name": "account 20", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000020", + "account_name": "filler_21", + "persona_name": "account 21", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000021", + "account_name": "filler_22", + "persona_name": "account 22", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000022", + "account_name": "filler_23", + "persona_name": "account 23", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000023", + "account_name": "filler_24", + "persona_name": "account 24", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000024", + "account_name": "filler_25", + "persona_name": "account 25", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000025", + "account_name": "filler_26", + "persona_name": "account 26", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000026", + "account_name": "filler_27", + "persona_name": "account 27", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000027", + "account_name": "filler_28", + "persona_name": "account 28", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000028", + "account_name": "filler_29", + "persona_name": "account 29", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000029", + "account_name": "filler_30", + "persona_name": "account 30", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000030", + "account_name": "filler_31", + "persona_name": "account 31", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000031", + "account_name": "filler_32", + "persona_name": "account 32", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000032", + "account_name": "filler_33", + "persona_name": "account 33", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000033", + "account_name": "filler_34", + "persona_name": "account 34", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000034", + "account_name": "filler_35", + "persona_name": "account 35", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000035", + "account_name": "filler_36", + "persona_name": "account 36", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000036", + "account_name": "filler_37", + "persona_name": "account 37", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000037", + "account_name": "filler_38", + "persona_name": "account 38", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000038", + "account_name": "filler_39", + "persona_name": "account 39", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000039", + "account_name": "filler_40", + "persona_name": "account 40", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000040", + "account_name": "filler_41", + "persona_name": "account 41", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000041", + "account_name": "filler_42", + "persona_name": "account 42", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000042", + "account_name": "filler_43", + "persona_name": "account 43", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000043", + "account_name": "filler_44", + "persona_name": "account 44", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000044", + "account_name": "filler_45", + "persona_name": "account 45", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000045", + "account_name": "filler_46", + "persona_name": "account 46", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000046", + "account_name": "filler_47", + "persona_name": "account 47", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000047", + "account_name": "filler_48", + "persona_name": "account 48", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000048", + "account_name": "filler_49", + "persona_name": "account 49", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000049", + "account_name": "filler_50", + "persona_name": "account 50", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000050", + "account_name": "filler_51", + "persona_name": "account 51", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000051", + "account_name": "filler_52", + "persona_name": "account 52", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000052", + "account_name": "filler_53", + "persona_name": "account 53", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000053", + "account_name": "filler_54", + "persona_name": "account 54", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000054", + "account_name": "filler_55", + "persona_name": "account 55", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000055", + "account_name": "filler_56", + "persona_name": "account 56", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000056", + "account_name": "filler_57", + "persona_name": "account 57", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000057", + "account_name": "filler_58", + "persona_name": "account 58", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000058", + "account_name": "filler_59", + "persona_name": "account 59", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000059", + "account_name": "filler_60", + "persona_name": "account 60", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000060", + "account_name": "filler_61", + "persona_name": "account 61", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000061", + "account_name": "filler_62", + "persona_name": "account 62", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000062", + "account_name": "filler_63", + "persona_name": "account 63", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000063", + "account_name": "filler_64", + "persona_name": "account 64", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000064", + "account_name": "filler_65", + "persona_name": "account 65", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000065", + "account_name": "filler_66", + "persona_name": "account 66", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000066", + "account_name": "filler_67", + "persona_name": "account 67", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000067", + "account_name": "filler_68", + "persona_name": "account 68", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000068", + "account_name": "filler_69", + "persona_name": "account 69", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000069", + "account_name": "filler_70", + "persona_name": "account 70", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000070", + "account_name": "filler_71", + "persona_name": "account 71", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000071", + "account_name": "filler_72", + "persona_name": "account 72", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000072", + "account_name": "filler_73", + "persona_name": "account 73", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000073", + "account_name": "filler_74", + "persona_name": "account 74", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000074", + "account_name": "filler_75", + "persona_name": "account 75", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000075", + "account_name": "filler_76", + "persona_name": "account 76", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000076", + "account_name": "filler_77", + "persona_name": "account 77", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000077", + "account_name": "filler_78", + "persona_name": "account 78", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000078", + "account_name": "filler_79", + "persona_name": "account 79", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000079", + "account_name": "filler_80", + "persona_name": "account 80", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000080", + "account_name": "filler_81", + "persona_name": "account 81", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000081", + "account_name": "filler_82", + "persona_name": "account 82", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000082", + "account_name": "filler_83", + "persona_name": "account 83", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000083", + "account_name": "filler_84", + "persona_name": "account 84", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000084", + "account_name": "filler_85", + "persona_name": "account 85", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000085", + "account_name": "filler_86", + "persona_name": "account 86", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000086", + "account_name": "filler_87", + "persona_name": "account 87", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000087", + "account_name": "filler_88", + "persona_name": "account 88", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000088", + "account_name": "filler_89", + "persona_name": "account 89", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000089", + "account_name": "filler_90", + "persona_name": "account 90", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000090", + "account_name": "filler_91", + "persona_name": "account 91", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000091", + "account_name": "filler_92", + "persona_name": "account 92", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000092", + "account_name": "filler_93", + "persona_name": "account 93", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000093", + "account_name": "filler_94", + "persona_name": "account 94", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000094", + "account_name": "filler_95", + "persona_name": "account 95", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000095", + "account_name": "filler_96", + "persona_name": "account 96", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000096", + "account_name": "filler_97", + "persona_name": "account 97", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000097", + "account_name": "filler_98", + "persona_name": "account 98", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000098", + "account_name": "filler_99", + "persona_name": "account 99", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000099", + "account_name": "filler_100", + "persona_name": "account 100", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000100", + "account_name": "filler_101", + "persona_name": "account 101", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000101", + "account_name": "filler_102", + "persona_name": "account 102", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000102", + "account_name": "filler_103", + "persona_name": "account 103", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000103", + "account_name": "filler_104", + "persona_name": "account 104", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000104", + "account_name": "filler_105", + "persona_name": "account 105", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000105", + "account_name": "filler_106", + "persona_name": "account 106", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000106", + "account_name": "filler_107", + "persona_name": "account 107", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000107", + "account_name": "filler_108", + "persona_name": "account 108", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000108", + "account_name": "filler_109", + "persona_name": "account 109", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000109", + "account_name": "filler_110", + "persona_name": "account 110", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000110", + "account_name": "filler_111", + "persona_name": "account 111", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000111", + "account_name": "filler_112", + "persona_name": "account 112", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000112", + "account_name": "filler_113", + "persona_name": "account 113", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000113", + "account_name": "filler_114", + "persona_name": "account 114", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000114", + "account_name": "filler_115", + "persona_name": "account 115", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000115", + "account_name": "filler_116", + "persona_name": "account 116", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000116", + "account_name": "filler_117", + "persona_name": "account 117", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000117", + "account_name": "filler_118", + "persona_name": "account 118", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000118", + "account_name": "filler_119", + "persona_name": "account 119", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000119", + "account_name": "filler_120", + "persona_name": "account 120", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000120", + "account_name": "filler_121", + "persona_name": "account 121", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000121", + "account_name": "filler_122", + "persona_name": "account 122", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000122", + "account_name": "filler_123", + "persona_name": "account 123", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000123", + "account_name": "filler_124", + "persona_name": "account 124", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000124", + "account_name": "filler_125", + "persona_name": "account 125", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000125", + "account_name": "filler_126", + "persona_name": "account 126", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000126", + "account_name": "filler_127", + "persona_name": "account 127", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000127", + "account_name": "filler_128", + "persona_name": "account 128", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000128", + "account_name": "filler_129", + "persona_name": "account 129", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000129", + "account_name": "filler_130", + "persona_name": "account 130", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000130", + "account_name": "filler_131", + "persona_name": "account 131", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000131", + "account_name": "filler_132", + "persona_name": "account 132", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000132", + "account_name": "filler_133", + "persona_name": "account 133", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000133", + "account_name": "filler_134", + "persona_name": "account 134", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000134", + "account_name": "filler_135", + "persona_name": "account 135", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000135", + "account_name": "filler_136", + "persona_name": "account 136", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000136", + "account_name": "filler_137", + "persona_name": "account 137", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000137", + "account_name": "filler_138", + "persona_name": "account 138", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000138", + "account_name": "filler_139", + "persona_name": "account 139", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000139", + "account_name": "filler_140", + "persona_name": "account 140", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000140", + "account_name": "filler_141", + "persona_name": "account 141", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000141", + "account_name": "filler_142", + "persona_name": "account 142", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000142", + "account_name": "filler_143", + "persona_name": "account 143", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000143", + "account_name": "filler_144", + "persona_name": "account 144", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000144", + "account_name": "filler_145", + "persona_name": "account 145", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000145", + "account_name": "filler_146", + "persona_name": "account 146", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000146", + "account_name": "filler_147", + "persona_name": "account 147", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000147", + "account_name": "filler_148", + "persona_name": "account 148", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000148", + "account_name": "filler_149", + "persona_name": "account 149", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000149", + "account_name": "filler_150", + "persona_name": "account 150", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000150", + "account_name": "filler_151", + "persona_name": "account 151", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000151", + "account_name": "filler_152", + "persona_name": "account 152", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000152", + "account_name": "filler_153", + "persona_name": "account 153", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000153", + "account_name": "filler_154", + "persona_name": "account 154", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000154", + "account_name": "filler_155", + "persona_name": "account 155", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000155", + "account_name": "filler_156", + "persona_name": "account 156", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000156", + "account_name": "filler_157", + "persona_name": "account 157", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000157", + "account_name": "filler_158", + "persona_name": "account 158", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000158", + "account_name": "filler_159", + "persona_name": "account 159", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000159", + "account_name": "filler_160", + "persona_name": "account 160", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000160", + "account_name": "filler_161", + "persona_name": "account 161", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000161", + "account_name": "filler_162", + "persona_name": "account 162", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000162", + "account_name": "filler_163", + "persona_name": "account 163", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000163", + "account_name": "filler_164", + "persona_name": "account 164", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000164", + "account_name": "filler_165", + "persona_name": "account 165", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000165", + "account_name": "filler_166", + "persona_name": "account 166", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000166", + "account_name": "filler_167", + "persona_name": "account 167", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000167", + "account_name": "filler_168", + "persona_name": "account 168", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000168", + "account_name": "filler_169", + "persona_name": "account 169", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000169", + "account_name": "filler_170", + "persona_name": "account 170", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000170", + "account_name": "filler_171", + "persona_name": "account 171", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000171", + "account_name": "filler_172", + "persona_name": "account 172", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000172", + "account_name": "filler_173", + "persona_name": "account 173", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000173", + "account_name": "filler_174", + "persona_name": "account 174", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000174", + "account_name": "filler_175", + "persona_name": "account 175", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000175", + "account_name": "filler_176", + "persona_name": "account 176", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000176", + "account_name": "filler_177", + "persona_name": "account 177", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000177", + "account_name": "filler_178", + "persona_name": "account 178", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000178", + "account_name": "filler_179", + "persona_name": "account 179", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000179", + "account_name": "filler_180", + "persona_name": "account 180", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000180", + "account_name": "filler_181", + "persona_name": "account 181", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000181", + "account_name": "filler_182", + "persona_name": "account 182", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000182", + "account_name": "filler_183", + "persona_name": "account 183", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000183", + "account_name": "filler_184", + "persona_name": "account 184", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000184", + "account_name": "filler_185", + "persona_name": "account 185", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000185", + "account_name": "filler_186", + "persona_name": "account 186", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000186", + "account_name": "filler_187", + "persona_name": "account 187", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000187", + "account_name": "filler_188", + "persona_name": "account 188", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000188", + "account_name": "filler_189", + "persona_name": "account 189", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000189", + "account_name": "filler_190", + "persona_name": "account 190", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000190", + "account_name": "filler_191", + "persona_name": "account 191", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000191", + "account_name": "filler_192", + "persona_name": "account 192", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000192", + "account_name": "filler_193", + "persona_name": "account 193", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000193", + "account_name": "filler_194", + "persona_name": "account 194", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000194", + "account_name": "filler_195", + "persona_name": "account 195", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000195", + "account_name": "filler_196", + "persona_name": "account 196", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000196", + "account_name": "filler_197", + "persona_name": "account 197", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000197", + "account_name": "filler_198", + "persona_name": "account 198", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000198", + "account_name": "filler_199", + "persona_name": "account 199", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000199", + "account_name": "filler_200", + "persona_name": "account 200", + "last_login_at": 1789358400 + } + ] + }, + { + "args": { + "platformId": "riot" + }, + "result": [] + }, + { + "args": { + "platformId": "roblox" + }, + "result": [] + } + ], + "platform_get_current_account": [ + { + "args": { + "platformId": "steam" + }, + "result": "76561199000000000" + }, + { + "args": { + "platformId": "riot" + }, + "result": "" + }, + { + "args": { + "platformId": "roblox" + }, + "result": "" + } + ], + "platform_get_path": [ + { + "args": {}, + "result": "C:\\Program Files (x86)\\Steam" + } + ], + "platform_get_startup_snapshot": [ + { + "args": { + "platformId": "steam" + }, + "result": { + "accounts": [ + { + "steam_id": "76561199000000000", + "account_name": "filler_1", + "persona_name": "account 1", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561199000000001", + "account_name": "filler_2", + "persona_name": "account 2", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000002", + "account_name": "filler_3", + "persona_name": "account 3", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000003", + "account_name": "filler_4", + "persona_name": "account 4", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000004", + "account_name": "filler_5", + "persona_name": "account 5", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000005", + "account_name": "filler_6", + "persona_name": "account 6", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000006", + "account_name": "filler_7", + "persona_name": "account 7", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000007", + "account_name": "filler_8", + "persona_name": "account 8", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000008", + "account_name": "filler_9", + "persona_name": "account 9", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000009", + "account_name": "filler_10", + "persona_name": "account 10", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000010", + "account_name": "filler_11", + "persona_name": "account 11", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000011", + "account_name": "filler_12", + "persona_name": "account 12", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000012", + "account_name": "filler_13", + "persona_name": "account 13", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000013", + "account_name": "filler_14", + "persona_name": "account 14", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000014", + "account_name": "filler_15", + "persona_name": "account 15", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000015", + "account_name": "filler_16", + "persona_name": "account 16", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000016", + "account_name": "filler_17", + "persona_name": "account 17", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000017", + "account_name": "filler_18", + "persona_name": "account 18", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000018", + "account_name": "filler_19", + "persona_name": "account 19", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000019", + "account_name": "filler_20", + "persona_name": "account 20", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000020", + "account_name": "filler_21", + "persona_name": "account 21", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000021", + "account_name": "filler_22", + "persona_name": "account 22", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000022", + "account_name": "filler_23", + "persona_name": "account 23", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000023", + "account_name": "filler_24", + "persona_name": "account 24", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000024", + "account_name": "filler_25", + "persona_name": "account 25", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000025", + "account_name": "filler_26", + "persona_name": "account 26", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000026", + "account_name": "filler_27", + "persona_name": "account 27", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000027", + "account_name": "filler_28", + "persona_name": "account 28", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000028", + "account_name": "filler_29", + "persona_name": "account 29", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000029", + "account_name": "filler_30", + "persona_name": "account 30", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000030", + "account_name": "filler_31", + "persona_name": "account 31", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000031", + "account_name": "filler_32", + "persona_name": "account 32", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000032", + "account_name": "filler_33", + "persona_name": "account 33", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000033", + "account_name": "filler_34", + "persona_name": "account 34", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000034", + "account_name": "filler_35", + "persona_name": "account 35", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000035", + "account_name": "filler_36", + "persona_name": "account 36", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000036", + "account_name": "filler_37", + "persona_name": "account 37", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000037", + "account_name": "filler_38", + "persona_name": "account 38", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000038", + "account_name": "filler_39", + "persona_name": "account 39", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000039", + "account_name": "filler_40", + "persona_name": "account 40", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000040", + "account_name": "filler_41", + "persona_name": "account 41", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000041", + "account_name": "filler_42", + "persona_name": "account 42", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000042", + "account_name": "filler_43", + "persona_name": "account 43", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000043", + "account_name": "filler_44", + "persona_name": "account 44", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000044", + "account_name": "filler_45", + "persona_name": "account 45", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000045", + "account_name": "filler_46", + "persona_name": "account 46", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000046", + "account_name": "filler_47", + "persona_name": "account 47", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000047", + "account_name": "filler_48", + "persona_name": "account 48", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000048", + "account_name": "filler_49", + "persona_name": "account 49", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000049", + "account_name": "filler_50", + "persona_name": "account 50", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000050", + "account_name": "filler_51", + "persona_name": "account 51", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000051", + "account_name": "filler_52", + "persona_name": "account 52", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000052", + "account_name": "filler_53", + "persona_name": "account 53", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000053", + "account_name": "filler_54", + "persona_name": "account 54", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000054", + "account_name": "filler_55", + "persona_name": "account 55", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000055", + "account_name": "filler_56", + "persona_name": "account 56", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000056", + "account_name": "filler_57", + "persona_name": "account 57", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000057", + "account_name": "filler_58", + "persona_name": "account 58", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000058", + "account_name": "filler_59", + "persona_name": "account 59", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000059", + "account_name": "filler_60", + "persona_name": "account 60", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000060", + "account_name": "filler_61", + "persona_name": "account 61", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000061", + "account_name": "filler_62", + "persona_name": "account 62", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000062", + "account_name": "filler_63", + "persona_name": "account 63", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000063", + "account_name": "filler_64", + "persona_name": "account 64", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000064", + "account_name": "filler_65", + "persona_name": "account 65", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000065", + "account_name": "filler_66", + "persona_name": "account 66", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000066", + "account_name": "filler_67", + "persona_name": "account 67", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000067", + "account_name": "filler_68", + "persona_name": "account 68", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000068", + "account_name": "filler_69", + "persona_name": "account 69", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000069", + "account_name": "filler_70", + "persona_name": "account 70", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000070", + "account_name": "filler_71", + "persona_name": "account 71", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000071", + "account_name": "filler_72", + "persona_name": "account 72", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000072", + "account_name": "filler_73", + "persona_name": "account 73", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000073", + "account_name": "filler_74", + "persona_name": "account 74", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000074", + "account_name": "filler_75", + "persona_name": "account 75", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000075", + "account_name": "filler_76", + "persona_name": "account 76", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000076", + "account_name": "filler_77", + "persona_name": "account 77", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000077", + "account_name": "filler_78", + "persona_name": "account 78", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000078", + "account_name": "filler_79", + "persona_name": "account 79", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000079", + "account_name": "filler_80", + "persona_name": "account 80", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000080", + "account_name": "filler_81", + "persona_name": "account 81", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000081", + "account_name": "filler_82", + "persona_name": "account 82", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000082", + "account_name": "filler_83", + "persona_name": "account 83", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000083", + "account_name": "filler_84", + "persona_name": "account 84", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000084", + "account_name": "filler_85", + "persona_name": "account 85", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000085", + "account_name": "filler_86", + "persona_name": "account 86", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000086", + "account_name": "filler_87", + "persona_name": "account 87", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000087", + "account_name": "filler_88", + "persona_name": "account 88", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000088", + "account_name": "filler_89", + "persona_name": "account 89", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000089", + "account_name": "filler_90", + "persona_name": "account 90", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000090", + "account_name": "filler_91", + "persona_name": "account 91", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000091", + "account_name": "filler_92", + "persona_name": "account 92", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000092", + "account_name": "filler_93", + "persona_name": "account 93", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000093", + "account_name": "filler_94", + "persona_name": "account 94", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000094", + "account_name": "filler_95", + "persona_name": "account 95", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000095", + "account_name": "filler_96", + "persona_name": "account 96", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000096", + "account_name": "filler_97", + "persona_name": "account 97", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000097", + "account_name": "filler_98", + "persona_name": "account 98", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000098", + "account_name": "filler_99", + "persona_name": "account 99", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000099", + "account_name": "filler_100", + "persona_name": "account 100", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000100", + "account_name": "filler_101", + "persona_name": "account 101", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000101", + "account_name": "filler_102", + "persona_name": "account 102", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000102", + "account_name": "filler_103", + "persona_name": "account 103", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000103", + "account_name": "filler_104", + "persona_name": "account 104", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000104", + "account_name": "filler_105", + "persona_name": "account 105", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000105", + "account_name": "filler_106", + "persona_name": "account 106", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000106", + "account_name": "filler_107", + "persona_name": "account 107", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000107", + "account_name": "filler_108", + "persona_name": "account 108", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000108", + "account_name": "filler_109", + "persona_name": "account 109", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000109", + "account_name": "filler_110", + "persona_name": "account 110", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000110", + "account_name": "filler_111", + "persona_name": "account 111", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000111", + "account_name": "filler_112", + "persona_name": "account 112", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000112", + "account_name": "filler_113", + "persona_name": "account 113", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000113", + "account_name": "filler_114", + "persona_name": "account 114", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000114", + "account_name": "filler_115", + "persona_name": "account 115", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000115", + "account_name": "filler_116", + "persona_name": "account 116", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000116", + "account_name": "filler_117", + "persona_name": "account 117", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000117", + "account_name": "filler_118", + "persona_name": "account 118", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000118", + "account_name": "filler_119", + "persona_name": "account 119", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000119", + "account_name": "filler_120", + "persona_name": "account 120", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000120", + "account_name": "filler_121", + "persona_name": "account 121", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000121", + "account_name": "filler_122", + "persona_name": "account 122", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000122", + "account_name": "filler_123", + "persona_name": "account 123", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000123", + "account_name": "filler_124", + "persona_name": "account 124", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000124", + "account_name": "filler_125", + "persona_name": "account 125", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000125", + "account_name": "filler_126", + "persona_name": "account 126", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000126", + "account_name": "filler_127", + "persona_name": "account 127", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000127", + "account_name": "filler_128", + "persona_name": "account 128", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000128", + "account_name": "filler_129", + "persona_name": "account 129", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000129", + "account_name": "filler_130", + "persona_name": "account 130", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000130", + "account_name": "filler_131", + "persona_name": "account 131", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000131", + "account_name": "filler_132", + "persona_name": "account 132", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000132", + "account_name": "filler_133", + "persona_name": "account 133", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000133", + "account_name": "filler_134", + "persona_name": "account 134", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000134", + "account_name": "filler_135", + "persona_name": "account 135", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000135", + "account_name": "filler_136", + "persona_name": "account 136", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000136", + "account_name": "filler_137", + "persona_name": "account 137", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000137", + "account_name": "filler_138", + "persona_name": "account 138", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000138", + "account_name": "filler_139", + "persona_name": "account 139", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000139", + "account_name": "filler_140", + "persona_name": "account 140", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000140", + "account_name": "filler_141", + "persona_name": "account 141", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000141", + "account_name": "filler_142", + "persona_name": "account 142", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000142", + "account_name": "filler_143", + "persona_name": "account 143", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000143", + "account_name": "filler_144", + "persona_name": "account 144", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000144", + "account_name": "filler_145", + "persona_name": "account 145", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000145", + "account_name": "filler_146", + "persona_name": "account 146", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000146", + "account_name": "filler_147", + "persona_name": "account 147", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000147", + "account_name": "filler_148", + "persona_name": "account 148", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000148", + "account_name": "filler_149", + "persona_name": "account 149", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000149", + "account_name": "filler_150", + "persona_name": "account 150", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000150", + "account_name": "filler_151", + "persona_name": "account 151", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000151", + "account_name": "filler_152", + "persona_name": "account 152", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000152", + "account_name": "filler_153", + "persona_name": "account 153", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000153", + "account_name": "filler_154", + "persona_name": "account 154", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000154", + "account_name": "filler_155", + "persona_name": "account 155", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000155", + "account_name": "filler_156", + "persona_name": "account 156", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000156", + "account_name": "filler_157", + "persona_name": "account 157", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000157", + "account_name": "filler_158", + "persona_name": "account 158", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000158", + "account_name": "filler_159", + "persona_name": "account 159", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000159", + "account_name": "filler_160", + "persona_name": "account 160", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000160", + "account_name": "filler_161", + "persona_name": "account 161", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000161", + "account_name": "filler_162", + "persona_name": "account 162", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000162", + "account_name": "filler_163", + "persona_name": "account 163", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000163", + "account_name": "filler_164", + "persona_name": "account 164", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000164", + "account_name": "filler_165", + "persona_name": "account 165", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000165", + "account_name": "filler_166", + "persona_name": "account 166", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000166", + "account_name": "filler_167", + "persona_name": "account 167", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000167", + "account_name": "filler_168", + "persona_name": "account 168", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000168", + "account_name": "filler_169", + "persona_name": "account 169", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000169", + "account_name": "filler_170", + "persona_name": "account 170", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000170", + "account_name": "filler_171", + "persona_name": "account 171", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000171", + "account_name": "filler_172", + "persona_name": "account 172", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000172", + "account_name": "filler_173", + "persona_name": "account 173", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000173", + "account_name": "filler_174", + "persona_name": "account 174", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000174", + "account_name": "filler_175", + "persona_name": "account 175", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000175", + "account_name": "filler_176", + "persona_name": "account 176", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000176", + "account_name": "filler_177", + "persona_name": "account 177", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000177", + "account_name": "filler_178", + "persona_name": "account 178", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000178", + "account_name": "filler_179", + "persona_name": "account 179", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000179", + "account_name": "filler_180", + "persona_name": "account 180", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000180", + "account_name": "filler_181", + "persona_name": "account 181", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000181", + "account_name": "filler_182", + "persona_name": "account 182", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000182", + "account_name": "filler_183", + "persona_name": "account 183", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000183", + "account_name": "filler_184", + "persona_name": "account 184", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000184", + "account_name": "filler_185", + "persona_name": "account 185", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000185", + "account_name": "filler_186", + "persona_name": "account 186", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000186", + "account_name": "filler_187", + "persona_name": "account 187", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000187", + "account_name": "filler_188", + "persona_name": "account 188", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000188", + "account_name": "filler_189", + "persona_name": "account 189", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000189", + "account_name": "filler_190", + "persona_name": "account 190", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000190", + "account_name": "filler_191", + "persona_name": "account 191", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000191", + "account_name": "filler_192", + "persona_name": "account 192", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000192", + "account_name": "filler_193", + "persona_name": "account 193", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000193", + "account_name": "filler_194", + "persona_name": "account 194", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000194", + "account_name": "filler_195", + "persona_name": "account 195", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000195", + "account_name": "filler_196", + "persona_name": "account 196", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000196", + "account_name": "filler_197", + "persona_name": "account 197", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000197", + "account_name": "filler_198", + "persona_name": "account 198", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000198", + "account_name": "filler_199", + "persona_name": "account 199", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000199", + "account_name": "filler_200", + "persona_name": "account 200", + "last_login_at": 1789358400 + } + ], + "currentAccount": "76561199000000000" + } + }, + { + "args": { + "platformId": "riot" + }, + "result": { + "profiles": [], + "currentProfile": "" + } + }, + { + "args": { + "platformId": "roblox" + }, + "result": { + "accounts": [], + "currentAccount": "" + } + } + ], + "platform_set_account_label": [ + { + "args": {}, + "result": null + } + ], + "reload_user_platforms": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "roblox_check_sessions": [ + { + "args": {}, + "result": [] + } + ], + "roblox_get_profile_info": [], + "save_client_storage_store": [ + { + "args": {}, + "result": null + } + ], + "steam_get_account_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_copyable_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_player_bans": [ + { + "args": { + "steamIds": [ + "76561199000000000", + "76561199000000001", + "76561199000000002", + "76561199000000003", + "76561199000000004", + "76561199000000005", + "76561199000000006", + "76561199000000007", + "76561199000000008", + "76561199000000009", + "76561199000000010", + "76561199000000011", + "76561199000000012", + "76561199000000013", + "76561199000000014", + "76561199000000015", + "76561199000000016", + "76561199000000017", + "76561199000000018", + "76561199000000019", + "76561199000000020", + "76561199000000021", + "76561199000000022", + "76561199000000023", + "76561199000000024", + "76561199000000025", + "76561199000000026", + "76561199000000027", + "76561199000000028", + "76561199000000029", + "76561199000000030", + "76561199000000031", + "76561199000000032", + "76561199000000033", + "76561199000000034", + "76561199000000035", + "76561199000000036", + "76561199000000037", + "76561199000000038", + "76561199000000039", + "76561199000000040", + "76561199000000041", + "76561199000000042", + "76561199000000043", + "76561199000000044", + "76561199000000045", + "76561199000000046", + "76561199000000047", + "76561199000000048", + "76561199000000049", + "76561199000000050", + "76561199000000051", + "76561199000000052", + "76561199000000053", + "76561199000000054", + "76561199000000055", + "76561199000000056", + "76561199000000057", + "76561199000000058", + "76561199000000059", + "76561199000000060", + "76561199000000061", + "76561199000000062", + "76561199000000063", + "76561199000000064", + "76561199000000065", + "76561199000000066", + "76561199000000067", + "76561199000000068", + "76561199000000069", + "76561199000000070", + "76561199000000071", + "76561199000000072", + "76561199000000073", + "76561199000000074", + "76561199000000075", + "76561199000000076", + "76561199000000077", + "76561199000000078", + "76561199000000079", + "76561199000000080", + "76561199000000081", + "76561199000000082", + "76561199000000083", + "76561199000000084", + "76561199000000085", + "76561199000000086", + "76561199000000087", + "76561199000000088", + "76561199000000089", + "76561199000000090", + "76561199000000091", + "76561199000000092", + "76561199000000093", + "76561199000000094", + "76561199000000095", + "76561199000000096", + "76561199000000097", + "76561199000000098", + "76561199000000099", + "76561199000000100", + "76561199000000101", + "76561199000000102", + "76561199000000103", + "76561199000000104", + "76561199000000105", + "76561199000000106", + "76561199000000107", + "76561199000000108", + "76561199000000109", + "76561199000000110", + "76561199000000111", + "76561199000000112", + "76561199000000113", + "76561199000000114", + "76561199000000115", + "76561199000000116", + "76561199000000117", + "76561199000000118", + "76561199000000119", + "76561199000000120", + "76561199000000121", + "76561199000000122", + "76561199000000123", + "76561199000000124", + "76561199000000125", + "76561199000000126", + "76561199000000127", + "76561199000000128", + "76561199000000129", + "76561199000000130", + "76561199000000131", + "76561199000000132", + "76561199000000133", + "76561199000000134", + "76561199000000135", + "76561199000000136", + "76561199000000137", + "76561199000000138", + "76561199000000139", + "76561199000000140", + "76561199000000141", + "76561199000000142", + "76561199000000143", + "76561199000000144", + "76561199000000145", + "76561199000000146", + "76561199000000147", + "76561199000000148", + "76561199000000149", + "76561199000000150", + "76561199000000151", + "76561199000000152", + "76561199000000153", + "76561199000000154", + "76561199000000155", + "76561199000000156", + "76561199000000157", + "76561199000000158", + "76561199000000159", + "76561199000000160", + "76561199000000161", + "76561199000000162", + "76561199000000163", + "76561199000000164", + "76561199000000165", + "76561199000000166", + "76561199000000167", + "76561199000000168", + "76561199000000169", + "76561199000000170", + "76561199000000171", + "76561199000000172", + "76561199000000173", + "76561199000000174", + "76561199000000175", + "76561199000000176", + "76561199000000177", + "76561199000000178", + "76561199000000179", + "76561199000000180", + "76561199000000181", + "76561199000000182", + "76561199000000183", + "76561199000000184", + "76561199000000185", + "76561199000000186", + "76561199000000187", + "76561199000000188", + "76561199000000189", + "76561199000000190", + "76561199000000191", + "76561199000000192", + "76561199000000193", + "76561199000000194", + "76561199000000195", + "76561199000000196", + "76561199000000197", + "76561199000000198", + "76561199000000199" + ] + }, + "result": [ + { + "steam_id": "76561199000000000", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000001", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000002", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000003", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000004", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000005", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000006", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000007", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000008", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000009", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000010", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000011", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000012", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000013", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000014", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000015", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000016", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000017", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000018", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000019", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000020", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000021", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000022", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000023", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000024", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000025", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000026", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000027", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000028", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000029", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000030", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000031", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000032", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000033", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000034", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000035", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000036", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000037", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000038", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000039", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000040", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000041", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000042", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000043", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000044", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000045", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000046", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000047", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000048", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000049", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000050", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000051", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000052", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000053", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000054", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000055", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000056", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000057", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000058", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000059", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000060", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000061", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000062", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000063", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000064", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000065", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000066", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000067", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000068", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000069", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000070", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000071", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000072", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000073", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000074", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000075", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000076", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000077", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000078", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000079", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000080", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000081", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000082", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000083", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000084", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000085", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000086", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000087", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000088", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000089", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000090", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000091", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000092", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000093", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000094", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000095", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000096", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000097", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000098", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000099", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000100", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000101", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000102", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000103", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000104", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000105", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000106", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000107", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000108", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000109", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000110", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000111", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000112", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000113", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000114", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000115", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000116", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000117", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000118", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000119", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000120", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000121", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000122", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000123", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000124", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000125", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000126", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000127", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000128", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000129", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000130", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000131", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000132", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000133", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000134", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000135", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000136", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000137", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000138", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000139", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000140", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000141", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000142", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000143", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000144", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000145", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000146", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000147", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000148", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000149", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000150", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000151", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000152", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000153", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000154", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000155", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000156", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000157", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000158", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000159", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000160", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000161", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000162", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000163", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000164", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000165", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000166", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000167", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000168", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000169", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000170", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000171", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000172", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000173", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000174", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000175", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000176", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000177", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000178", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000179", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000180", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000181", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000182", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000183", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000184", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000185", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000186", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000187", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000188", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000189", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000190", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000191", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000192", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000193", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000194", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000195", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000196", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000197", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000198", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000199", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + } + ] + } + ], + "steam_get_profile_info": [ + { + "args": { + "accountId": "76561199000000000" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 1", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000001" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 2", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000002" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 3", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000003" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 4", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000004" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 5", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000005" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 6", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000006" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 7", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000007" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 8", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000008" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 9", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000009" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 10", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000010" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 11", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000011" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 12", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000012" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 13", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000013" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 14", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000014" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 15", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000015" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 16", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000016" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 17", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000017" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 18", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000018" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 19", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000019" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 20", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000020" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 21", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000021" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 22", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000022" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 23", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000023" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 24", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000024" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 25", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000025" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 26", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000026" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 27", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000027" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 28", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000028" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 29", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000029" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 30", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000030" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 31", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000031" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 32", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000032" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 33", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000033" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 34", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000034" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 35", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000035" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 36", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000036" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 37", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000037" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 38", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000038" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 39", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000039" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 40", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000040" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 41", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000041" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 42", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000042" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 43", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000043" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 44", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000044" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 45", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000045" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 46", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000046" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 47", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000047" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 48", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000048" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 49", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000049" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 50", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000050" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 51", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000051" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 52", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000052" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 53", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000053" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 54", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000054" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 55", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000055" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 56", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000056" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 57", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000057" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 58", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000058" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 59", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000059" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 60", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000060" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 61", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000061" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 62", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000062" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 63", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000063" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 64", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000064" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 65", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000065" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 66", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000066" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 67", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000067" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 68", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000068" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 69", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000069" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 70", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000070" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 71", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000071" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 72", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000072" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 73", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000073" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 74", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000074" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 75", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000075" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 76", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000076" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 77", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000077" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 78", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000078" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 79", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000079" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 80", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000080" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 81", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000081" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 82", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000082" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 83", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000083" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 84", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000084" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 85", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000085" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 86", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000086" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 87", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000087" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 88", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000088" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 89", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000089" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 90", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000090" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 91", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000091" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 92", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000092" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 93", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000093" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 94", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000094" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 95", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000095" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 96", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000096" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 97", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000097" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 98", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000098" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 99", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000099" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 100", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000100" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 101", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000101" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 102", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000102" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 103", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000103" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 104", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000104" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 105", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000105" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 106", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000106" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 107", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000107" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 108", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000108" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 109", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000109" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 110", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000110" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 111", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000111" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 112", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000112" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 113", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000113" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 114", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000114" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 115", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000115" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 116", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000116" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 117", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000117" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 118", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000118" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 119", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000119" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 120", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000120" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 121", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000121" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 122", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000122" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 123", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000123" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 124", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000124" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 125", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000125" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 126", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000126" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 127", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000127" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 128", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000128" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 129", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000129" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 130", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000130" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 131", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000131" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 132", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000132" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 133", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000133" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 134", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000134" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 135", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000135" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 136", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000136" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 137", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000137" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 138", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000138" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 139", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000139" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 140", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000140" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 141", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000141" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 142", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000142" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 143", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000143" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 144", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000144" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 145", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000145" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 146", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000146" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 147", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000147" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 148", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000148" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 149", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000149" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 150", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000150" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 151", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000151" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 152", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000152" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 153", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000153" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 154", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000154" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 155", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000155" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 156", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000156" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 157", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000157" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 158", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000158" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 159", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000159" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 160", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000160" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 161", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000161" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 162", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000162" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 163", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000163" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 164", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000164" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 165", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000165" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 166", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000166" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 167", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000167" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 168", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000168" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 169", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000169" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 170", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000170" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 171", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000171" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 172", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000172" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 173", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000173" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 174", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000174" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 175", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000175" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 176", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000176" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 177", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000177" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 178", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000178" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 179", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000179" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 180", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000180" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 181", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000181" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 182", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000182" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 183", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000183" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 184", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000184" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 185", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000185" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 186", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000186" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 187", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000187" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 188", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000188" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 189", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000189" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 190", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000190" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 191", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000191" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 192", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000192" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 193", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000193" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 194", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000194" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 195", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000195" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 196", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000196" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 197", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000197" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 198", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000198" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 199", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000199" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 200", + "vac_banned": false, + "trade_ban_state": "none" + } + } + ], + "steam_get_profile_infos": [ + { + "args": { + "accountIds": [ + "76561199000000000", + "76561199000000001", + "76561199000000002", + "76561199000000003", + "76561199000000004", + "76561199000000005", + "76561199000000006", + "76561199000000007", + "76561199000000008", + "76561199000000009", + "76561199000000010", + "76561199000000011", + "76561199000000012", + "76561199000000013", + "76561199000000014", + "76561199000000015", + "76561199000000016", + "76561199000000017", + "76561199000000018", + "76561199000000019", + "76561199000000020", + "76561199000000021", + "76561199000000022", + "76561199000000023", + "76561199000000024", + "76561199000000025", + "76561199000000026", + "76561199000000027", + "76561199000000028", + "76561199000000029", + "76561199000000030", + "76561199000000031", + "76561199000000032", + "76561199000000033", + "76561199000000034", + "76561199000000035", + "76561199000000036", + "76561199000000037", + "76561199000000038", + "76561199000000039", + "76561199000000040", + "76561199000000041", + "76561199000000042", + "76561199000000043", + "76561199000000044", + "76561199000000045", + "76561199000000046", + "76561199000000047", + "76561199000000048", + "76561199000000049", + "76561199000000050", + "76561199000000051", + "76561199000000052", + "76561199000000053", + "76561199000000054", + "76561199000000055", + "76561199000000056", + "76561199000000057", + "76561199000000058", + "76561199000000059", + "76561199000000060", + "76561199000000061", + "76561199000000062", + "76561199000000063", + "76561199000000064", + "76561199000000065", + "76561199000000066", + "76561199000000067", + "76561199000000068", + "76561199000000069", + "76561199000000070", + "76561199000000071", + "76561199000000072", + "76561199000000073", + "76561199000000074", + "76561199000000075", + "76561199000000076", + "76561199000000077", + "76561199000000078", + "76561199000000079", + "76561199000000080", + "76561199000000081", + "76561199000000082", + "76561199000000083", + "76561199000000084", + "76561199000000085", + "76561199000000086", + "76561199000000087", + "76561199000000088", + "76561199000000089", + "76561199000000090", + "76561199000000091", + "76561199000000092", + "76561199000000093", + "76561199000000094", + "76561199000000095", + "76561199000000096", + "76561199000000097", + "76561199000000098", + "76561199000000099", + "76561199000000100", + "76561199000000101", + "76561199000000102", + "76561199000000103", + "76561199000000104", + "76561199000000105", + "76561199000000106", + "76561199000000107", + "76561199000000108", + "76561199000000109", + "76561199000000110", + "76561199000000111", + "76561199000000112", + "76561199000000113", + "76561199000000114", + "76561199000000115", + "76561199000000116", + "76561199000000117", + "76561199000000118", + "76561199000000119", + "76561199000000120", + "76561199000000121", + "76561199000000122", + "76561199000000123", + "76561199000000124", + "76561199000000125", + "76561199000000126", + "76561199000000127", + "76561199000000128", + "76561199000000129", + "76561199000000130", + "76561199000000131", + "76561199000000132", + "76561199000000133", + "76561199000000134", + "76561199000000135", + "76561199000000136", + "76561199000000137", + "76561199000000138", + "76561199000000139", + "76561199000000140", + "76561199000000141", + "76561199000000142", + "76561199000000143", + "76561199000000144", + "76561199000000145", + "76561199000000146", + "76561199000000147", + "76561199000000148", + "76561199000000149", + "76561199000000150", + "76561199000000151", + "76561199000000152", + "76561199000000153", + "76561199000000154", + "76561199000000155", + "76561199000000156", + "76561199000000157", + "76561199000000158", + "76561199000000159", + "76561199000000160", + "76561199000000161", + "76561199000000162", + "76561199000000163", + "76561199000000164", + "76561199000000165", + "76561199000000166", + "76561199000000167", + "76561199000000168", + "76561199000000169", + "76561199000000170", + "76561199000000171", + "76561199000000172", + "76561199000000173", + "76561199000000174", + "76561199000000175", + "76561199000000176", + "76561199000000177", + "76561199000000178", + "76561199000000179", + "76561199000000180", + "76561199000000181", + "76561199000000182", + "76561199000000183", + "76561199000000184", + "76561199000000185", + "76561199000000186", + "76561199000000187", + "76561199000000188", + "76561199000000189", + "76561199000000190", + "76561199000000191", + "76561199000000192", + "76561199000000193", + "76561199000000194", + "76561199000000195", + "76561199000000196", + "76561199000000197", + "76561199000000198", + "76561199000000199" + ] + }, + "result": { + "76561199000000000": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 1", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000001": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 2", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000002": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 3", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000003": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 4", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000004": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 5", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000005": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 6", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000006": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 7", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000007": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 8", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000008": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 9", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000009": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 10", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000010": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 11", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000011": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 12", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000012": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 13", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000013": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 14", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000014": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 15", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000015": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 16", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000016": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 17", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000017": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 18", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000018": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 19", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000019": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 20", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000020": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 21", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000021": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 22", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000022": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 23", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000023": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 24", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000024": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 25", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000025": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 26", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000026": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 27", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000027": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 28", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000028": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 29", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000029": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 30", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000030": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 31", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000031": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 32", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000032": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 33", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000033": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 34", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000034": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 35", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000035": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 36", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000036": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 37", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000037": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 38", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000038": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 39", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000039": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 40", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000040": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 41", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000041": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 42", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000042": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 43", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000043": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 44", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000044": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 45", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000045": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 46", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000046": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 47", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000047": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 48", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000048": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 49", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000049": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 50", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000050": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 51", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000051": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 52", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000052": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 53", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000053": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 54", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000054": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 55", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000055": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 56", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000056": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 57", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000057": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 58", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000058": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 59", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000059": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 60", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000060": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 61", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000061": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 62", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000062": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 63", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000063": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 64", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000064": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 65", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000065": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 66", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000066": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 67", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000067": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 68", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000068": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 69", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000069": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 70", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000070": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 71", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000071": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 72", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000072": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 73", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000073": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 74", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000074": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 75", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000075": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 76", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000076": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 77", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000077": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 78", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000078": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 79", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000079": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 80", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000080": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 81", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000081": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 82", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000082": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 83", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000083": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 84", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000084": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 85", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000085": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 86", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000086": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 87", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000087": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 88", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000088": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 89", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000089": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 90", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000090": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 91", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000091": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 92", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000092": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 93", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000093": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 94", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000094": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 95", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000095": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 96", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000096": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 97", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000097": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 98", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000098": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 99", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000099": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 100", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000100": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 101", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000101": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 102", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000102": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 103", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000103": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 104", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000104": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 105", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000105": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 106", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000106": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 107", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000107": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 108", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000108": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 109", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000109": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 110", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000110": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 111", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000111": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 112", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000112": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 113", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000113": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 114", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000114": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 115", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000115": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 116", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000116": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 117", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000117": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 118", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000118": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 119", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000119": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 120", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000120": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 121", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000121": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 122", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000122": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 123", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000123": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 124", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000124": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 125", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000125": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 126", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000126": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 127", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000127": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 128", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000128": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 129", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000129": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 130", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000130": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 131", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000131": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 132", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000132": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 133", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000133": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 134", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000134": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 135", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000135": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 136", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000136": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 137", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000137": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 138", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000138": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 139", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000139": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 140", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000140": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 141", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000141": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 142", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000142": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 143", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000143": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 144", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000144": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 145", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000145": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 146", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000146": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 147", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000147": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 148", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000148": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 149", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000149": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 150", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000150": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 151", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000151": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 152", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000152": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 153", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000153": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 154", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000154": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 155", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000155": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 156", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000156": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 157", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000157": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 158", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000158": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 159", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000159": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 160", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000160": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 161", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000161": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 162", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000162": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 163", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000163": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 164", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000164": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 165", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000165": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 166", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000166": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 167", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000167": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 168", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000168": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 169", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000169": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 170", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000170": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 171", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000171": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 172", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000172": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 173", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000173": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 174", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000174": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 175", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000175": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 176", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000176": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 177", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000177": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 178", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000178": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 179", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000179": { + "avatar_url": "crates/accshift-egui/parity/avatars/12.svg", + "display_name": "account 180", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000180": { + "avatar_url": "crates/accshift-egui/parity/avatars/13.svg", + "display_name": "account 181", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000181": { + "avatar_url": "crates/accshift-egui/parity/avatars/14.svg", + "display_name": "account 182", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000182": { + "avatar_url": "crates/accshift-egui/parity/avatars/15.svg", + "display_name": "account 183", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000183": { + "avatar_url": "crates/accshift-egui/parity/avatars/16.svg", + "display_name": "account 184", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000184": { + "avatar_url": "crates/accshift-egui/parity/avatars/17.svg", + "display_name": "account 185", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000185": { + "avatar_url": "crates/accshift-egui/parity/avatars/18.svg", + "display_name": "account 186", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000186": { + "avatar_url": "crates/accshift-egui/parity/avatars/19.svg", + "display_name": "account 187", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000187": { + "avatar_url": "crates/accshift-egui/parity/avatars/20.svg", + "display_name": "account 188", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000188": { + "avatar_url": "crates/accshift-egui/parity/avatars/21.svg", + "display_name": "account 189", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000189": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "account 190", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000190": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "account 191", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000191": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "account 192", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000192": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "account 193", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000193": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "account 194", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000194": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "account 195", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000195": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "account 196", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000196": { + "avatar_url": "crates/accshift-egui/parity/avatars/8.svg", + "display_name": "account 197", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000197": { + "avatar_url": "crates/accshift-egui/parity/avatars/9.svg", + "display_name": "account 198", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000198": { + "avatar_url": "crates/accshift-egui/parity/avatars/10.svg", + "display_name": "account 199", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000199": { + "avatar_url": "crates/accshift-egui/parity/avatars/11.svg", + "display_name": "account 200", + "vac_banned": false, + "trade_ban_state": "none" + } + } + } + ], + "steam_has_api_key": [ + { + "args": {}, + "result": true + } + ], + "steam_open_api_key_page": [ + { + "args": {}, + "result": null + } + ], + "steam_open_userdata": [ + { + "args": {}, + "result": null + } + ], + "telemetry_get_state": [ + { + "args": {}, + "result": { + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": true + } + } + ] + }, + "skipped": { + "pin_lock": "PIN session state", + "pin_unlock": "PIN session state, reads the live settings store", + "platform_switch_account": "changes the current account after switchDelayMs; modelled from spec" + } +} diff --git a/crates/accshift-egui/parity/data/slow.json b/crates/accshift-egui/parity/data/slow.json new file mode 100644 index 00000000..0d93c98c --- /dev/null +++ b/crates/accshift-egui/parity/data/slow.json @@ -0,0 +1,1076 @@ +{ + "format": 1, + "scenario": "slow", + "now": 1791000000, + "spec": { + "label": "dev dataset with a 4s switch — spinners and pending states", + "runtimeOs": "windows", + "switchDelayMs": 4000, + "steamPath": "C:\\Program Files (x86)\\Steam", + "hasSteamApiKey": true, + "currentSteamAccount": "76561198100000001", + "currentRiotProfile": "riot-1", + "currentRobloxAccount": "" + }, + "commands": { + "cs2_bridge_get_settings": [ + { + "args": {}, + "result": { + "enabled": false, + "url": "", + "apiKey": "" + } + } + ], + "descriptor_install_file": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_preview_file": [ + { + "args": {}, + "result": { + "source": "C:\\Users\\player\\Downloads\\acme.json", + "descriptor": { + "id": "acme", + "schemaVersion": 1, + "name": "Acme Launcher", + "shortName": "Acme", + "os": { + "windows": { + "roots": { + "files": ["${APPDATA}/Acme Launcher"] + }, + "detect": { + "executableResolves": true + }, + "executable": { + "fileName": "AcmeLauncher.exe", + "candidates": [ + { + "kind": "path", + "template": "${ProgramFiles}/Acme/AcmeLauncher.exe" + } + ] + }, + "identity": { + "source": { + "kind": "synthetic" + }, + "format": { + "charset": "alphanumeric", + "maxLength": 64 + }, + "current": "config" + }, + "state": { + "files": [ + { + "live": "${APPDATA}/Acme Launcher/session.json", + "snapshot": "session.json", + "snapshotMarker": true + } + ] + }, + "close": { + "processes": ["AcmeLauncher.exe"] + }, + "launch": {} + } + } + }, + "fileName": "acme.json", + "replaces": false, + "blocked": "", + "plan": { + "platformId": "acme", + "operation": "switch", + "accountId": "preview", + "applied": false, + "roots": ["C:\\Users\\player\\AppData\\Roaming\\Acme Launcher"], + "steps": [ + { + "action": "close", + "kind": "process", + "target": "AcmeLauncher.exe" + }, + { + "action": "capture", + "kind": "file", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\session.json", + "snapshot": "acme\\current\\session.json" + }, + { + "action": "delete", + "kind": "directory", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\cache", + "note": "skipped: not there" + }, + { + "action": "restore", + "kind": "file", + "target": "C:\\Users\\player\\AppData\\Roaming\\Acme Launcher\\session.json", + "snapshot": "acme\\preview\\session.json" + }, + { + "action": "launch", + "kind": "executable", + "target": "C:\\Program Files\\Acme\\AcmeLauncher.exe" + } + ], + "warnings": ["No snapshot for this account yet: add it through setup first."] + }, + "planProblem": "" + } + } + ], + "descriptor_remove": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_select_file": [ + { + "args": {}, + "result": "C:\\Users\\player\\Downloads\\acme.json" + } + ], + "detect_streaming_software": [ + { + "args": {}, + "result": false + } + ], + "get_boot_payload": [ + { + "args": {}, + "result": { + "migration": "skipped", + "runtimeOs": "windows", + "storageSnapshot": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam", "riot"], + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": true + } + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + }, + "client.account-card-colors": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198100000001": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198100000003": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "vac banned", + "timestamp": 1791000000000 + }, + "76561198100000004": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 1791000000000 + }, + "76561198100000005": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 1791000000000 + }, + "76561198100000006": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 1791000000000 + }, + "76561198100000007": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "nested account", + "timestamp": 1791000000000 + }, + "76561198100000008": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + }, + "customThemes": [], + "userPlatforms": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + } + ], + "get_desktop_wallpaper": [ + { + "args": {}, + "result": null + } + ], + "get_runtime_os": [ + { + "args": {}, + "result": "windows" + } + ], + "get_storage_manifest": [ + { + "args": {}, + "result": { + "schemaVersion": 1, + "stores": {} + } + } + ], + "list_custom_themes": [ + { + "args": {}, + "result": [] + } + ], + "load_client_storage_snapshot": [ + { + "args": {}, + "result": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam", "riot"], + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": true + } + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + }, + "client.account-card-colors": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198100000001": { + "url": "crates/accshift-egui/parity/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198100000003": { + "url": "crates/accshift-egui/parity/avatars/3.svg", + "displayName": "vac banned", + "timestamp": 1791000000000 + }, + "76561198100000004": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 1791000000000 + }, + "76561198100000005": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 1791000000000 + }, + "76561198100000006": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 1791000000000 + }, + "76561198100000007": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "nested account", + "timestamp": 1791000000000 + }, + "76561198100000008": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + } + } + ], + "migrate_legacy_config": [ + { + "args": {}, + "result": "skipped" + } + ], + "open_descriptors_folder": [ + { + "args": {}, + "result": null + } + ], + "open_logs_folder": [ + { + "args": {}, + "result": null + } + ], + "open_url": [ + { + "args": {}, + "result": null + } + ], + "platform_detect_installed": [ + { + "args": {}, + "result": ["steam", "riot"] + } + ], + "platform_dry_run": [ + { + "args": { + "platformId": "steam", + "accountId": "76561198100000001" + }, + "result": { + "platformId": "steam", + "operation": "switch", + "accountId": "76561198100000001", + "applied": false, + "roots": [], + "steps": [], + "warnings": [] + } + } + ], + "platform_get_accounts": [ + { + "args": { + "platformId": "steam" + }, + "result": [ + { + "steam_id": "76561198100000001", + "account_name": "mainacct", + "persona_name": "main", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198100000002", + "account_name": "no_avatar_here", + "persona_name": "no avatar", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198100000003", + "account_name": "banned_one", + "persona_name": "vac banned", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198100000004", + "account_name": "trade_locked", + "persona_name": "trade banned", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198100000005", + "account_name": "overflow_test_account_name", + "persona_name": "a persona name long enough to overflow every card layout", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198100000006", + "account_name": "never_used", + "persona_name": "never logged in", + "last_login_at": null + }, + { + "steam_id": "76561198100000007", + "account_name": "nested_one", + "persona_name": "nested account", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561198100000008", + "account_name": "nested_two", + "persona_name": "deeply nested", + "last_login_at": 1787198400 + } + ] + }, + { + "args": { + "platformId": "riot" + }, + "result": [ + { + "id": "riot-1", + "label": "ready", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "capturing", + "snapshot_state": "capturing", + "last_used_at": null + }, + { + "id": "riot-3", + "label": "awaiting", + "snapshot_state": "awaiting_capture", + "last_used_at": null + }, + { + "id": "riot-4", + "label": "setup pending", + "snapshot_state": "setup_pending", + "last_used_at": null + } + ] + }, + { + "args": { + "platformId": "roblox" + }, + "result": [] + } + ], + "platform_get_current_account": [ + { + "args": { + "platformId": "steam" + }, + "result": "76561198100000001" + }, + { + "args": { + "platformId": "riot" + }, + "result": "riot-1" + }, + { + "args": { + "platformId": "roblox" + }, + "result": "" + } + ], + "platform_get_path": [ + { + "args": {}, + "result": "C:\\Program Files (x86)\\Steam" + } + ], + "platform_get_startup_snapshot": [ + { + "args": { + "platformId": "steam" + }, + "result": { + "accounts": [ + { + "steam_id": "76561198100000001", + "account_name": "mainacct", + "persona_name": "main", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198100000002", + "account_name": "no_avatar_here", + "persona_name": "no avatar", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198100000003", + "account_name": "banned_one", + "persona_name": "vac banned", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198100000004", + "account_name": "trade_locked", + "persona_name": "trade banned", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198100000005", + "account_name": "overflow_test_account_name", + "persona_name": "a persona name long enough to overflow every card layout", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198100000006", + "account_name": "never_used", + "persona_name": "never logged in", + "last_login_at": null + }, + { + "steam_id": "76561198100000007", + "account_name": "nested_one", + "persona_name": "nested account", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561198100000008", + "account_name": "nested_two", + "persona_name": "deeply nested", + "last_login_at": 1787198400 + } + ], + "currentAccount": "76561198100000001" + } + }, + { + "args": { + "platformId": "riot" + }, + "result": { + "profiles": [ + { + "id": "riot-1", + "label": "ready", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "capturing", + "snapshot_state": "capturing", + "last_used_at": null + }, + { + "id": "riot-3", + "label": "awaiting", + "snapshot_state": "awaiting_capture", + "last_used_at": null + }, + { + "id": "riot-4", + "label": "setup pending", + "snapshot_state": "setup_pending", + "last_used_at": null + } + ], + "currentProfile": "riot-1" + } + }, + { + "args": { + "platformId": "roblox" + }, + "result": { + "accounts": [], + "currentAccount": "" + } + } + ], + "platform_set_account_label": [ + { + "args": {}, + "result": null + } + ], + "reload_user_platforms": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "roblox_check_sessions": [ + { + "args": {}, + "result": [] + } + ], + "roblox_get_profile_info": [], + "save_client_storage_store": [ + { + "args": {}, + "result": null + } + ], + "steam_get_account_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_copyable_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_player_bans": [ + { + "args": { + "steamIds": [ + "76561198100000001", + "76561198100000002", + "76561198100000003", + "76561198100000004", + "76561198100000005", + "76561198100000006", + "76561198100000007", + "76561198100000008" + ] + }, + "result": [ + { + "steam_id": "76561198100000001", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000002", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000003", + "community_banned": false, + "vac_banned": true, + "number_of_vac_bans": 1, + "days_since_last_ban": 412, + "number_of_game_bans": 1, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000004", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "banned" + }, + { + "steam_id": "76561198100000005", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000006", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000007", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000008", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + } + ] + } + ], + "steam_get_profile_info": [ + { + "args": { + "accountId": "76561198100000001" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000002" + }, + "result": { + "avatar_url": null, + "display_name": "no avatar", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000003" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "vac banned", + "vac_banned": true, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000004" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "trade banned", + "vac_banned": false, + "trade_ban_state": "banned" + } + }, + { + "args": { + "accountId": "76561198100000005" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "a persona name long enough to overflow every card layout", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000006" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "never logged in", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000007" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "nested account", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000008" + }, + "result": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "deeply nested", + "vac_banned": false, + "trade_ban_state": "none" + } + } + ], + "steam_get_profile_infos": [ + { + "args": { + "accountIds": [ + "76561198100000001", + "76561198100000002", + "76561198100000003", + "76561198100000004", + "76561198100000005", + "76561198100000006", + "76561198100000007", + "76561198100000008" + ] + }, + "result": { + "76561198100000001": { + "avatar_url": "crates/accshift-egui/parity/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000002": { + "avatar_url": null, + "display_name": "no avatar", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000003": { + "avatar_url": "crates/accshift-egui/parity/avatars/3.svg", + "display_name": "vac banned", + "vac_banned": true, + "trade_ban_state": "none" + }, + "76561198100000004": { + "avatar_url": "crates/accshift-egui/parity/avatars/4.svg", + "display_name": "trade banned", + "vac_banned": false, + "trade_ban_state": "banned" + }, + "76561198100000005": { + "avatar_url": "crates/accshift-egui/parity/avatars/5.svg", + "display_name": "a persona name long enough to overflow every card layout", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000006": { + "avatar_url": "crates/accshift-egui/parity/avatars/6.svg", + "display_name": "never logged in", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000007": { + "avatar_url": "crates/accshift-egui/parity/avatars/7.svg", + "display_name": "nested account", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000008": { + "avatar_url": "crates/accshift-egui/parity/avatars/2.svg", + "display_name": "deeply nested", + "vac_banned": false, + "trade_ban_state": "none" + } + } + } + ], + "steam_has_api_key": [ + { + "args": {}, + "result": true + } + ], + "steam_open_api_key_page": [ + { + "args": {}, + "result": null + } + ], + "steam_open_userdata": [ + { + "args": {}, + "result": null + } + ], + "telemetry_get_state": [ + { + "args": {}, + "result": { + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": true + } + } + ] + }, + "skipped": { + "pin_lock": "PIN session state", + "pin_unlock": "PIN session state, reads the live settings store", + "platform_switch_account": "changes the current account after switchDelayMs; modelled from spec" + } +} diff --git a/crates/accshift-egui/parity/diff.mjs b/crates/accshift-egui/parity/diff.mjs new file mode 100644 index 00000000..0ba2348d --- /dev/null +++ b/crates/accshift-egui/parity/diff.mjs @@ -0,0 +1,409 @@ +// Pixel diff of the parity bench: reference frames (ref.mjs) against egui +// frames. Node and ffmpeg only (ffmpeg on PATH, or FFMPEG=). +// +// node diff.mjs --out +// node diff.mjs --batch --out +// ... --region x,y,w,h [--scale ] +// +// Both images are decoded to raw RGBA by ffmpeg (PNG or PAM in), then each is +// composited over black so a transparent pixel compares as what an opaque +// window would show. Per shot: +// mae mean absolute error over R, G and B, in 0-255 levels +// psnr 10 log10(255^2 / MSE) over R, G and B, in dB (99 when identical) +// over8 percent of pixels whose largest channel delta exceeds 8 +// over24 same, above 24 +// worst the 16x16 tile (aligned on a 16 px grid, in image pixels) with the +// highest mean error: x, y and its mae +// and two PNGs: -heat.png (largest channel delta per pixel, x4, on a +// black to red to yellow to white ramp) and -side.png (reference on +// top, other below). When the sizes differ, the overlapping top-left area is +// compared and the shot is flagged. +// +// `--region x,y,w,h` limits every number and both PNGs to that rectangle, in +// CSS px of the viewport. It is scaled by each shot's scale: the scene's +// `viewport.scale` (scenes/.json next to this file) in batch mode, +// `--scale` (default 1) for a single pair. The worst tile stays in image +// pixels of the whole shot. +// +// Batch mode pairs shots through /manifest.json (written by ref.mjs): +// //.png|pam first, then /.png|pam for +// shots whose scene step names an alias (the spike's `--capture` PAM names). +// Without a manifest, every //.png is tried. Writes +// /report.json and /report.md, one row per shot, and the images +// under //. + +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { basename, dirname, extname, join, relative, resolve } from "node:path"; + +const FFMPEG = process.env.FFMPEG ?? "ffmpeg"; + +function headerSize(file) { + const buf = readFileSync(file); + if (buf.subarray(1, 4).toString("latin1") === "PNG") { + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; + } + if (buf.subarray(0, 2).toString("latin1") === "P7") { + const head = buf.subarray(0, 256).toString("latin1"); + const width = Number(head.match(/WIDTH (\d+)/)?.[1]); + const height = Number(head.match(/HEIGHT (\d+)/)?.[1]); + if (width && height) return { width, height }; + } + throw new Error(`${file}: not a PNG or PAM`); +} + +// ffmpeg takes about two seconds to start on Windows, so each shot costs two +// runs: one decodes both inputs, one encodes both outputs. Raw frames go +// through a temp folder. +function withTemp(job) { + const dir = mkdtempSync(join(tmpdir(), "parity-diff-")); + try { + return job(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** RGBA pixels of PNG or PAM files, through one ffmpeg run. */ +export function decode(...files) { + return withTemp((dir) => { + const args = ["-v", "error", "-y"]; + for (const f of files) args.push("-i", f); + files.forEach((_, i) => + args.push("-map", `${i}:v`, "-f", "rawvideo", "-pix_fmt", "rgba", join(dir, `${i}.rgba`)), + ); + execFileSync(FFMPEG, args, { windowsHide: true, stdio: ["ignore", "ignore", "pipe"] }); + return files.map((file, i) => { + const { width, height } = headerSize(file); + const data = readFileSync(join(dir, `${i}.rgba`)); + if (data.length !== width * height * 4) { + throw new Error(`${file}: ffmpeg gave ${data.length} bytes for ${width}x${height}`); + } + return { width, height, data }; + }); + }); +} + +/** Write RGBA buffers as PNGs, through one ffmpeg run. */ +function encodePngs(images) { + withTemp((dir) => { + const args = ["-v", "error", "-y"]; + images.forEach(({ file, width, height, rgba }, i) => { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(join(dir, `${i}.rgba`), rgba); + args.push( + "-f", + "rawvideo", + "-pix_fmt", + "rgba", + "-s", + `${width}x${height}`, + "-i", + join(dir, `${i}.rgba`), + ); + }); + images.forEach(({ file }, i) => args.push("-map", `${i}:v`, "-frames:v", "1", file)); + execFileSync(FFMPEG, args, { windowsHide: true, stdio: ["ignore", "ignore", "pipe"] }); + }); +} + +/** RGB of every pixel, composited over black, 3 bytes a pixel. */ +function overBlack(img) { + const n = img.width * img.height; + const rgb = new Uint8Array(n * 3); + for (let i = 0; i < n; i++) { + const alpha = img.data[i * 4 + 3]; + for (let c = 0; c < 3; c++) rgb[i * 3 + c] = Math.round((img.data[i * 4 + c] * alpha) / 255); + } + return rgb; +} + +function ramp(v) { + // 0 black, 1/3 red, 2/3 yellow, 1 white. + const t = Math.min(1, v / 255); + const r = Math.min(1, t * 3); + const g = Math.min(1, Math.max(0, t * 3 - 1)); + const b = Math.min(1, Math.max(0, t * 3 - 2)); + return [r * 255, g * 255, b * 255]; +} + +/** `img` cut to `rect` (image pixels), clipped to the image. */ +function crop(img, rect) { + const x0 = Math.max(0, Math.min(img.width, rect.x)); + const y0 = Math.max(0, Math.min(img.height, rect.y)); + const width = Math.max(0, Math.min(img.width, rect.x + rect.w) - x0); + const height = Math.max(0, Math.min(img.height, rect.y + rect.h) - y0); + const data = Buffer.alloc(width * height * 4); + for (let y = 0; y < height; y++) { + const from = ((y0 + y) * img.width + x0) * 4; + img.data.copy(data, y * width * 4, from, from + width * 4); + } + return { width, height, data }; +} + +/** `--region` in CSS px to image pixels at `scale`. */ +export function scaleRegion(region, scale) { + if (!region) return null; + const [x, y, w, h] = region; + const x0 = Math.round(x * scale); + const y0 = Math.round(y * scale); + return { + x: x0, + y: y0, + w: Math.round((x + w) * scale) - x0, + h: Math.round((y + h) * scale) - y0, + }; +} + +export function compare(refFile, otherFile, heatFile, sideFile, region = null) { + let [a, b] = decode(refFile, otherFile); + if (region) { + a = crop(a, region); + b = crop(b, region); + } + a.rgb = overBlack(a); + b.rgb = overBlack(b); + const width = Math.min(a.width, b.width); + const height = Math.min(a.height, b.height); + const sizeMatch = a.width === b.width && a.height === b.height; + const heat = Buffer.alloc(width * height * 4); + const tilesX = Math.ceil(width / 16); + const tilesY = Math.ceil(height / 16); + const tileSum = new Float64Array(tilesX * tilesY); + const tileCount = new Uint32Array(tilesX * tilesY); + let absSum = 0; + let sqSum = 0; + let over8 = 0; + let over24 = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const ia = y * a.width + x; + const ib = y * b.width + x; + let max = 0; + let abs = 0; + for (let c = 0; c < 3; c++) { + const d = Math.abs(a.rgb[ia * 3 + c] - b.rgb[ib * 3 + c]); + abs += d; + sqSum += d * d; + if (d > max) max = d; + } + absSum += abs; + if (max > 8) over8++; + if (max > 24) over24++; + const t = Math.floor(y / 16) * tilesX + Math.floor(x / 16); + tileSum[t] += abs / 3; + tileCount[t]++; + const [r, g, bl] = ramp(max * 4); + const o = (y * width + x) * 4; + heat[o] = r; + heat[o + 1] = g; + heat[o + 2] = bl; + heat[o + 3] = 255; + } + } + const n = width * height; + const mse = sqSum / (n * 3); + let worst = 0; + for (let t = 1; t < tileSum.length; t++) { + if (tileSum[t] / tileCount[t] > tileSum[worst] / tileCount[worst]) worst = t; + } + + // Reference on top, other below, both over black, on the wider canvas. + const sw = Math.max(a.width, b.width); + const side = Buffer.alloc(sw * (a.height + b.height) * 4); + const blit = (img, top) => { + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + const i = y * img.width + x; + const o = ((top + y) * sw + x) * 4; + side[o] = img.rgb[i * 3]; + side[o + 1] = img.rgb[i * 3 + 1]; + side[o + 2] = img.rgb[i * 3 + 2]; + side[o + 3] = 255; + } + } + }; + blit(a, 0); + blit(b, a.height); + encodePngs([ + { file: heatFile, width, height, rgba: heat }, + { file: sideFile, width: sw, height: a.height + b.height, rgba: side }, + ]); + + const round = (v, d = 2) => Math.round(v * 10 ** d) / 10 ** d; + return { + width, + height, + sizeMatch, + refSize: [a.width, a.height], + otherSize: [b.width, b.height], + mae: round(absSum / (n * 3), 3), + psnr: mse === 0 ? 99 : round(10 * Math.log10((255 * 255) / mse)), + over8: round((over8 / n) * 100), + over24: round((over24 / n) * 100), + ...(region ? { region } : {}), + worst: { + x: (worst % tilesX) * 16 + (region?.x ?? 0), + y: Math.floor(worst / tilesX) * 16 + (region?.y ?? 0), + mae: round(tileSum[worst] / tileCount[worst]), + }, + }; +} + +function findOther(otherDir, stems) { + for (const stem of stems) { + for (const ext of [".png", ".pam"]) { + const p = join(otherDir, stem + ext); + if (existsSync(p)) return p; + } + } + return null; +} + +function refShots(refDir) { + const manifest = join(refDir, "manifest.json"); + if (existsSync(manifest)) return JSON.parse(readFileSync(manifest, "utf8")); + const shots = []; + for (const scene of readdirSync(refDir)) { + const dir = join(refDir, scene); + if (!statSync(dir).isDirectory() || scene.startsWith(".")) continue; + for (const f of readdirSync(dir)) { + if (extname(f) === ".png") + shots.push({ scene, shot: basename(f, ".png"), file: `${scene}/${f}` }); + } + } + return shots; +} + +/** `viewport.scale` of a scene file next to this script, or null. */ +function sceneScale(scene) { + const file = join(dirname(fileURLToPath(import.meta.url)), "scenes", `${scene}.json`); + if (!existsSync(file)) return null; + return JSON.parse(readFileSync(file, "utf8")).viewport?.scale ?? 1.5; +} + +function batch(refDir, otherDir, out, region) { + const rows = []; + for (const s of refShots(refDir)) { + let rect = null; + if (region) { + const scale = sceneScale(s.scene); + if (scale === null) { + console.log( + `${s.scene}/${s.shot}: no scenes/${s.scene}.json for the region scale, skipped`, + ); + rows.push({ scene: s.scene, shot: s.shot, missing: true }); + continue; + } + rect = scaleRegion(region, scale); + } + const other = findOther(otherDir, [`${s.scene}/${s.shot}`, ...(s.alias ? [s.alias] : [])]); + const row = { scene: s.scene, shot: s.shot, ref: join(refDir, s.file), other }; + if (!other) { + row.missing = true; + console.log(`${s.scene}/${s.shot}: no counterpart in ${otherDir}`); + } else { + Object.assign( + row, + compare( + row.ref, + other, + join(out, s.scene, `${s.shot}-heat.png`), + join(out, s.scene, `${s.shot}-side.png`), + rect, + ), + ); + console.log( + `${s.scene}/${s.shot}: psnr ${row.psnr} dB, mae ${row.mae}, >8 ${row.over8}%, >24 ${row.over24}%`, + ); + } + rows.push(row); + } + const done = rows.filter((r) => !r.missing); + const mean = (k) => + done.length ? Math.round((done.reduce((s, r) => s + r[k], 0) / done.length) * 100) / 100 : null; + const summary = { + shots: rows.length, + compared: done.length, + meanPsnr: mean("psnr"), + meanMae: mean("mae"), + }; + writeFileSync( + join(out, "report.json"), + JSON.stringify({ refDir, otherDir, region, summary, rows }, null, 2) + "\n", + ); + + const rel = (p) => relative(out, p).split("\\").join("/"); + const md = [ + `# Parity diff`, + "", + `Reference \`${refDir}\` against \`${otherDir}\`: ${done.length} of ${rows.length} shots compared, mean PSNR ${summary.meanPsnr} dB, mean MAE ${summary.meanMae}.${region ? ` Region ${region.join(",")} (CSS px).` : ""}`, + "", + "| Shot | Other | PSNR (dB) | MAE | > 8 (%) | > 24 (%) | Worst 16x16 tile | Images |", + "| --- | --- | ---: | ---: | ---: | ---: | --- | --- |", + ...rows.map((r) => + r.missing + ? `| ${r.scene}/${r.shot} | missing | | | | | | |` + : `| ${r.scene}/${r.shot}${r.sizeMatch ? "" : ` (size ${r.refSize.join("x")} vs ${r.otherSize.join("x")})`} | ${basename(r.other)} | ${r.psnr} | ${r.mae} | ${r.over8} | ${r.over24} | ${r.worst.x},${r.worst.y} (mae ${r.worst.mae}) | [heat](${rel(join(out, r.scene, `${r.shot}-heat.png`))}), [side](${rel(join(out, r.scene, `${r.shot}-side.png`))}) |`, + ), + "", + ].join("\n"); + writeFileSync(join(out, "report.md"), md); + console.log( + `${done.length}/${rows.length} shots, mean PSNR ${summary.meanPsnr} dB -> ${join(out, "report.md")}`, + ); + return summary; +} + +function main() { + const argv = process.argv.slice(2); + let out = null; + let isBatch = false; + let region = null; + let scale = 1; + const pos = []; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--out") out = argv[++i]; + else if (argv[i] === "--batch") isBatch = true; + else if (argv[i] === "--region") region = String(argv[++i]).split(",").map(Number); + else if (argv[i] === "--scale") scale = Number(argv[++i]); + else pos.push(argv[i]); + } + const badRegion = region && (region.length !== 4 || region.some((v) => !Number.isFinite(v))); + if (!out || pos.length !== 2 || badRegion || !(scale > 0)) { + console.error( + "usage: diff.mjs --out [--region x,y,w,h --scale s] | diff.mjs --batch --out [--region x,y,w,h]", + ); + process.exit(2); + } + out = resolve(out); + mkdirSync(out, { recursive: true }); + const [ref, other] = pos.map((p) => resolve(p)); + if (isBatch) { + const summary = batch(ref, other, out, region); + if (summary.compared === 0) process.exitCode = 1; + return; + } + const stem = basename(ref, extname(ref)); + const result = compare( + ref, + other, + join(out, `${stem}-heat.png`), + join(out, `${stem}-side.png`), + scaleRegion(region, scale), + ); + console.log(JSON.stringify(result)); +} + +main(); diff --git a/crates/accshift-egui/parity/fragments/glyph-check.html b/crates/accshift-egui/parity/fragments/glyph-check.html new file mode 100644 index 00000000..0beb2e65 --- /dev/null +++ b/crates/accshift-egui/parity/fragments/glyph-check.html @@ -0,0 +1,81 @@ + +
+ + +
+

+ W e g h С м н к 账 号 设 置 +

+

+ W e g h С м н к 账 号 设 置 +

+

+ W e g h С м н к 账 号 设 置 +

+

+ W e g h С м н к 账 号 设 置 +

+
diff --git a/crates/accshift-egui/parity/fragments/gradients.html b/crates/accshift-egui/parity/fragments/gradients.html new file mode 100644 index 00000000..7ffb9dfb --- /dev/null +++ b/crates/accshift-egui/parity/fragments/gradients.html @@ -0,0 +1,59 @@ + +
+
+
+
+
diff --git a/crates/accshift-egui/parity/fragments/inset.html b/crates/accshift-egui/parity/fragments/inset.html new file mode 100644 index 00000000..dec80fd5 --- /dev/null +++ b/crates/accshift-egui/parity/fragments/inset.html @@ -0,0 +1,68 @@ + +
+
+
+
+
diff --git a/crates/accshift-egui/parity/fragments/radius.html b/crates/accshift-egui/parity/fragments/radius.html new file mode 100644 index 00000000..5e44a248 --- /dev/null +++ b/crates/accshift-egui/parity/fragments/radius.html @@ -0,0 +1,104 @@ + +
+
+
+
+
+
+
+
+
diff --git a/crates/accshift-egui/parity/fragments/shadows.html b/crates/accshift-egui/parity/fragments/shadows.html new file mode 100644 index 00000000..e978c4fd --- /dev/null +++ b/crates/accshift-egui/parity/fragments/shadows.html @@ -0,0 +1,62 @@ + +
+
+
+
+
diff --git a/crates/accshift-egui/parity/fragments/typography-display.html b/crates/accshift-egui/parity/fragments/typography-display.html new file mode 100644 index 00000000..d64e2e10 --- /dev/null +++ b/crates/accshift-egui/parity/fragments/typography-display.html @@ -0,0 +1,328 @@ + +
+ +
+ +

+ Help make accshift better? +

+ +

+ The deal +

+ +

+ Help shape accshift +

+ +

+ FL +

+ +

+ FL +

+ +

+ Streamer +

+ +

+ Streamer mode +

+ +

+ Weight 100 账号设置 +

+ +

+ Weight 200 账号设置 +

+ +

+ Weight 300 账号设置 +

+ +

+ Weight 350 账号设置 +

+ +

+ Weight 400 账号设置 +

+ +

+ Weight 450 账号设置 +

+ +

+ Weight 500 账号设置 +

+ +

+ Weight 550 账号设置 +

+ +

+ Weight 600 账号设置 +

+ +

+ Weight 650 账号设置 +

+ +

+ Weight 700 账号设置 +

+ +

+ Weight 750 账号设置 +

+ +

+ Weight 800 账号设置 +

+ +

+ Weight 850 账号设置 +

+ +

+ Weight 900 账号设置 +

+
diff --git a/crates/accshift-egui/parity/fragments/typography.html b/crates/accshift-egui/parity/fragments/typography.html new file mode 100644 index 00000000..d6db9f42 --- /dev/null +++ b/crates/accshift-egui/parity/fragments/typography.html @@ -0,0 +1,863 @@ + +
+ +
+ +

+ i +

+ +

+ Inherited from theme +

+ +

+ Main account, do not share +

+ +

+ Level 42 progress +

+ +

+ Beta +

+ +

+ Active +

+ +

+ Click again to switch +

+ +

+ Appearance +

+ +

+ VAC banned +

+ +

+ Prime Premier 14,203 +

+ +

+ Counter-Strike 2 +

+ +

+ Not detected 128 / 280 +

+ +

+ Violet +

+ +

+ Last login 2 hours ago +

+ +

+ Open Steam profile +

+ +

+ 3 days ago +

+ +

+ Ctrl+K Connected +

+ +

+ Accounts +

+ +

+ Custom colour +

+ +

+ Note +

+ +

+ Limited account +

+ +

+ Wrong PIN, 2 tries left +

+ +

+ C:\Program Files (x86)\Steam +

+ +

+ Hide account names when you go live +

+ +

+ Nothing is sent until you agree +

+ +

+ Undo +

+ +

+ Steam +

+ +

+ Surfaces +

+ +

+ Online +

+ +

+ 17 +

+ +

+ accshift 1.0.5 +

+ +

+ Switching +

+ +

+ Commands +

+ +

+ Crash reports and anonymous usage +

+ +

+ Search accounts +

+ +

+ Added 12 March 2026 +

+ +

+ Remove this account from accshift? +

+ +

+ Could not save your choice +

+ +

+ We never see your passwords +

+ +

+ Smurfs +

+ +

+ Version 1.0.5 +

+ +

+ Recent +

+ +

+ xXSniperWolfXx +

+ +

+ New persona +

+ +

+ Steam +

+ +

+ Startup +

+ +

+ Help us find bugs faster +

+ +

+ Switch to main account +

+ +

+ Reset +

+ +

+ Frosty Lynx +

+ +

+ Edit 4 accounts +

+ +

+ Weekend squad +

+ +

+ Type a command or search +

+ +

+ Remove +

+ +

+ Settings +

+ +

+ Enter your PIN +

+ +

+ Frosty Lynx +

+ +

+ 1234 +

+ +

+ :root { --accent: #8b5cf6; } +

+ +

+ Сменить аккаунт, настройки +

+ +

+ 切换账号 设置 上次登录 +

+ +

+ 账号 文件夹 Steam +

+
diff --git a/crates/accshift-egui/parity/scenes/add-glass-dark-100.json b/crates/accshift-egui/parity/scenes/add-glass-dark-100.json new file mode 100644 index 00000000..a7feb70a --- /dev/null +++ b/crates/accshift-egui/parity/scenes/add-glass-dark-100.json @@ -0,0 +1,115 @@ +{ + "id": "add-glass-dark-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "setup": [ + { + "setupId": "setup-1", + "state": "waiting_for_client", + "accountId": "", + "accountDisplayName": "", + "errorMessage": "" + }, + { + "setupId": "setup-1", + "state": "waiting_for_login", + "accountId": "", + "accountDisplayName": "", + "errorMessage": "" + }, + { + "setupId": "setup-1", + "state": "waiting_for_login", + "accountId": "", + "accountDisplayName": "new_player", + "errorMessage": "" + }, + { + "setupId": "setup-1", + "state": "failed", + "accountId": "", + "accountDisplayName": "", + "errorMessage": "Steam closed before the sign in finished." + } + ], + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "click": [55, 18] + }, + { + "move": [430, 400] + }, + { + "wait": 700 + }, + { + "shot": "client" + }, + { + "wait": 1500 + }, + { + "shot": "login" + }, + { + "wait": 1500 + }, + { + "shot": "detected" + }, + { + "wait": 1500 + }, + { + "shot": "failed" + }, + { + "dom": "failed", + "select": ".card, .chip, .section-link, .extension-surface, .details" + }, + { + "move": [175, 358] + }, + { + "wait": 300 + }, + { + "shot": "close-hover" + }, + { + "click": [175, 358] + }, + { + "wait": 800 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/add-light-150.json b/crates/accshift-egui/parity/scenes/add-light-150.json new file mode 100644 index 00000000..24738c4f --- /dev/null +++ b/crates/accshift-egui/parity/scenes/add-light-150.json @@ -0,0 +1,115 @@ +{ + "id": "add-light-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "setup": [ + { + "setupId": "setup-1", + "state": "waiting_for_client", + "accountId": "", + "accountDisplayName": "", + "errorMessage": "" + }, + { + "setupId": "setup-1", + "state": "waiting_for_login", + "accountId": "", + "accountDisplayName": "", + "errorMessage": "" + }, + { + "setupId": "setup-1", + "state": "waiting_for_login", + "accountId": "", + "accountDisplayName": "new_player", + "errorMessage": "" + }, + { + "setupId": "setup-1", + "state": "failed", + "accountId": "", + "accountDisplayName": "", + "errorMessage": "Steam closed before the sign in finished." + } + ], + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "click": [55, 18] + }, + { + "move": [430, 400] + }, + { + "wait": 700 + }, + { + "shot": "client" + }, + { + "wait": 1500 + }, + { + "shot": "login" + }, + { + "wait": 1500 + }, + { + "shot": "detected" + }, + { + "wait": 1500 + }, + { + "shot": "failed" + }, + { + "dom": "failed", + "select": ".card, .chip, .section-link, .extension-surface, .details" + }, + { + "move": [175, 358] + }, + { + "wait": 300 + }, + { + "shot": "close-hover" + }, + { + "click": [175, 358] + }, + { + "wait": 800 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/add-list-glass-dark-100.json b/crates/accshift-egui/parity/scenes/add-list-glass-dark-100.json new file mode 100644 index 00000000..7cd56257 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/add-list-glass-dark-100.json @@ -0,0 +1,102 @@ +{ + "id": "add-list-glass-dark-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "setup": [ + { + "setupId": "setup-1", + "state": "waiting_for_client", + "accountId": "", + "accountDisplayName": "", + "errorMessage": "" + }, + { + "setupId": "setup-1", + "state": "waiting_for_login", + "accountId": "", + "accountDisplayName": "", + "errorMessage": "" + }, + { + "setupId": "setup-1", + "state": "waiting_for_login", + "accountId": "", + "accountDisplayName": "new_player", + "errorMessage": "" + }, + { + "setupId": "setup-1", + "state": "failed", + "accountId": "", + "accountDisplayName": "", + "errorMessage": "Steam closed before the sign in finished." + } + ], + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "click": [55, 18] + }, + { + "wait": 300 + }, + { + "scroll": [300, 300, 600] + }, + { + "move": [700, 400] + }, + { + "wait": 700 + }, + { + "shot": "client" + }, + { + "wait": 1500 + }, + { + "shot": "login" + }, + { + "wait": 1500 + }, + { + "shot": "detected" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/armed-glass-dark-100.json b/crates/accshift-egui/parity/scenes/armed-glass-dark-100.json new file mode 100644 index 00000000..ea72d9f8 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/armed-glass-dark-100.json @@ -0,0 +1,67 @@ +{ + "id": "armed-glass-dark-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "click": [315, 150] + }, + { + "wait": 500 + }, + { + "shot": "initials" + }, + { + "dom": "initials", + "select": ".card, .confirm-hint, .initials, .avatar, .play-overlay" + }, + { + "click": [600, 380] + }, + { + "wait": 500 + }, + { + "click": [205, 150] + }, + { + "wait": 500 + }, + { + "shot": "picture" + }, + { + "click": [600, 380] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/armed-glass-dark-150.json b/crates/accshift-egui/parity/scenes/armed-glass-dark-150.json new file mode 100644 index 00000000..2e0149af --- /dev/null +++ b/crates/accshift-egui/parity/scenes/armed-glass-dark-150.json @@ -0,0 +1,67 @@ +{ + "id": "armed-glass-dark-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "click": [315, 150] + }, + { + "wait": 500 + }, + { + "shot": "initials" + }, + { + "dom": "initials", + "select": ".card, .confirm-hint, .initials, .avatar, .play-overlay" + }, + { + "click": [600, 380] + }, + { + "wait": 500 + }, + { + "click": [205, 150] + }, + { + "wait": 500 + }, + { + "shot": "picture" + }, + { + "click": [600, 380] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/armed-light-100.json b/crates/accshift-egui/parity/scenes/armed-light-100.json new file mode 100644 index 00000000..9106ba8c --- /dev/null +++ b/crates/accshift-egui/parity/scenes/armed-light-100.json @@ -0,0 +1,67 @@ +{ + "id": "armed-light-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "click": [315, 150] + }, + { + "wait": 500 + }, + { + "shot": "initials" + }, + { + "dom": "initials", + "select": ".card, .confirm-hint, .initials, .avatar, .play-overlay" + }, + { + "click": [600, 380] + }, + { + "wait": 500 + }, + { + "click": [205, 150] + }, + { + "wait": 500 + }, + { + "shot": "picture" + }, + { + "click": [600, 380] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/armed-light-150.json b/crates/accshift-egui/parity/scenes/armed-light-150.json new file mode 100644 index 00000000..72aec10c --- /dev/null +++ b/crates/accshift-egui/parity/scenes/armed-light-150.json @@ -0,0 +1,67 @@ +{ + "id": "armed-light-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "click": [315, 150] + }, + { + "wait": 500 + }, + { + "shot": "initials" + }, + { + "dom": "initials", + "select": ".card, .confirm-hint, .initials, .avatar, .play-overlay" + }, + { + "click": [600, 380] + }, + { + "wait": 500 + }, + { + "click": [205, 150] + }, + { + "wait": 500 + }, + { + "shot": "picture" + }, + { + "click": [600, 380] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/bulk-edit-glass-dark-100.json b/crates/accshift-egui/parity/scenes/bulk-edit-glass-dark-100.json new file mode 100644 index 00000000..525582d4 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/bulk-edit-glass-dark-100.json @@ -0,0 +1,185 @@ +{ + "id": "bulk-edit-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [119, 17] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "dom": "bar", + "select": ".bar, .bar *" + }, + { + "shot": "open" + }, + { + "click": [205, 150] + }, + { + "click": [315, 150] + }, + { + "click": [205, 296] + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "selected" + }, + { + "move": [48, 417] + }, + { + "wait": 300 + }, + { + "shot": "hover-tool" + }, + { + "click": [818, 417] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "dom": "dialog", + "select": ".overlay, .overlay *" + }, + { + "shot": "settings" + }, + { + "click": [590, 134] + }, + { + "click": [590, 175] + }, + { + "click": [590, 175] + }, + { + "click": [525, 238] + }, + { + "text": "-novid" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "tri" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "down": [425, 150] + }, + { + "move": [480, 152] + }, + { + "move": [535, 150] + }, + { + "move": [645, 150] + }, + { + "up": [645, 150] + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "painted" + }, + { + "click": [291, 417] + }, + { + "wait": 300 + }, + { + "dom": "popover", + "select": ".color-popover, .color-popover *" + }, + { + "shot": "colors" + }, + { + "move": [345, 381] + }, + { + "wait": 300 + }, + { + "shot": "hover-swatch" + }, + { + "click": [345, 381] + }, + { + "move": [430, 5] + }, + { + "wait": 600 + }, + { + "shot": "color-applied" + }, + { + "click": [127, 417] + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "deselected" + }, + { + "key": "Escape" + }, + { + "wait": 500 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/bulk-edit-light-150.json b/crates/accshift-egui/parity/scenes/bulk-edit-light-150.json new file mode 100644 index 00000000..f0a7ac71 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/bulk-edit-light-150.json @@ -0,0 +1,185 @@ +{ + "id": "bulk-edit-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [119, 17] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "dom": "bar", + "select": ".bar, .bar *" + }, + { + "shot": "open" + }, + { + "click": [205, 150] + }, + { + "click": [315, 150] + }, + { + "click": [205, 296] + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "selected" + }, + { + "move": [48, 417] + }, + { + "wait": 300 + }, + { + "shot": "hover-tool" + }, + { + "click": [818, 417] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "dom": "dialog", + "select": ".overlay, .overlay *" + }, + { + "shot": "settings" + }, + { + "click": [590, 134] + }, + { + "click": [590, 175] + }, + { + "click": [590, 175] + }, + { + "click": [525, 238] + }, + { + "text": "-novid" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "tri" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "down": [425, 150] + }, + { + "move": [480, 152] + }, + { + "move": [535, 150] + }, + { + "move": [645, 150] + }, + { + "up": [645, 150] + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "painted" + }, + { + "click": [291, 417] + }, + { + "wait": 300 + }, + { + "dom": "popover", + "select": ".color-popover, .color-popover *" + }, + { + "shot": "colors" + }, + { + "move": [345, 381] + }, + { + "wait": 300 + }, + { + "shot": "hover-swatch" + }, + { + "click": [345, 381] + }, + { + "move": [430, 5] + }, + { + "wait": 600 + }, + { + "shot": "color-applied" + }, + { + "click": [127, 417] + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "deselected" + }, + { + "key": "Escape" + }, + { + "wait": 500 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardext-glass-dark-100.json b/crates/accshift-egui/parity/scenes/cardext-glass-dark-100.json new file mode 100644 index 00000000..d3953232 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardext-glass-dark-100.json @@ -0,0 +1,116 @@ +{ + "id": "cardext-glass-dark-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "client.account-card-notes": { + "76561198100000001": "Main account, keep the trade lock in mind", + "76561198100000006": "Fresh account for the summer sale" + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "dom": "hover-main", + "select": ".card-shell, .card, .extension-surface, .details, .section, .section-title, .section-text, .section-line, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [425, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-vac" + }, + { + "dom": "hover-vac", + "select": ".card-shell, .card, .extension-surface, .details, .section, .section-title, .section-text, .section-line, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [535, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-trade" + }, + { + "dom": "hover-trade", + "select": ".card-shell, .card, .extension-surface, .details, .section, .section-title, .section-text, .section-line, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [755, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-edge" + }, + { + "dom": "hover-edge", + "select": ".card-shell, .card, .extension-surface, .details, .section, .section-title, .section-text, .section-line, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardext-glass-dark-150.json b/crates/accshift-egui/parity/scenes/cardext-glass-dark-150.json new file mode 100644 index 00000000..3298970c --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardext-glass-dark-150.json @@ -0,0 +1,100 @@ +{ + "id": "cardext-glass-dark-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "client.account-card-notes": { + "76561198100000001": "Main account, keep the trade lock in mind", + "76561198100000006": "Fresh account for the summer sale" + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [425, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-vac" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [535, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-trade" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [755, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-edge" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardext-light-100.json b/crates/accshift-egui/parity/scenes/cardext-light-100.json new file mode 100644 index 00000000..9aafe5cb --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardext-light-100.json @@ -0,0 +1,116 @@ +{ + "id": "cardext-light-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "client.account-card-notes": { + "76561198100000001": "Main account, keep the trade lock in mind", + "76561198100000006": "Fresh account for the summer sale" + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "dom": "hover-main", + "select": ".card-shell, .card, .extension-surface, .details, .section, .section-title, .section-text, .section-line, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [425, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-vac" + }, + { + "dom": "hover-vac", + "select": ".card-shell, .card, .extension-surface, .details, .section, .section-title, .section-text, .section-line, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [535, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-trade" + }, + { + "dom": "hover-trade", + "select": ".card-shell, .card, .extension-surface, .details, .section, .section-title, .section-text, .section-line, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [755, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-edge" + }, + { + "dom": "hover-edge", + "select": ".card-shell, .card, .extension-surface, .details, .section, .section-title, .section-text, .section-line, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardext-light-150.json b/crates/accshift-egui/parity/scenes/cardext-light-150.json new file mode 100644 index 00000000..676a10fe --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardext-light-150.json @@ -0,0 +1,100 @@ +{ + "id": "cardext-light-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "client.account-card-notes": { + "76561198100000001": "Main account, keep the trade lock in mind", + "76561198100000006": "Fresh account for the summer sale" + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [425, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-vac" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [535, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-trade" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [755, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-edge" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardfocus-ban-glass-dark-150.json b/crates/accshift-egui/parity/scenes/cardfocus-ban-glass-dark-150.json new file mode 100644 index 00000000..d049081f --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardfocus-ban-glass-dark-150.json @@ -0,0 +1,124 @@ +{ + "id": "cardfocus-ban-glass-dark-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "client.account-card-notes": { + "76561198100000001": "Main account, keep the trade lock in mind", + "76561198100000006": "Fresh account for the summer sale" + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "key": "ArrowRight" + }, + { + "wait": 150 + }, + { + "key": "ArrowRight" + }, + { + "wait": 150 + }, + { + "key": "ArrowRight" + }, + { + "wait": 150 + }, + { + "key": "ArrowRight" + }, + { + "wait": 300 + }, + { + "shot": "kb-vac" + }, + { + "move": [425, 150] + }, + { + "wait": 600 + }, + { + "shot": "kb-vac-hover" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "key": "ArrowRight" + }, + { + "wait": 300 + }, + { + "shot": "kb-trade" + }, + { + "key": "ArrowLeft" + }, + { + "wait": 150 + }, + { + "key": "ArrowLeft" + }, + { + "wait": 150 + }, + { + "key": "ArrowLeft" + }, + { + "wait": 300 + }, + { + "shot": "kb-main" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "kb-main-hover" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardfocus-ban-light-100.json b/crates/accshift-egui/parity/scenes/cardfocus-ban-light-100.json new file mode 100644 index 00000000..07c700cf --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardfocus-ban-light-100.json @@ -0,0 +1,124 @@ +{ + "id": "cardfocus-ban-light-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "client.account-card-notes": { + "76561198100000001": "Main account, keep the trade lock in mind", + "76561198100000006": "Fresh account for the summer sale" + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "key": "ArrowRight" + }, + { + "wait": 150 + }, + { + "key": "ArrowRight" + }, + { + "wait": 150 + }, + { + "key": "ArrowRight" + }, + { + "wait": 150 + }, + { + "key": "ArrowRight" + }, + { + "wait": 300 + }, + { + "shot": "kb-vac" + }, + { + "move": [425, 150] + }, + { + "wait": 600 + }, + { + "shot": "kb-vac-hover" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "key": "ArrowRight" + }, + { + "wait": 300 + }, + { + "shot": "kb-trade" + }, + { + "key": "ArrowLeft" + }, + { + "wait": 150 + }, + { + "key": "ArrowLeft" + }, + { + "wait": 150 + }, + { + "key": "ArrowLeft" + }, + { + "wait": 300 + }, + { + "shot": "kb-main" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "kb-main-hover" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardfocus-glass-dark-100.json b/crates/accshift-egui/parity/scenes/cardfocus-glass-dark-100.json new file mode 100644 index 00000000..d196bcd2 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardfocus-glass-dark-100.json @@ -0,0 +1,125 @@ +{ + "id": "cardfocus-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.0 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "key": "ArrowRight" + }, + { + "wait": 250 + }, + { + "shot": "first" + }, + { + "key": "ArrowRight" + }, + { + "key": "ArrowDown" + }, + { + "wait": 250 + }, + { + "shot": "down" + }, + { + "key": "Delete" + }, + { + "wait": 350 + }, + { + "shot": "menu" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "scrolled" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "shot": "cleared" + }, + { + "key": "ArrowRight" + }, + { + "wait": 300 + }, + { + "shot": "back" + }, + { + "key": "F2" + }, + { + "wait": 350 + }, + { + "shot": "rename" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "key": "Ctrl+Shift+l" + }, + { + "wait": 700 + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "list" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardfocus-glass-dark-150.json b/crates/accshift-egui/parity/scenes/cardfocus-glass-dark-150.json new file mode 100644 index 00000000..fea992cf --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardfocus-glass-dark-150.json @@ -0,0 +1,125 @@ +{ + "id": "cardfocus-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "key": "ArrowRight" + }, + { + "wait": 250 + }, + { + "shot": "first" + }, + { + "key": "ArrowRight" + }, + { + "key": "ArrowDown" + }, + { + "wait": 250 + }, + { + "shot": "down" + }, + { + "key": "Delete" + }, + { + "wait": 350 + }, + { + "shot": "menu" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "scrolled" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "shot": "cleared" + }, + { + "key": "ArrowRight" + }, + { + "wait": 300 + }, + { + "shot": "back" + }, + { + "key": "F2" + }, + { + "wait": 350 + }, + { + "shot": "rename" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "key": "Ctrl+Shift+l" + }, + { + "wait": 700 + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "list" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardfocus-light-100.json b/crates/accshift-egui/parity/scenes/cardfocus-light-100.json new file mode 100644 index 00000000..2af31f29 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardfocus-light-100.json @@ -0,0 +1,125 @@ +{ + "id": "cardfocus-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.0 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "key": "ArrowRight" + }, + { + "wait": 250 + }, + { + "shot": "first" + }, + { + "key": "ArrowRight" + }, + { + "key": "ArrowDown" + }, + { + "wait": 250 + }, + { + "shot": "down" + }, + { + "key": "Delete" + }, + { + "wait": 350 + }, + { + "shot": "menu" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "scrolled" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "shot": "cleared" + }, + { + "key": "ArrowRight" + }, + { + "wait": 300 + }, + { + "shot": "back" + }, + { + "key": "F2" + }, + { + "wait": 350 + }, + { + "shot": "rename" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "key": "Ctrl+Shift+l" + }, + { + "wait": 700 + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "list" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardfocus-light-150.json b/crates/accshift-egui/parity/scenes/cardfocus-light-150.json new file mode 100644 index 00000000..613f3b08 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardfocus-light-150.json @@ -0,0 +1,125 @@ +{ + "id": "cardfocus-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "key": "ArrowRight" + }, + { + "wait": 250 + }, + { + "shot": "first" + }, + { + "key": "ArrowRight" + }, + { + "key": "ArrowDown" + }, + { + "wait": 250 + }, + { + "shot": "down" + }, + { + "key": "Delete" + }, + { + "wait": 350 + }, + { + "shot": "menu" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "scrolled" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "shot": "cleared" + }, + { + "key": "ArrowRight" + }, + { + "wait": 300 + }, + { + "shot": "back" + }, + { + "key": "F2" + }, + { + "wait": 350 + }, + { + "shot": "rename" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "key": "Ctrl+Shift+l" + }, + { + "wait": 700 + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "list" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardfocus-sections-glass-dark-100.json b/crates/accshift-egui/parity/scenes/cardfocus-sections-glass-dark-100.json new file mode 100644 index 00000000..a64fe59b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardfocus-sections-glass-dark-100.json @@ -0,0 +1,185 @@ +{ + "id": "cardfocus-sections-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "key": "ArrowRight" + }, + { + "wait": 250 + }, + { + "shot": "kb-first" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "kb-down" + }, + { + "key": "ArrowDown" + }, + { + "wait": 400 + }, + { + "shot": "kb-down2" + }, + { + "key": "ArrowUp" + }, + { + "wait": 150 + }, + { + "key": "ArrowUp" + }, + { + "wait": 400 + }, + { + "shot": "kb-up" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cardfocus-sections-light-150.json b/crates/accshift-egui/parity/scenes/cardfocus-sections-light-150.json new file mode 100644 index 00000000..f7b502df --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cardfocus-sections-light-150.json @@ -0,0 +1,185 @@ +{ + "id": "cardfocus-sections-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "key": "ArrowRight" + }, + { + "wait": 250 + }, + { + "shot": "kb-first" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "kb-down" + }, + { + "key": "ArrowDown" + }, + { + "wait": 400 + }, + { + "shot": "kb-down2" + }, + { + "key": "ArrowUp" + }, + { + "wait": 150 + }, + { + "key": "ArrowUp" + }, + { + "wait": 400 + }, + { + "shot": "kb-up" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cs2-glass-dark-100.json b/crates/accshift-egui/parity/scenes/cs2-glass-dark-100.json new file mode 100644 index 00000000..c2415b61 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cs2-glass-dark-100.json @@ -0,0 +1,138 @@ +{ + "id": "cs2-glass-dark-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "client.account-card-notes": { + "76561198100000001": "Main account, keep the trade lock in mind" + } + }, + "cs2": [ + { + "steamId": "76561198100000001", + "level": 24, + "xp": 3120, + "xpMax": 5000, + "caseEarned": true, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000002", + "level": 8, + "xp": 450, + "xpMax": 5000, + "caseEarned": false, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000003", + "level": 40, + "xp": 4999, + "xpMax": 5000, + "caseEarned": false, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000006", + "level": null, + "xp": null, + "xpMax": 5000, + "caseEarned": true, + "weekStartTs": null, + "lastUpdated": null + } + ], + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "dom": "root", + "select": ".username, .username-badge" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "dom": "hover-main", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [315, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-noavatar" + }, + { + "dom": "hover-noavatar", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [425, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-vac" + }, + { + "dom": "hover-vac", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cs2-glass-dark-150.json b/crates/accshift-egui/parity/scenes/cs2-glass-dark-150.json new file mode 100644 index 00000000..78fd6c33 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cs2-glass-dark-150.json @@ -0,0 +1,138 @@ +{ + "id": "cs2-glass-dark-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "client.account-card-notes": { + "76561198100000001": "Main account, keep the trade lock in mind" + } + }, + "cs2": [ + { + "steamId": "76561198100000001", + "level": 24, + "xp": 3120, + "xpMax": 5000, + "caseEarned": true, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000002", + "level": 8, + "xp": 450, + "xpMax": 5000, + "caseEarned": false, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000003", + "level": 40, + "xp": 4999, + "xpMax": 5000, + "caseEarned": false, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000006", + "level": null, + "xp": null, + "xpMax": 5000, + "caseEarned": true, + "weekStartTs": null, + "lastUpdated": null + } + ], + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "dom": "root", + "select": ".username, .username-badge" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "dom": "hover-main", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [315, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-noavatar" + }, + { + "dom": "hover-noavatar", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [425, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-vac" + }, + { + "dom": "hover-vac", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cs2-light-100.json b/crates/accshift-egui/parity/scenes/cs2-light-100.json new file mode 100644 index 00000000..5db68514 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cs2-light-100.json @@ -0,0 +1,138 @@ +{ + "id": "cs2-light-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "client.account-card-notes": { + "76561198100000001": "Main account, keep the trade lock in mind" + } + }, + "cs2": [ + { + "steamId": "76561198100000001", + "level": 24, + "xp": 3120, + "xpMax": 5000, + "caseEarned": true, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000002", + "level": 8, + "xp": 450, + "xpMax": 5000, + "caseEarned": false, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000003", + "level": 40, + "xp": 4999, + "xpMax": 5000, + "caseEarned": false, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000006", + "level": null, + "xp": null, + "xpMax": 5000, + "caseEarned": true, + "weekStartTs": null, + "lastUpdated": null + } + ], + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "dom": "root", + "select": ".username, .username-badge" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "dom": "hover-main", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [315, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-noavatar" + }, + { + "dom": "hover-noavatar", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [425, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-vac" + }, + { + "dom": "hover-vac", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/cs2-light-150.json b/crates/accshift-egui/parity/scenes/cs2-light-150.json new file mode 100644 index 00000000..89edc8fd --- /dev/null +++ b/crates/accshift-egui/parity/scenes/cs2-light-150.json @@ -0,0 +1,138 @@ +{ + "id": "cs2-light-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "client.account-card-notes": { + "76561198100000001": "Main account, keep the trade lock in mind" + } + }, + "cs2": [ + { + "steamId": "76561198100000001", + "level": 24, + "xp": 3120, + "xpMax": 5000, + "caseEarned": true, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000002", + "level": 8, + "xp": 450, + "xpMax": 5000, + "caseEarned": false, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000003", + "level": 40, + "xp": 4999, + "xpMax": 5000, + "caseEarned": false, + "weekStartTs": 1790812800, + "lastUpdated": "2026-09-30T18:00:00Z" + }, + { + "steamId": "76561198100000006", + "level": null, + "xp": null, + "xpMax": 5000, + "caseEarned": true, + "weekStartTs": null, + "lastUpdated": null + } + ], + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "dom": "root", + "select": ".username, .username-badge" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "dom": "hover-main", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [315, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-noavatar" + }, + { + "dom": "hover-noavatar", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "move": [425, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-vac" + }, + { + "dom": "hover-vac", + "select": ".card-shell, .card, .username, .username-badge, .extension-surface, .details, .section, .section-title, .section-text, .progress-row, .progress-track, .progress-fill, .progress-label, .chip" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/deeplink-errors-light-150.json b/crates/accshift-egui/parity/scenes/deeplink-errors-light-150.json new file mode 100644 index 00000000..53a69988 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/deeplink-errors-light-150.json @@ -0,0 +1,54 @@ +{ + "id": "deeplink-errors-light-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "deeplink": "accshift://launch/steam/main" + }, + { + "wait": 300 + }, + { + "deeplink": "accshift://switch/acme/main" + }, + { + "wait": 300 + }, + { + "deeplink": "accshift://switch/steam/nobody" + }, + { + "wait": 1500 + }, + { + "shot": "errors" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/deeplink-glass-dark-100.json b/crates/accshift-egui/parity/scenes/deeplink-glass-dark-100.json new file mode 100644 index 00000000..08d17d87 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/deeplink-glass-dark-100.json @@ -0,0 +1,57 @@ +{ + "id": "deeplink-glass-dark-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "deeplink": "accshift://switch/steam/no_avatar_here" + }, + { + "wait": 700 + }, + { + "shot": "confirm" + }, + { + "click": [563, 258] + }, + { + "wait": 300 + }, + { + "shot": "switching" + }, + { + "wait": 1500 + }, + { + "shot": "switched" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/deeplink-light-150.json b/crates/accshift-egui/parity/scenes/deeplink-light-150.json new file mode 100644 index 00000000..c8df52b4 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/deeplink-light-150.json @@ -0,0 +1,51 @@ +{ + "id": "deeplink-light-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "deeplink": "accshift://switch/steam/no_avatar_here" + }, + { + "wait": 700 + }, + { + "shot": "confirm" + }, + { + "click": [495, 258] + }, + { + "wait": 400 + }, + { + "shot": "cancelled" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/deeplink-off-glass-dark-100.json b/crates/accshift-egui/parity/scenes/deeplink-off-glass-dark-100.json new file mode 100644 index 00000000..0572b9aa --- /dev/null +++ b/crates/accshift-egui/parity/scenes/deeplink-off-glass-dark-100.json @@ -0,0 +1,49 @@ +{ + "id": "deeplink-off-glass-dark-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + }, + "deepLinksEnabled": false + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "deeplink": "accshift://launch/steam/main" + }, + { + "wait": 300 + }, + { + "deeplink": "accshift://switch/steam/mainacct" + }, + { + "wait": 700 + }, + { + "shot": "off" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/deeplink-tab-glass-dark-100.json b/crates/accshift-egui/parity/scenes/deeplink-tab-glass-dark-100.json new file mode 100644 index 00000000..a63177e3 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/deeplink-tab-glass-dark-100.json @@ -0,0 +1,57 @@ +{ + "id": "deeplink-tab-glass-dark-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "deeplink": "accshift://switch/riot/ready" + }, + { + "wait": 700 + }, + { + "shot": "confirm" + }, + { + "click": [563, 258] + }, + { + "wait": 300 + }, + { + "shot": "switching" + }, + { + "wait": 1500 + }, + { + "shot": "switched" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/dialog-glass-dark-100.json b/crates/accshift-egui/parity/scenes/dialog-glass-dark-100.json new file mode 100644 index 00000000..b1ffbc24 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/dialog-glass-dark-100.json @@ -0,0 +1,119 @@ +{ + "id": "dialog-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "rclick": [430, 400] + }, + { + "wait": 300 + }, + { + "click": [474, 416] + }, + { + "wait": 400 + }, + { + "shot": "input-empty" + }, + { + "dom": "input-empty", + "select": ".overlay, .overlay *" + }, + { + "text": "Ranked" + }, + { + "wait": 300 + }, + { + "shot": "input-typed" + }, + { + "dom": "input-typed", + "select": ".overlay, .overlay *" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [95, 152] + }, + { + "wait": 300 + }, + { + "click": [131, 173] + }, + { + "wait": 400 + }, + { + "shot": "rename" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [205, 152] + }, + { + "wait": 300 + }, + { + "click": [240, 377] + }, + { + "wait": 400 + }, + { + "shot": "confirm" + }, + { + "dom": "confirm", + "select": ".overlay, .overlay *" + }, + { + "key": "Tab" + }, + { + "wait": 300 + }, + { + "shot": "confirm-tab" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/dialog-glass-dark-150.json b/crates/accshift-egui/parity/scenes/dialog-glass-dark-150.json new file mode 100644 index 00000000..cfd82f92 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/dialog-glass-dark-150.json @@ -0,0 +1,119 @@ +{ + "id": "dialog-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "rclick": [430, 400] + }, + { + "wait": 300 + }, + { + "click": [474, 416] + }, + { + "wait": 400 + }, + { + "shot": "input-empty" + }, + { + "dom": "input-empty", + "select": ".overlay, .overlay *" + }, + { + "text": "Ranked" + }, + { + "wait": 300 + }, + { + "shot": "input-typed" + }, + { + "dom": "input-typed", + "select": ".overlay, .overlay *" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [95, 152] + }, + { + "wait": 300 + }, + { + "click": [131, 173] + }, + { + "wait": 400 + }, + { + "shot": "rename" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [205, 152] + }, + { + "wait": 300 + }, + { + "click": [240, 377] + }, + { + "wait": 400 + }, + { + "shot": "confirm" + }, + { + "dom": "confirm", + "select": ".overlay, .overlay *" + }, + { + "key": "Tab" + }, + { + "wait": 300 + }, + { + "shot": "confirm-tab" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/dialog-light-100.json b/crates/accshift-egui/parity/scenes/dialog-light-100.json new file mode 100644 index 00000000..4d9a3411 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/dialog-light-100.json @@ -0,0 +1,119 @@ +{ + "id": "dialog-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "rclick": [430, 400] + }, + { + "wait": 300 + }, + { + "click": [474, 416] + }, + { + "wait": 400 + }, + { + "shot": "input-empty" + }, + { + "dom": "input-empty", + "select": ".overlay, .overlay *" + }, + { + "text": "Ranked" + }, + { + "wait": 300 + }, + { + "shot": "input-typed" + }, + { + "dom": "input-typed", + "select": ".overlay, .overlay *" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [95, 152] + }, + { + "wait": 300 + }, + { + "click": [131, 173] + }, + { + "wait": 400 + }, + { + "shot": "rename" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [205, 152] + }, + { + "wait": 300 + }, + { + "click": [240, 377] + }, + { + "wait": 400 + }, + { + "shot": "confirm" + }, + { + "dom": "confirm", + "select": ".overlay, .overlay *" + }, + { + "key": "Tab" + }, + { + "wait": 300 + }, + { + "shot": "confirm-tab" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/dialog-light-150.json b/crates/accshift-egui/parity/scenes/dialog-light-150.json new file mode 100644 index 00000000..ad87c027 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/dialog-light-150.json @@ -0,0 +1,119 @@ +{ + "id": "dialog-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "rclick": [430, 400] + }, + { + "wait": 300 + }, + { + "click": [474, 416] + }, + { + "wait": 400 + }, + { + "shot": "input-empty" + }, + { + "dom": "input-empty", + "select": ".overlay, .overlay *" + }, + { + "text": "Ranked" + }, + { + "wait": 300 + }, + { + "shot": "input-typed" + }, + { + "dom": "input-typed", + "select": ".overlay, .overlay *" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [95, 152] + }, + { + "wait": 300 + }, + { + "click": [131, 173] + }, + { + "wait": 400 + }, + { + "shot": "rename" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [205, 152] + }, + { + "wait": 300 + }, + { + "click": [240, 377] + }, + { + "wait": 400 + }, + { + "shot": "confirm" + }, + { + "dom": "confirm", + "select": ".overlay, .overlay *" + }, + { + "key": "Tab" + }, + { + "wait": 300 + }, + { + "shot": "confirm-tab" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/drag-escape-light-150.json b/crates/accshift-egui/parity/scenes/drag-escape-light-150.json new file mode 100644 index 00000000..e848b948 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/drag-escape-light-150.json @@ -0,0 +1,74 @@ +{ + "id": "drag-escape-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "down": [535, 150] + }, + { + "wait": 250 + }, + { + "move": [550, 160] + }, + { + "move": [640, 300] + }, + { + "wait": 450 + }, + { + "shot": "dragging" + }, + { + "key": "Escape" + }, + { + "wait": 350 + }, + { + "shot": "cancelled" + }, + { + "up": [640, 300] + }, + { + "wait": 350 + }, + { + "down": [535, 150] + }, + { + "wait": 250 + }, + { + "move": [545, 150] + }, + { + "move": [535, 150] + }, + { + "wait": 350 + }, + { + "up": [535, 150] + }, + { + "wait": 450 + }, + { + "shot": "self-drop" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/drag-folder-glass-dark-150.json b/crates/accshift-egui/parity/scenes/drag-folder-glass-dark-150.json new file mode 100644 index 00000000..315e7c2c --- /dev/null +++ b/crates/accshift-egui/parity/scenes/drag-folder-glass-dark-150.json @@ -0,0 +1,80 @@ +{ + "id": "drag-folder-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "down": [425, 150] + }, + { + "wait": 250 + }, + { + "move": [410, 150] + }, + { + "move": [95, 150] + }, + { + "wait": 350 + }, + { + "shot": "over" + }, + { + "up": [95, 150] + }, + { + "wait": 450 + }, + { + "shot": "moved" + }, + { + "click": [95, 150] + }, + { + "wait": 600 + }, + { + "shot": "inside" + }, + { + "down": [205, 150] + }, + { + "wait": 250 + }, + { + "move": [190, 150] + }, + { + "move": [95, 150] + }, + { + "wait": 350 + }, + { + "shot": "back-over" + }, + { + "up": [95, 150] + }, + { + "wait": 450 + }, + { + "shot": "back" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/drag-folder-light-100.json b/crates/accshift-egui/parity/scenes/drag-folder-light-100.json new file mode 100644 index 00000000..d086c531 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/drag-folder-light-100.json @@ -0,0 +1,80 @@ +{ + "id": "drag-folder-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "down": [425, 150] + }, + { + "wait": 250 + }, + { + "move": [410, 150] + }, + { + "move": [95, 150] + }, + { + "wait": 350 + }, + { + "shot": "over" + }, + { + "up": [95, 150] + }, + { + "wait": 450 + }, + { + "shot": "moved" + }, + { + "click": [95, 150] + }, + { + "wait": 600 + }, + { + "shot": "inside" + }, + { + "down": [205, 150] + }, + { + "wait": 250 + }, + { + "move": [190, 150] + }, + { + "move": [95, 150] + }, + { + "wait": 350 + }, + { + "shot": "back-over" + }, + { + "up": [95, 150] + }, + { + "wait": 450 + }, + { + "shot": "back" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/drag-list-glass-dark-150.json b/crates/accshift-egui/parity/scenes/drag-list-glass-dark-150.json new file mode 100644 index 00000000..59a4d8e4 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/drag-list-glass-dark-150.json @@ -0,0 +1,74 @@ +{ + "id": "drag-list-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "down": [300, 207] + }, + { + "wait": 250 + }, + { + "move": [310, 220] + }, + { + "wait": 350 + }, + { + "shot": "lifted" + }, + { + "move": [310, 303] + }, + { + "wait": 450 + }, + { + "shot": "preview" + }, + { + "move": [310, 111] + }, + { + "wait": 450 + }, + { + "shot": "over" + }, + { + "up": [310, 111] + }, + { + "wait": 450 + }, + { + "shot": "moved" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/drag-list-light-100.json b/crates/accshift-egui/parity/scenes/drag-list-light-100.json new file mode 100644 index 00000000..8ecce909 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/drag-list-light-100.json @@ -0,0 +1,74 @@ +{ + "id": "drag-list-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "down": [300, 207] + }, + { + "wait": 250 + }, + { + "move": [310, 220] + }, + { + "wait": 350 + }, + { + "shot": "lifted" + }, + { + "move": [310, 303] + }, + { + "wait": 450 + }, + { + "shot": "preview" + }, + { + "move": [310, 111] + }, + { + "wait": 450 + }, + { + "shot": "over" + }, + { + "up": [310, 111] + }, + { + "wait": 450 + }, + { + "shot": "moved" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/drag-listsection-glass-dark-150.json b/crates/accshift-egui/parity/scenes/drag-listsection-glass-dark-150.json new file mode 100644 index 00000000..ab215210 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/drag-listsection-glass-dark-150.json @@ -0,0 +1,173 @@ +{ + "id": "drag-listsection-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "demo-folder-smurfs" + }, + { + "type": "folder", + "id": "w1a-ranked" + }, + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "down": [300, 100] + }, + { + "wait": 250 + }, + { + "move": [315, 105] + }, + { + "wait": 450 + }, + { + "shot": "lifted" + }, + { + "move": [300, 300] + }, + { + "wait": 450 + }, + { + "shot": "preview" + }, + { + "up": [300, 300] + }, + { + "wait": 450 + }, + { + "shot": "dropped" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/drag-listsection-light-100.json b/crates/accshift-egui/parity/scenes/drag-listsection-light-100.json new file mode 100644 index 00000000..d1c8abed --- /dev/null +++ b/crates/accshift-egui/parity/scenes/drag-listsection-light-100.json @@ -0,0 +1,173 @@ +{ + "id": "drag-listsection-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "demo-folder-smurfs" + }, + { + "type": "folder", + "id": "w1a-ranked" + }, + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "down": [300, 100] + }, + { + "wait": 250 + }, + { + "move": [315, 105] + }, + { + "wait": 450 + }, + { + "shot": "lifted" + }, + { + "move": [300, 300] + }, + { + "wait": 450 + }, + { + "shot": "preview" + }, + { + "up": [300, 300] + }, + { + "wait": 450 + }, + { + "shot": "dropped" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/drag-reorder-glass-dark-150.json b/crates/accshift-egui/parity/scenes/drag-reorder-glass-dark-150.json new file mode 100644 index 00000000..29769e2e --- /dev/null +++ b/crates/accshift-egui/parity/scenes/drag-reorder-glass-dark-150.json @@ -0,0 +1,50 @@ +{ + "id": "drag-reorder-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "down": [315, 150] + }, + { + "wait": 250 + }, + { + "move": [330, 150] + }, + { + "wait": 350 + }, + { + "shot": "lifted" + }, + { + "move": [530, 150] + }, + { + "wait": 450 + }, + { + "shot": "preview" + }, + { + "up": [530, 150] + }, + { + "wait": 450 + }, + { + "shot": "dropped" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/drag-reorder-light-100.json b/crates/accshift-egui/parity/scenes/drag-reorder-light-100.json new file mode 100644 index 00000000..0ea30412 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/drag-reorder-light-100.json @@ -0,0 +1,50 @@ +{ + "id": "drag-reorder-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "down": [315, 150] + }, + { + "wait": 250 + }, + { + "move": [330, 150] + }, + { + "wait": 350 + }, + { + "shot": "lifted" + }, + { + "move": [530, 150] + }, + { + "wait": 450 + }, + { + "shot": "preview" + }, + { + "up": [530, 150] + }, + { + "wait": 450 + }, + { + "shot": "dropped" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/drag-section-glass-dark-150.json b/crates/accshift-egui/parity/scenes/drag-section-glass-dark-150.json new file mode 100644 index 00000000..601266b9 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/drag-section-glass-dark-150.json @@ -0,0 +1,158 @@ +{ + "id": "drag-section-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "demo-folder-smurfs" + }, + { + "type": "folder", + "id": "w1a-ranked" + }, + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "down": [600, 180] + }, + { + "wait": 250 + }, + { + "move": [615, 185] + }, + { + "wait": 450 + }, + { + "shot": "lifted" + }, + { + "move": [600, 400] + }, + { + "wait": 450 + }, + { + "shot": "preview" + }, + { + "up": [600, 400] + }, + { + "wait": 450 + }, + { + "shot": "dropped" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/drag-section-light-100.json b/crates/accshift-egui/parity/scenes/drag-section-light-100.json new file mode 100644 index 00000000..7e20c351 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/drag-section-light-100.json @@ -0,0 +1,158 @@ +{ + "id": "drag-section-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "demo-folder-smurfs" + }, + { + "type": "folder", + "id": "w1a-ranked" + }, + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "down": [600, 180] + }, + { + "wait": 250 + }, + { + "move": [615, 185] + }, + { + "wait": 450 + }, + { + "shot": "lifted" + }, + { + "move": [600, 400] + }, + { + "wait": 450 + }, + { + "shot": "preview" + }, + { + "up": [600, 400] + }, + { + "wait": 450 + }, + { + "shot": "dropped" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/glyph-check-100.json b/crates/accshift-egui/parity/scenes/glyph-check-100.json new file mode 100644 index 00000000..a3f90ee6 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/glyph-check-100.json @@ -0,0 +1,22 @@ +{ + "id": "glyph-check-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 400, + "height": 130, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/glyph-check.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/glyph-check-150.json b/crates/accshift-egui/parity/scenes/glyph-check-150.json new file mode 100644 index 00000000..d3b652f2 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/glyph-check-150.json @@ -0,0 +1,22 @@ +{ + "id": "glyph-check-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 400, + "height": 130, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/glyph-check.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/jpeg-glass-dark-100.json b/crates/accshift-egui/parity/scenes/jpeg-glass-dark-100.json new file mode 100644 index 00000000..12d4874d --- /dev/null +++ b/crates/accshift-egui/parity/scenes/jpeg-glass-dark-100.json @@ -0,0 +1,112 @@ +{ + "id": "jpeg-glass-dark-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "cache.steam.profiles": { + "76561198100000001": { + "url": "crates/accshift-egui/parity/avatars/photo.jpg", + "displayName": "main", + "timestamp": 4102444800000 + }, + "76561198100000003": { + "url": "crates/accshift-egui/parity/avatars/photo.jpg", + "displayName": "vac banned", + "timestamp": 4102444800000 + }, + "76561198100000004": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 4102444800000 + }, + "76561198100000005": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 4102444800000 + }, + "76561198100000006": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 4102444800000 + }, + "76561198100000007": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "nested account", + "timestamp": 4102444800000 + }, + "76561198100000008": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 4102444800000 + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "list" + }, + { + "click": [300, 159] + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "selected" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/jpeg-glass-dark-150.json b/crates/accshift-egui/parity/scenes/jpeg-glass-dark-150.json new file mode 100644 index 00000000..8c27d5a0 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/jpeg-glass-dark-150.json @@ -0,0 +1,112 @@ +{ + "id": "jpeg-glass-dark-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "cache.steam.profiles": { + "76561198100000001": { + "url": "crates/accshift-egui/parity/avatars/photo.jpg", + "displayName": "main", + "timestamp": 4102444800000 + }, + "76561198100000003": { + "url": "crates/accshift-egui/parity/avatars/photo.jpg", + "displayName": "vac banned", + "timestamp": 4102444800000 + }, + "76561198100000004": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 4102444800000 + }, + "76561198100000005": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 4102444800000 + }, + "76561198100000006": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 4102444800000 + }, + "76561198100000007": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "nested account", + "timestamp": 4102444800000 + }, + "76561198100000008": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 4102444800000 + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "list" + }, + { + "click": [300, 159] + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "selected" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/jpeg-light-100.json b/crates/accshift-egui/parity/scenes/jpeg-light-100.json new file mode 100644 index 00000000..df8246be --- /dev/null +++ b/crates/accshift-egui/parity/scenes/jpeg-light-100.json @@ -0,0 +1,112 @@ +{ + "id": "jpeg-light-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "cache.steam.profiles": { + "76561198100000001": { + "url": "crates/accshift-egui/parity/avatars/photo.jpg", + "displayName": "main", + "timestamp": 4102444800000 + }, + "76561198100000003": { + "url": "crates/accshift-egui/parity/avatars/photo.jpg", + "displayName": "vac banned", + "timestamp": 4102444800000 + }, + "76561198100000004": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 4102444800000 + }, + "76561198100000005": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 4102444800000 + }, + "76561198100000006": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 4102444800000 + }, + "76561198100000007": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "nested account", + "timestamp": 4102444800000 + }, + "76561198100000008": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 4102444800000 + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "list" + }, + { + "click": [300, 159] + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "selected" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/jpeg-light-150.json b/crates/accshift-egui/parity/scenes/jpeg-light-150.json new file mode 100644 index 00000000..7d9a65e4 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/jpeg-light-150.json @@ -0,0 +1,112 @@ +{ + "id": "jpeg-light-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + }, + "cache.steam.profiles": { + "76561198100000001": { + "url": "crates/accshift-egui/parity/avatars/photo.jpg", + "displayName": "main", + "timestamp": 4102444800000 + }, + "76561198100000003": { + "url": "crates/accshift-egui/parity/avatars/photo.jpg", + "displayName": "vac banned", + "timestamp": 4102444800000 + }, + "76561198100000004": { + "url": "crates/accshift-egui/parity/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 4102444800000 + }, + "76561198100000005": { + "url": "crates/accshift-egui/parity/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 4102444800000 + }, + "76561198100000006": { + "url": "crates/accshift-egui/parity/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 4102444800000 + }, + "76561198100000007": { + "url": "crates/accshift-egui/parity/avatars/7.svg", + "displayName": "nested account", + "timestamp": 4102444800000 + }, + "76561198100000008": { + "url": "crates/accshift-egui/parity/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 4102444800000 + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "root" + }, + { + "move": [205, 150] + }, + { + "wait": 600 + }, + { + "shot": "hover-main" + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "list" + }, + { + "click": [300, 159] + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "selected" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/liquid-100.json b/crates/accshift-egui/parity/scenes/liquid-100.json new file mode 100644 index 00000000..f1854014 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/liquid-100.json @@ -0,0 +1,45 @@ +{ + "id": "liquid-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "liquid-glass", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "grid" + }, + { + "move": [645, 150] + }, + { + "wait": 500 + }, + { + "shot": "hover" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/liquid-150.json b/crates/accshift-egui/parity/scenes/liquid-150.json new file mode 100644 index 00000000..f2d9673e --- /dev/null +++ b/crates/accshift-egui/parity/scenes/liquid-150.json @@ -0,0 +1,45 @@ +{ + "id": "liquid-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "liquid-glass", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "grid" + }, + { + "move": [645, 150] + }, + { + "wait": 500 + }, + { + "shot": "hover" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/list-glass-dark-100.json b/crates/accshift-egui/parity/scenes/list-glass-dark-100.json new file mode 100644 index 00000000..4f953415 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/list-glass-dark-100.json @@ -0,0 +1,76 @@ +{ + "id": "list-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "list" + }, + { + "dom": "list", + "select": ".list-wrapper, .list-wrapper *" + }, + { + "move": [300, 207] + }, + { + "wait": 300 + }, + { + "shot": "hover" + }, + { + "click": [300, 159] + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "selected" + }, + { + "dom": "selected", + "select": ".preview-panel, .preview-panel *" + }, + { + "click": [300, 111] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "folder" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/list-glass-dark-150.json b/crates/accshift-egui/parity/scenes/list-glass-dark-150.json new file mode 100644 index 00000000..ce777440 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/list-glass-dark-150.json @@ -0,0 +1,76 @@ +{ + "id": "list-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "list" + }, + { + "dom": "list", + "select": ".list-wrapper, .list-wrapper *" + }, + { + "move": [300, 207] + }, + { + "wait": 300 + }, + { + "shot": "hover" + }, + { + "click": [300, 159] + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "selected" + }, + { + "dom": "selected", + "select": ".preview-panel, .preview-panel *" + }, + { + "click": [300, 111] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "folder" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/list-light-100.json b/crates/accshift-egui/parity/scenes/list-light-100.json new file mode 100644 index 00000000..74ec988b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/list-light-100.json @@ -0,0 +1,76 @@ +{ + "id": "list-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "list" + }, + { + "dom": "list", + "select": ".list-wrapper, .list-wrapper *" + }, + { + "move": [300, 207] + }, + { + "wait": 300 + }, + { + "shot": "hover" + }, + { + "click": [300, 159] + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "selected" + }, + { + "dom": "selected", + "select": ".preview-panel, .preview-panel *" + }, + { + "click": [300, 111] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "folder" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/list-light-150.json b/crates/accshift-egui/parity/scenes/list-light-150.json new file mode 100644 index 00000000..4d256ec0 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/list-light-150.json @@ -0,0 +1,76 @@ +{ + "id": "list-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "list" + }, + { + "dom": "list", + "select": ".list-wrapper, .list-wrapper *" + }, + { + "move": [300, 207] + }, + { + "wait": 300 + }, + { + "shot": "hover" + }, + { + "click": [300, 159] + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "selected" + }, + { + "dom": "selected", + "select": ".preview-panel, .preview-panel *" + }, + { + "click": [300, 111] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "folder" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/list-sections-glass-dark-100.json b/crates/accshift-egui/parity/scenes/list-sections-glass-dark-100.json new file mode 100644 index 00000000..298fc72c --- /dev/null +++ b/crates/accshift-egui/parity/scenes/list-sections-glass-dark-100.json @@ -0,0 +1,171 @@ +{ + "id": "list-sections-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "sections" + }, + { + "dom": "sections", + "select": ".list-wrapper, .list-wrapper *" + }, + { + "click": [300, 270] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "collapsed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/list-sections-glass-dark-150.json b/crates/accshift-egui/parity/scenes/list-sections-glass-dark-150.json new file mode 100644 index 00000000..81bc3e6b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/list-sections-glass-dark-150.json @@ -0,0 +1,171 @@ +{ + "id": "list-sections-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "sections" + }, + { + "dom": "sections", + "select": ".list-wrapper, .list-wrapper *" + }, + { + "click": [300, 270] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "collapsed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/list-sections-light-100.json b/crates/accshift-egui/parity/scenes/list-sections-light-100.json new file mode 100644 index 00000000..001cd4ca --- /dev/null +++ b/crates/accshift-egui/parity/scenes/list-sections-light-100.json @@ -0,0 +1,171 @@ +{ + "id": "list-sections-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "sections" + }, + { + "dom": "sections", + "select": ".list-wrapper, .list-wrapper *" + }, + { + "click": [300, 270] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "collapsed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/list-sections-light-150.json b/crates/accshift-egui/parity/scenes/list-sections-light-150.json new file mode 100644 index 00000000..1bbe3569 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/list-sections-light-150.json @@ -0,0 +1,171 @@ +{ + "id": "list-sections-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "click": [816, 60] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "sections" + }, + { + "dom": "sections", + "select": ".list-wrapper, .list-wrapper *" + }, + { + "click": [300, 270] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "collapsed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/menu-glass-dark-100.json b/crates/accshift-egui/parity/scenes/menu-glass-dark-100.json new file mode 100644 index 00000000..d4e8bb81 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/menu-glass-dark-100.json @@ -0,0 +1,135 @@ +{ + "id": "menu-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "rclick": [205, 152] + }, + { + "wait": 400 + }, + { + "shot": "account" + }, + { + "dom": "account", + "select": ".context-menu, .context-menu *" + }, + { + "move": [260, 161] + }, + { + "wait": 300 + }, + { + "shot": "account-hover" + }, + { + "move": [260, 191] + }, + { + "wait": 500 + }, + { + "shot": "loader" + }, + { + "dom": "loader", + "select": ".context-menu, .context-menu *" + }, + { + "move": [260, 230] + }, + { + "wait": 500 + }, + { + "shot": "submenu" + }, + { + "dom": "submenu", + "select": ".context-menu, .context-menu *" + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "keys" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [95, 152] + }, + { + "move": [165, 223] + }, + { + "wait": 400 + }, + { + "shot": "folder" + }, + { + "dom": "folder", + "select": ".context-menu, .context-menu *" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [430, 400] + }, + { + "wait": 400 + }, + { + "shot": "background" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [755, 298] + }, + { + "wait": 400 + }, + { + "shot": "clamped" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/menu-glass-dark-150.json b/crates/accshift-egui/parity/scenes/menu-glass-dark-150.json new file mode 100644 index 00000000..0efecdf7 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/menu-glass-dark-150.json @@ -0,0 +1,135 @@ +{ + "id": "menu-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "rclick": [205, 152] + }, + { + "wait": 400 + }, + { + "shot": "account" + }, + { + "dom": "account", + "select": ".context-menu, .context-menu *" + }, + { + "move": [260, 161] + }, + { + "wait": 300 + }, + { + "shot": "account-hover" + }, + { + "move": [260, 191] + }, + { + "wait": 500 + }, + { + "shot": "loader" + }, + { + "dom": "loader", + "select": ".context-menu, .context-menu *" + }, + { + "move": [260, 230] + }, + { + "wait": 500 + }, + { + "shot": "submenu" + }, + { + "dom": "submenu", + "select": ".context-menu, .context-menu *" + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "keys" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [95, 152] + }, + { + "move": [165, 223] + }, + { + "wait": 400 + }, + { + "shot": "folder" + }, + { + "dom": "folder", + "select": ".context-menu, .context-menu *" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [430, 400] + }, + { + "wait": 400 + }, + { + "shot": "background" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [755, 298] + }, + { + "wait": 400 + }, + { + "shot": "clamped" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/menu-light-100.json b/crates/accshift-egui/parity/scenes/menu-light-100.json new file mode 100644 index 00000000..2331b9e0 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/menu-light-100.json @@ -0,0 +1,135 @@ +{ + "id": "menu-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "rclick": [205, 152] + }, + { + "wait": 400 + }, + { + "shot": "account" + }, + { + "dom": "account", + "select": ".context-menu, .context-menu *" + }, + { + "move": [260, 161] + }, + { + "wait": 300 + }, + { + "shot": "account-hover" + }, + { + "move": [260, 191] + }, + { + "wait": 500 + }, + { + "shot": "loader" + }, + { + "dom": "loader", + "select": ".context-menu, .context-menu *" + }, + { + "move": [260, 230] + }, + { + "wait": 500 + }, + { + "shot": "submenu" + }, + { + "dom": "submenu", + "select": ".context-menu, .context-menu *" + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "keys" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [95, 152] + }, + { + "move": [165, 223] + }, + { + "wait": 400 + }, + { + "shot": "folder" + }, + { + "dom": "folder", + "select": ".context-menu, .context-menu *" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [430, 400] + }, + { + "wait": 400 + }, + { + "shot": "background" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [755, 298] + }, + { + "wait": 400 + }, + { + "shot": "clamped" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/menu-light-150.json b/crates/accshift-egui/parity/scenes/menu-light-150.json new file mode 100644 index 00000000..795cffd0 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/menu-light-150.json @@ -0,0 +1,135 @@ +{ + "id": "menu-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "rclick": [205, 152] + }, + { + "wait": 400 + }, + { + "shot": "account" + }, + { + "dom": "account", + "select": ".context-menu, .context-menu *" + }, + { + "move": [260, 161] + }, + { + "wait": 300 + }, + { + "shot": "account-hover" + }, + { + "move": [260, 191] + }, + { + "wait": 500 + }, + { + "shot": "loader" + }, + { + "dom": "loader", + "select": ".context-menu, .context-menu *" + }, + { + "move": [260, 230] + }, + { + "wait": 500 + }, + { + "shot": "submenu" + }, + { + "dom": "submenu", + "select": ".context-menu, .context-menu *" + }, + { + "key": "ArrowDown" + }, + { + "key": "ArrowDown" + }, + { + "wait": 300 + }, + { + "shot": "keys" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [95, 152] + }, + { + "move": [165, 223] + }, + { + "wait": 400 + }, + { + "shot": "folder" + }, + { + "dom": "folder", + "select": ".context-menu, .context-menu *" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [430, 400] + }, + { + "wait": 400 + }, + { + "shot": "background" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "rclick": [755, 298] + }, + { + "wait": 400 + }, + { + "shot": "clamped" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/menukey-glass-dark-150.json b/crates/accshift-egui/parity/scenes/menukey-glass-dark-150.json new file mode 100644 index 00000000..ad7da224 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/menukey-glass-dark-150.json @@ -0,0 +1,68 @@ +{ + "id": "menukey-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "key": "ArrowRight" + }, + { + "wait": 250 + }, + { + "key": "ContextMenu" + }, + { + "wait": 350 + }, + { + "shot": "roving" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "click": [315, 150] + }, + { + "wait": 300 + }, + { + "key": "ContextMenu" + }, + { + "wait": 350 + }, + { + "shot": "clicked" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/menukey-light-100.json b/crates/accshift-egui/parity/scenes/menukey-light-100.json new file mode 100644 index 00000000..9a85987f --- /dev/null +++ b/crates/accshift-egui/parity/scenes/menukey-light-100.json @@ -0,0 +1,68 @@ +{ + "id": "menukey-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.0 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "key": "ArrowRight" + }, + { + "wait": 250 + }, + { + "key": "ContextMenu" + }, + { + "wait": 350 + }, + { + "shot": "roving" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "click": [315, 150] + }, + { + "wait": 300 + }, + { + "key": "ContextMenu" + }, + { + "wait": 350 + }, + { + "shot": "clicked" + }, + { + "key": "Escape" + }, + { + "wait": 250 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/onboarding-deal-glass-dark-100.json b/crates/accshift-egui/parity/scenes/onboarding-deal-glass-dark-100.json new file mode 100644 index 00000000..0c88c210 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/onboarding-deal-glass-dark-100.json @@ -0,0 +1,46 @@ +{ + "id": "onboarding-deal-glass-dark-100", + "scenario": "demo", + "os": "windows", + "onboarding": true, + "viewport": { + "width": 1000, + "height": 680, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 1500 }, + { "click": [677, 472] }, + { "wait": 400 }, + { "click": [700, 621] }, + { "wait": 300 }, + { "click": [700, 621] }, + { "wait": 300 }, + { "click": [700, 621] }, + { "wait": 300 }, + { "click": [700, 621] }, + { "move": [500, 376] }, + { "wait": 600 }, + { "shot": "hover-no" }, + { "move": [500, 457] }, + { "wait": 400 }, + { "shot": "hover-deal" }, + { "move": [500, 532] }, + { "wait": 400 }, + { "shot": "hover-refuse" }, + { "move": [280, 617] }, + { "wait": 300 }, + { "shot": "hover-back" }, + { "click": [500, 376] }, + { "move": [950, 400] }, + { "wait": 300 }, + { "shot": "rejecting" }, + { "wait": 1100 }, + { "shot": "fading" }, + { "wait": 1600 }, + { "dom": "persist-error", "select": ".modal, .modal *" }, + { "shot": "persist-error" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/onboarding-glass-dark-100.json b/crates/accshift-egui/parity/scenes/onboarding-glass-dark-100.json new file mode 100644 index 00000000..98dfbc41 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/onboarding-glass-dark-100.json @@ -0,0 +1,40 @@ +{ + "id": "onboarding-glass-dark-100", + "scenario": "demo", + "os": "windows", + "onboarding": true, + "viewport": { + "width": 1000, + "height": 680, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 1500 }, + { "shot": "welcome" }, + { "click": [677, 472] }, + { "move": [950, 400] }, + { "wait": 800 }, + { "shot": "feature-1" }, + { "click": [700, 621] }, + { "move": [950, 400] }, + { "wait": 700 }, + { "shot": "feature-2" }, + { "click": [700, 621] }, + { "move": [950, 400] }, + { "wait": 700 }, + { "dom": "feature-3", "select": ".modal *, .spotlight, .mock-ctx-menu, .mock-ctx-menu *" }, + { "shot": "feature-3" }, + { "click": [700, 621] }, + { "move": [950, 400] }, + { "wait": 700 }, + { "dom": "feature-4", "select": ".modal *, .spotlight" }, + { "shot": "feature-4" }, + { "click": [700, 621] }, + { "move": [950, 400] }, + { "wait": 1000 }, + { "dom": "deal", "select": ".modal, .modal *" }, + { "shot": "deal" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/onboarding-light-150.json b/crates/accshift-egui/parity/scenes/onboarding-light-150.json new file mode 100644 index 00000000..b8d9a39a --- /dev/null +++ b/crates/accshift-egui/parity/scenes/onboarding-light-150.json @@ -0,0 +1,40 @@ +{ + "id": "onboarding-light-150", + "scenario": "demo", + "os": "windows", + "onboarding": true, + "viewport": { + "width": 1000, + "height": 680, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { "wait": 1500 }, + { "dom": "welcome", "select": ".modal, .modal *" }, + { "shot": "welcome" }, + { "click": [677, 472] }, + { "move": [950, 400] }, + { "wait": 800 }, + { "dom": "feature-1", "select": ".modal, .modal *, .spotlight" }, + { "shot": "feature-1" }, + { "click": [700, 621] }, + { "move": [950, 400] }, + { "wait": 700 }, + { "shot": "feature-2" }, + { "click": [700, 621] }, + { "move": [950, 400] }, + { "wait": 700 }, + { "shot": "feature-3" }, + { "click": [700, 621] }, + { "move": [950, 400] }, + { "wait": 700 }, + { "shot": "feature-4" }, + { "click": [700, 621] }, + { "move": [950, 400] }, + { "wait": 1000 }, + { "dom": "deal", "select": ".modal, .modal *" }, + { "shot": "deal" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/palette-glass-dark-100.json b/crates/accshift-egui/parity/scenes/palette-glass-dark-100.json new file mode 100644 index 00000000..1488f252 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/palette-glass-dark-100.json @@ -0,0 +1,105 @@ +{ + "id": "palette-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.0 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 300 + }, + { + "shot": "idle" + }, + { + "dom": "idle", + "select": ".palette-overlay, .palette-overlay *" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 100 + }, + { + "key": "ArrowUp" + }, + { + "wait": 200 + }, + { + "shot": "last" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 100 + }, + { + "move": [430, 185] + }, + { + "wait": 200 + }, + { + "shot": "hover" + }, + { + "move": [250, 18] + }, + { + "text": "acc" + }, + { + "wait": 250 + }, + { + "shot": "typed" + }, + { + "text": "zzq" + }, + { + "wait": 250 + }, + { + "shot": "empty" + }, + { + "key": "Escape" + }, + { + "wait": 200 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/palette-glass-dark-150.json b/crates/accshift-egui/parity/scenes/palette-glass-dark-150.json new file mode 100644 index 00000000..52637cba --- /dev/null +++ b/crates/accshift-egui/parity/scenes/palette-glass-dark-150.json @@ -0,0 +1,105 @@ +{ + "id": "palette-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 300 + }, + { + "shot": "idle" + }, + { + "dom": "idle", + "select": ".palette-overlay, .palette-overlay *" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 100 + }, + { + "key": "ArrowUp" + }, + { + "wait": 200 + }, + { + "shot": "last" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 100 + }, + { + "move": [430, 185] + }, + { + "wait": 200 + }, + { + "shot": "hover" + }, + { + "move": [250, 18] + }, + { + "text": "acc" + }, + { + "wait": 250 + }, + { + "shot": "typed" + }, + { + "text": "zzq" + }, + { + "wait": 250 + }, + { + "shot": "empty" + }, + { + "key": "Escape" + }, + { + "wait": 200 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/palette-light-100.json b/crates/accshift-egui/parity/scenes/palette-light-100.json new file mode 100644 index 00000000..85915afa --- /dev/null +++ b/crates/accshift-egui/parity/scenes/palette-light-100.json @@ -0,0 +1,105 @@ +{ + "id": "palette-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.0 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 300 + }, + { + "shot": "idle" + }, + { + "dom": "idle", + "select": ".palette-overlay, .palette-overlay *" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 100 + }, + { + "key": "ArrowUp" + }, + { + "wait": 200 + }, + { + "shot": "last" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 100 + }, + { + "move": [430, 185] + }, + { + "wait": 200 + }, + { + "shot": "hover" + }, + { + "move": [250, 18] + }, + { + "text": "acc" + }, + { + "wait": 250 + }, + { + "shot": "typed" + }, + { + "text": "zzq" + }, + { + "wait": 250 + }, + { + "shot": "empty" + }, + { + "key": "Escape" + }, + { + "wait": 200 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/palette-light-150.json b/crates/accshift-egui/parity/scenes/palette-light-150.json new file mode 100644 index 00000000..67d69fd7 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/palette-light-150.json @@ -0,0 +1,105 @@ +{ + "id": "palette-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 300 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 300 + }, + { + "shot": "idle" + }, + { + "dom": "idle", + "select": ".palette-overlay, .palette-overlay *" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 100 + }, + { + "key": "ArrowUp" + }, + { + "wait": 200 + }, + { + "shot": "last" + }, + { + "key": "Escape" + }, + { + "wait": 200 + }, + { + "key": "Ctrl+k" + }, + { + "wait": 100 + }, + { + "move": [430, 185] + }, + { + "wait": 200 + }, + { + "shot": "hover" + }, + { + "move": [250, 18] + }, + { + "text": "acc" + }, + { + "wait": 250 + }, + { + "shot": "typed" + }, + { + "text": "zzq" + }, + { + "wait": 250 + }, + { + "shot": "empty" + }, + { + "key": "Escape" + }, + { + "wait": 200 + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/personas-flow-glass-dark-100.json b/crates/accshift-egui/parity/scenes/personas-flow-glass-dark-100.json new file mode 100644 index 00000000..da16095a --- /dev/null +++ b/crates/accshift-egui/parity/scenes/personas-flow-glass-dark-100.json @@ -0,0 +1,167 @@ +{ + "id": "personas-flow-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "PERSONAS": [ + { + "id": "persona-main", + "name": "Main", + "color": "", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000001" + }, + { + "platformId": "riot", + "accountId": "riot-1" + }, + { + "platformId": "roblox", + "accountId": "1000000001" + } + ] + }, + { + "id": "persona-smurfs", + "name": "Smurfs", + "color": "#8b5cf6", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000002" + }, + { + "platformId": "riot", + "accountId": "riot-2" + } + ] + }, + { + "id": "persona-duo", + "name": "Duo nights", + "color": "", + "assignments": [ + { + "platformId": "riot", + "accountId": "riot-3" + } + ] + }, + { + "id": "persona-all", + "name": "Everything at once, with a long name", + "color": "#f97316", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000004" + }, + { + "platformId": "riot", + "accountId": "riot-2" + }, + { + "platformId": "roblox", + "accountId": "1000000003" + }, + { + "platformId": "epic", + "accountId": "epic-1" + } + ] + } + ] + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [379, 18] + }, + { + "move": [600, 380] + }, + { + "wait": 1200 + }, + { + "rclick": [250, 120] + }, + { + "wait": 300 + }, + { + "shot": "menu" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "click": [100, 120] + }, + { + "wait": 400 + }, + { + "shot": "switch-confirm" + }, + { + "click": [563, 258] + }, + { + "wait": 1500 + }, + { + "shot": "switched" + }, + { + "move": [600, 380] + }, + { + "wait": 300 + }, + { + "rclick": [250, 120] + }, + { + "wait": 300 + }, + { + "click": [296, 200] + }, + { + "wait": 500 + }, + { + "shot": "recolored" + }, + { + "rclick": [250, 120] + }, + { + "wait": 300 + }, + { + "click": [282, 240] + }, + { + "wait": 400 + }, + { + "shot": "delete-confirm" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/personas-flow-light-150.json b/crates/accshift-egui/parity/scenes/personas-flow-light-150.json new file mode 100644 index 00000000..57be0809 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/personas-flow-light-150.json @@ -0,0 +1,167 @@ +{ + "id": "personas-flow-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "PERSONAS": [ + { + "id": "persona-main", + "name": "Main", + "color": "", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000001" + }, + { + "platformId": "riot", + "accountId": "riot-1" + }, + { + "platformId": "roblox", + "accountId": "1000000001" + } + ] + }, + { + "id": "persona-smurfs", + "name": "Smurfs", + "color": "#8b5cf6", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000002" + }, + { + "platformId": "riot", + "accountId": "riot-2" + } + ] + }, + { + "id": "persona-duo", + "name": "Duo nights", + "color": "", + "assignments": [ + { + "platformId": "riot", + "accountId": "riot-3" + } + ] + }, + { + "id": "persona-all", + "name": "Everything at once, with a long name", + "color": "#f97316", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000004" + }, + { + "platformId": "riot", + "accountId": "riot-2" + }, + { + "platformId": "roblox", + "accountId": "1000000003" + }, + { + "platformId": "epic", + "accountId": "epic-1" + } + ] + } + ] + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [379, 18] + }, + { + "move": [600, 380] + }, + { + "wait": 1200 + }, + { + "rclick": [250, 120] + }, + { + "wait": 300 + }, + { + "shot": "menu" + }, + { + "key": "Escape" + }, + { + "wait": 300 + }, + { + "click": [100, 120] + }, + { + "wait": 400 + }, + { + "shot": "switch-confirm" + }, + { + "click": [563, 258] + }, + { + "wait": 1500 + }, + { + "shot": "switched" + }, + { + "move": [600, 380] + }, + { + "wait": 300 + }, + { + "rclick": [250, 120] + }, + { + "wait": 300 + }, + { + "click": [296, 200] + }, + { + "wait": 500 + }, + { + "shot": "recolored" + }, + { + "rclick": [250, 120] + }, + { + "wait": 300 + }, + { + "click": [282, 240] + }, + { + "wait": 400 + }, + { + "shot": "delete-confirm" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/personas-glass-dark-100.json b/crates/accshift-egui/parity/scenes/personas-glass-dark-100.json new file mode 100644 index 00000000..97c9d1fc --- /dev/null +++ b/crates/accshift-egui/parity/scenes/personas-glass-dark-100.json @@ -0,0 +1,62 @@ +{ + "id": "personas-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1 }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "PERSONAS": [ + { + "id": "persona-main", + "name": "Main", + "color": "", + "assignments": [ + { "platformId": "steam", "accountId": "76561198100000001" }, + { "platformId": "riot", "accountId": "riot-1" }, + { "platformId": "roblox", "accountId": "1000000001" } + ] + }, + { + "id": "persona-smurfs", + "name": "Smurfs", + "color": "#8b5cf6", + "assignments": [ + { "platformId": "steam", "accountId": "76561198100000002" }, + { "platformId": "riot", "accountId": "riot-2" } + ] + }, + { + "id": "persona-duo", + "name": "Duo nights", + "color": "", + "assignments": [{ "platformId": "riot", "accountId": "riot-3" }] + }, + { + "id": "persona-all", + "name": "Everything at once, with a long name", + "color": "#f97316", + "assignments": [ + { "platformId": "steam", "accountId": "76561198100000004" }, + { "platformId": "riot", "accountId": "riot-2" }, + { "platformId": "roblox", "accountId": "1000000003" }, + { "platformId": "epic", "accountId": "epic-1" } + ] + } + ] + }, + "steps": [ + { "wait": 600 }, + { "click": [379, 18] }, + { "move": [600, 380] }, + { "wait": 1200 }, + { "dom": "grid", "select": ".personas-panel, .personas-panel *" }, + { "shot": "grid" }, + { "move": [250, 120] }, + { "wait": 400 }, + { "shot": "hover-tinted" }, + { "move": [700, 120] }, + { "wait": 400 }, + { "shot": "hover-new" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/personas-light-150.json b/crates/accshift-egui/parity/scenes/personas-light-150.json new file mode 100644 index 00000000..004c65c7 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/personas-light-150.json @@ -0,0 +1,61 @@ +{ + "id": "personas-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "light", + "locale": "en", + "seed": { + "PERSONAS": [ + { + "id": "persona-main", + "name": "Main", + "color": "", + "assignments": [ + { "platformId": "steam", "accountId": "76561198100000001" }, + { "platformId": "riot", "accountId": "riot-1" }, + { "platformId": "roblox", "accountId": "1000000001" } + ] + }, + { + "id": "persona-smurfs", + "name": "Smurfs", + "color": "#8b5cf6", + "assignments": [ + { "platformId": "steam", "accountId": "76561198100000002" }, + { "platformId": "riot", "accountId": "riot-2" } + ] + }, + { + "id": "persona-duo", + "name": "Duo nights", + "color": "", + "assignments": [{ "platformId": "riot", "accountId": "riot-3" }] + }, + { + "id": "persona-all", + "name": "Everything at once, with a long name", + "color": "#f97316", + "assignments": [ + { "platformId": "steam", "accountId": "76561198100000004" }, + { "platformId": "riot", "accountId": "riot-2" }, + { "platformId": "roblox", "accountId": "1000000003" }, + { "platformId": "epic", "accountId": "epic-1" } + ] + } + ] + }, + "steps": [ + { "wait": 600 }, + { "click": [379, 18] }, + { "move": [600, 380] }, + { "wait": 1200 }, + { "shot": "grid" }, + { "move": [250, 120] }, + { "wait": 400 }, + { "shot": "hover-tinted" }, + { "move": [700, 120] }, + { "wait": 400 }, + { "shot": "hover-new" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/personas-wizard-glass-dark-100.json b/crates/accshift-egui/parity/scenes/personas-wizard-glass-dark-100.json new file mode 100644 index 00000000..112fa1b7 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/personas-wizard-glass-dark-100.json @@ -0,0 +1,189 @@ +{ + "id": "personas-wizard-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "PERSONAS": [ + { + "id": "persona-main", + "name": "Main", + "color": "", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000001" + }, + { + "platformId": "riot", + "accountId": "riot-1" + }, + { + "platformId": "roblox", + "accountId": "1000000001" + } + ] + }, + { + "id": "persona-smurfs", + "name": "Smurfs", + "color": "#8b5cf6", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000002" + }, + { + "platformId": "riot", + "accountId": "riot-2" + } + ] + }, + { + "id": "persona-duo", + "name": "Duo nights", + "color": "", + "assignments": [ + { + "platformId": "riot", + "accountId": "riot-3" + } + ] + }, + { + "id": "persona-all", + "name": "Everything at once, with a long name", + "color": "#f97316", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000004" + }, + { + "platformId": "riot", + "accountId": "riot-2" + }, + { + "platformId": "roblox", + "accountId": "1000000003" + }, + { + "platformId": "epic", + "accountId": "epic-1" + } + ] + } + ] + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [379, 18] + }, + { + "move": [600, 380] + }, + { + "wait": 1200 + }, + { + "click": [762, 144] + }, + { + "wait": 600 + }, + { + "shot": "new" + }, + { + "text": "Duo" + }, + { + "wait": 300 + }, + { + "shot": "named" + }, + { + "click": [99, 229] + }, + { + "move": [600, 400] + }, + { + "wait": 400 + }, + { + "shot": "picker" + }, + { + "click": [120, 205] + }, + { + "move": [600, 400] + }, + { + "wait": 400 + }, + { + "shot": "picked" + }, + { + "click": [38, 92] + }, + { + "wait": 300 + }, + { + "shot": "armed" + }, + { + "key": "Escape" + }, + { + "wait": 500 + }, + { + "rclick": [250, 120] + }, + { + "wait": 300 + }, + { + "click": [282, 138] + }, + { + "move": [600, 400] + }, + { + "wait": 600 + }, + { + "shot": "edit" + }, + { + "click": [264, 229] + }, + { + "move": [600, 400] + }, + { + "wait": 400 + }, + { + "shot": "picker-assigned" + }, + { + "dom": "picker-assigned", + "select": ".wizard, .wizard *" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/personas-wizard-light-150.json b/crates/accshift-egui/parity/scenes/personas-wizard-light-150.json new file mode 100644 index 00000000..04989f2b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/personas-wizard-light-150.json @@ -0,0 +1,189 @@ +{ + "id": "personas-wizard-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "PERSONAS": [ + { + "id": "persona-main", + "name": "Main", + "color": "", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000001" + }, + { + "platformId": "riot", + "accountId": "riot-1" + }, + { + "platformId": "roblox", + "accountId": "1000000001" + } + ] + }, + { + "id": "persona-smurfs", + "name": "Smurfs", + "color": "#8b5cf6", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000002" + }, + { + "platformId": "riot", + "accountId": "riot-2" + } + ] + }, + { + "id": "persona-duo", + "name": "Duo nights", + "color": "", + "assignments": [ + { + "platformId": "riot", + "accountId": "riot-3" + } + ] + }, + { + "id": "persona-all", + "name": "Everything at once, with a long name", + "color": "#f97316", + "assignments": [ + { + "platformId": "steam", + "accountId": "76561198100000004" + }, + { + "platformId": "riot", + "accountId": "riot-2" + }, + { + "platformId": "roblox", + "accountId": "1000000003" + }, + { + "platformId": "epic", + "accountId": "epic-1" + } + ] + } + ] + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [379, 18] + }, + { + "move": [600, 380] + }, + { + "wait": 1200 + }, + { + "click": [762, 144] + }, + { + "wait": 600 + }, + { + "shot": "new" + }, + { + "text": "Duo" + }, + { + "wait": 300 + }, + { + "shot": "named" + }, + { + "click": [99, 229] + }, + { + "move": [600, 400] + }, + { + "wait": 400 + }, + { + "shot": "picker" + }, + { + "click": [120, 205] + }, + { + "move": [600, 400] + }, + { + "wait": 400 + }, + { + "shot": "picked" + }, + { + "click": [38, 92] + }, + { + "wait": 300 + }, + { + "shot": "armed" + }, + { + "key": "Escape" + }, + { + "wait": 500 + }, + { + "rclick": [250, 120] + }, + { + "wait": 300 + }, + { + "click": [282, 138] + }, + { + "move": [600, 400] + }, + { + "wait": 600 + }, + { + "shot": "edit" + }, + { + "click": [264, 229] + }, + { + "move": [600, 400] + }, + { + "wait": 400 + }, + { + "shot": "picker-assigned" + }, + { + "dom": "picker-assigned", + "select": ".wizard, .wizard *" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-gradients-100.json b/crates/accshift-egui/parity/scenes/primitives-gradients-100.json new file mode 100644 index 00000000..8c0c5dad --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-gradients-100.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-gradients-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/gradients.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-gradients-125.json b/crates/accshift-egui/parity/scenes/primitives-gradients-125.json new file mode 100644 index 00000000..37fd6a85 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-gradients-125.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-gradients-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/gradients.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-gradients-150.json b/crates/accshift-egui/parity/scenes/primitives-gradients-150.json new file mode 100644 index 00000000..ce0d2b7a --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-gradients-150.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-gradients-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/gradients.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-inset-100.json b/crates/accshift-egui/parity/scenes/primitives-inset-100.json new file mode 100644 index 00000000..63bc3b41 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-inset-100.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-inset-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/inset.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-inset-125.json b/crates/accshift-egui/parity/scenes/primitives-inset-125.json new file mode 100644 index 00000000..cb548d9e --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-inset-125.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-inset-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/inset.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-inset-150.json b/crates/accshift-egui/parity/scenes/primitives-inset-150.json new file mode 100644 index 00000000..ccb4758a --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-inset-150.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-inset-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/inset.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-radius-100.json b/crates/accshift-egui/parity/scenes/primitives-radius-100.json new file mode 100644 index 00000000..a2687053 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-radius-100.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-radius-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/radius.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-radius-125.json b/crates/accshift-egui/parity/scenes/primitives-radius-125.json new file mode 100644 index 00000000..6a6c015e --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-radius-125.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-radius-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/radius.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-radius-150.json b/crates/accshift-egui/parity/scenes/primitives-radius-150.json new file mode 100644 index 00000000..caf40ca6 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-radius-150.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-radius-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/radius.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-shadows-100.json b/crates/accshift-egui/parity/scenes/primitives-shadows-100.json new file mode 100644 index 00000000..075a69fe --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-shadows-100.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-shadows-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/shadows.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-shadows-125.json b/crates/accshift-egui/parity/scenes/primitives-shadows-125.json new file mode 100644 index 00000000..76331d09 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-shadows-125.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-shadows-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/shadows.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-shadows-150.json b/crates/accshift-egui/parity/scenes/primitives-shadows-150.json new file mode 100644 index 00000000..e680bded --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-shadows-150.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-shadows-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/shadows.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-afk-early-glass-dark-100.json b/crates/accshift-egui/parity/scenes/secure-afk-early-glass-dark-100.json new file mode 100644 index 00000000..c29cdba7 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-afk-early-glass-dark-100.json @@ -0,0 +1,25 @@ +{ + "id": "secure-afk-early-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "inactivityBlurSeconds": 1 + } + }, + "steps": [ + { + "wait": 2400 + }, + { + "shot": "covered" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-afk-early-glass-dark-150.json b/crates/accshift-egui/parity/scenes/secure-afk-early-glass-dark-150.json new file mode 100644 index 00000000..0f86715a --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-afk-early-glass-dark-150.json @@ -0,0 +1,25 @@ +{ + "id": "secure-afk-early-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "inactivityBlurSeconds": 1 + } + }, + "steps": [ + { + "wait": 2400 + }, + { + "shot": "covered" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-afk-early-light-100.json b/crates/accshift-egui/parity/scenes/secure-afk-early-light-100.json new file mode 100644 index 00000000..c2b61b79 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-afk-early-light-100.json @@ -0,0 +1,25 @@ +{ + "id": "secure-afk-early-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "inactivityBlurSeconds": 1 + } + }, + "steps": [ + { + "wait": 2400 + }, + { + "shot": "covered" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-afk-early-light-150.json b/crates/accshift-egui/parity/scenes/secure-afk-early-light-150.json new file mode 100644 index 00000000..f670fe52 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-afk-early-light-150.json @@ -0,0 +1,25 @@ +{ + "id": "secure-afk-early-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "inactivityBlurSeconds": 1 + } + }, + "steps": [ + { + "wait": 2400 + }, + { + "shot": "covered" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-afk-still-glass-dark-100.json b/crates/accshift-egui/parity/scenes/secure-afk-still-glass-dark-100.json new file mode 100644 index 00000000..fb08f24b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-afk-still-glass-dark-100.json @@ -0,0 +1,39 @@ +{ + "id": "secure-afk-still-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "inactivityBlurSeconds": 1, + "animations": "off" + } + }, + "steps": [ + { + "wait": 2500 + }, + { + "shot": "afk" + }, + { + "dom": "afk", + "select": ".inactive-overlay, .inactive-overlay *, .afk-version-strip, .afk-version-strip *" + }, + { + "move": [430, 300] + }, + { + "wait": 300 + }, + { + "shot": "back" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-afk-still-glass-dark-150.json b/crates/accshift-egui/parity/scenes/secure-afk-still-glass-dark-150.json new file mode 100644 index 00000000..26bf579c --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-afk-still-glass-dark-150.json @@ -0,0 +1,39 @@ +{ + "id": "secure-afk-still-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "inactivityBlurSeconds": 1, + "animations": "off" + } + }, + "steps": [ + { + "wait": 2500 + }, + { + "shot": "afk" + }, + { + "dom": "afk", + "select": ".inactive-overlay, .inactive-overlay *, .afk-version-strip, .afk-version-strip *" + }, + { + "move": [430, 300] + }, + { + "wait": 300 + }, + { + "shot": "back" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-afk-still-light-100.json b/crates/accshift-egui/parity/scenes/secure-afk-still-light-100.json new file mode 100644 index 00000000..7b6acb76 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-afk-still-light-100.json @@ -0,0 +1,39 @@ +{ + "id": "secure-afk-still-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "inactivityBlurSeconds": 1, + "animations": "off" + } + }, + "steps": [ + { + "wait": 2500 + }, + { + "shot": "afk" + }, + { + "dom": "afk", + "select": ".inactive-overlay, .inactive-overlay *, .afk-version-strip, .afk-version-strip *" + }, + { + "move": [430, 300] + }, + { + "wait": 300 + }, + { + "shot": "back" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-afk-still-light-150.json b/crates/accshift-egui/parity/scenes/secure-afk-still-light-150.json new file mode 100644 index 00000000..43b6dd44 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-afk-still-light-150.json @@ -0,0 +1,39 @@ +{ + "id": "secure-afk-still-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "inactivityBlurSeconds": 1, + "animations": "off" + } + }, + "steps": [ + { + "wait": 2500 + }, + { + "shot": "afk" + }, + { + "dom": "afk", + "select": ".inactive-overlay, .inactive-overlay *, .afk-version-strip, .afk-version-strip *" + }, + { + "move": [430, 300] + }, + { + "wait": 300 + }, + { + "shot": "back" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-pin-glass-dark-100.json b/crates/accshift-egui/parity/scenes/secure-pin-glass-dark-100.json new file mode 100644 index 00000000..26d44bb9 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-pin-glass-dark-100.json @@ -0,0 +1,82 @@ +{ + "id": "secure-pin-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "pinEnabled": true, + "pinHash": "000102030405060708090a0b0c0d0e0f:e19d9507e40b77fbb7503faedce7cb4ebf8c6820a8b746d9dfa9fcab899ec65d" + } + }, + "steps": [ + { + "wait": 900 + }, + { + "move": [250, 18] + }, + { + "text": "1" + }, + { + "key": "Backspace" + }, + { + "wait": 200 + }, + { + "shot": "locked" + }, + { + "dom": "locked", + "select": ".pin-lock-overlay, .pin-lock-overlay *" + }, + { + "move": [842, 18] + }, + { + "text": "1" + }, + { + "key": "Backspace" + }, + { + "wait": 200 + }, + { + "shot": "close-hover" + }, + { + "move": [250, 18] + }, + { + "text": "12" + }, + { + "wait": 200 + }, + { + "shot": "typing" + }, + { + "text": "99" + }, + { + "wait": 900 + }, + { + "shot": "wrong" + }, + { + "dom": "wrong", + "select": ".pin-lock-overlay, .pin-lock-overlay *" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-pin-glass-dark-150.json b/crates/accshift-egui/parity/scenes/secure-pin-glass-dark-150.json new file mode 100644 index 00000000..71f5fafe --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-pin-glass-dark-150.json @@ -0,0 +1,82 @@ +{ + "id": "secure-pin-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "pinEnabled": true, + "pinHash": "000102030405060708090a0b0c0d0e0f:e19d9507e40b77fbb7503faedce7cb4ebf8c6820a8b746d9dfa9fcab899ec65d" + } + }, + "steps": [ + { + "wait": 900 + }, + { + "move": [250, 18] + }, + { + "text": "1" + }, + { + "key": "Backspace" + }, + { + "wait": 200 + }, + { + "shot": "locked" + }, + { + "dom": "locked", + "select": ".pin-lock-overlay, .pin-lock-overlay *" + }, + { + "move": [842, 18] + }, + { + "text": "1" + }, + { + "key": "Backspace" + }, + { + "wait": 200 + }, + { + "shot": "close-hover" + }, + { + "move": [250, 18] + }, + { + "text": "12" + }, + { + "wait": 200 + }, + { + "shot": "typing" + }, + { + "text": "99" + }, + { + "wait": 900 + }, + { + "shot": "wrong" + }, + { + "dom": "wrong", + "select": ".pin-lock-overlay, .pin-lock-overlay *" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-pin-light-100.json b/crates/accshift-egui/parity/scenes/secure-pin-light-100.json new file mode 100644 index 00000000..502009cd --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-pin-light-100.json @@ -0,0 +1,82 @@ +{ + "id": "secure-pin-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "pinEnabled": true, + "pinHash": "000102030405060708090a0b0c0d0e0f:e19d9507e40b77fbb7503faedce7cb4ebf8c6820a8b746d9dfa9fcab899ec65d" + } + }, + "steps": [ + { + "wait": 900 + }, + { + "move": [250, 18] + }, + { + "text": "1" + }, + { + "key": "Backspace" + }, + { + "wait": 200 + }, + { + "shot": "locked" + }, + { + "dom": "locked", + "select": ".pin-lock-overlay, .pin-lock-overlay *" + }, + { + "move": [842, 18] + }, + { + "text": "1" + }, + { + "key": "Backspace" + }, + { + "wait": 200 + }, + { + "shot": "close-hover" + }, + { + "move": [250, 18] + }, + { + "text": "12" + }, + { + "wait": 200 + }, + { + "shot": "typing" + }, + { + "text": "99" + }, + { + "wait": 900 + }, + { + "shot": "wrong" + }, + { + "dom": "wrong", + "select": ".pin-lock-overlay, .pin-lock-overlay *" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/secure-pin-light-150.json b/crates/accshift-egui/parity/scenes/secure-pin-light-150.json new file mode 100644 index 00000000..d9aee18b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/secure-pin-light-150.json @@ -0,0 +1,82 @@ +{ + "id": "secure-pin-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "pinEnabled": true, + "pinHash": "000102030405060708090a0b0c0d0e0f:e19d9507e40b77fbb7503faedce7cb4ebf8c6820a8b746d9dfa9fcab899ec65d" + } + }, + "steps": [ + { + "wait": 900 + }, + { + "move": [250, 18] + }, + { + "text": "1" + }, + { + "key": "Backspace" + }, + { + "wait": 200 + }, + { + "shot": "locked" + }, + { + "dom": "locked", + "select": ".pin-lock-overlay, .pin-lock-overlay *" + }, + { + "move": [842, 18] + }, + { + "text": "1" + }, + { + "key": "Backspace" + }, + { + "wait": 200 + }, + { + "shot": "close-hover" + }, + { + "move": [250, 18] + }, + { + "text": "12" + }, + { + "wait": 200 + }, + { + "shot": "typing" + }, + { + "text": "99" + }, + { + "wait": 900 + }, + { + "shot": "wrong" + }, + { + "dom": "wrong", + "select": ".pin-lock-overlay, .pin-lock-overlay *" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-descriptor-preview-glass-dark-100.json b/crates/accshift-egui/parity/scenes/settings-descriptor-preview-glass-dark-100.json new file mode 100644 index 00000000..c7bac4fe --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-descriptor-preview-glass-dark-100.json @@ -0,0 +1,95 @@ +{ + "id": "settings-descriptor-preview-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [93, 141] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "scroll": [350, 300, 600] + }, + { + "wait": 400 + }, + { + "click": [258, 354] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "preview" + }, + { + "move": [607, 374] + }, + { + "wait": 300 + }, + { + "shot": "hover-cancel" + }, + { + "move": [669, 374] + }, + { + "wait": 300 + }, + { + "shot": "hover-add" + }, + { + "move": [420, 250] + }, + { + "scroll": [420, 250, 300] + }, + { + "wait": 300 + }, + { + "shot": "scrolled" + }, + { + "move": [430, 5] + }, + { + "key": "Tab" + }, + { + "wait": 300 + }, + { + "shot": "tab" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-descriptor-preview-light-150.json b/crates/accshift-egui/parity/scenes/settings-descriptor-preview-light-150.json new file mode 100644 index 00000000..3c8702f0 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-descriptor-preview-light-150.json @@ -0,0 +1,65 @@ +{ + "id": "settings-descriptor-preview-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [93, 141] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "scroll": [350, 300, 600] + }, + { + "wait": 400 + }, + { + "click": [258, 354] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "preview" + }, + { + "click": [669, 374] + }, + { + "move": [430, 5] + }, + { + "wait": 600 + }, + { + "shot": "installed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-epic-fr-light-100.json b/crates/accshift-egui/parity/scenes/settings-epic-fr-light-100.json new file mode 100644 index 00000000..0009d108 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-epic-fr-light-100.json @@ -0,0 +1,184 @@ +{ + "id": "settings-epic-fr-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "fr", + "seed": { + "SETTINGS": { + "enabledPlatforms": ["steam", "riot", "roblox", "epic"] + } + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [93, 352] + }, + { + "move": [430, 5] + }, + { + "wait": 600 + }, + { + "shot": "epic" + }, + { + "click": [700, 192] + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "move": [430, 5] + }, + { + "wait": 900 + }, + { + "shot": "placeholder" + }, + { + "key": "Tab" + }, + { + "wait": 300 + }, + { + "shot": "focus-choose" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-fr-glass-dark-100.json b/crates/accshift-egui/parity/scenes/settings-fr-glass-dark-100.json new file mode 100644 index 00000000..172ecd69 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-fr-glass-dark-100.json @@ -0,0 +1,41 @@ +{ + "id": "settings-fr-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "fr", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "shot": "general" + }, + { + "click": [93, 176] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "privacy" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-glass-dark-100.json b/crates/accshift-egui/parity/scenes/settings-glass-dark-100.json new file mode 100644 index 00000000..9b6bf9f7 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-glass-dark-100.json @@ -0,0 +1,77 @@ +{ + "id": "settings-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "shot": "general" + }, + { + "click": [93, 176] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "privacy" + }, + { + "move": [448, 267] + }, + { + "wait": 300 + }, + { + "shot": "hover-toggle" + }, + { + "click": [350, 192] + }, + { + "text": "45" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "input-edit" + }, + { + "click": [766, 167] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "pin-setup" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-glass-dark-150.json b/crates/accshift-egui/parity/scenes/settings-glass-dark-150.json new file mode 100644 index 00000000..ee1c3ee2 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-glass-dark-150.json @@ -0,0 +1,77 @@ +{ + "id": "settings-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "shot": "general" + }, + { + "click": [93, 176] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "privacy" + }, + { + "move": [448, 267] + }, + { + "wait": 300 + }, + { + "shot": "hover-toggle" + }, + { + "click": [350, 192] + }, + { + "text": "45" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "input-edit" + }, + { + "click": [766, 167] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "pin-setup" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-light-100.json b/crates/accshift-egui/parity/scenes/settings-light-100.json new file mode 100644 index 00000000..e583e1e0 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-light-100.json @@ -0,0 +1,77 @@ +{ + "id": "settings-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "shot": "general" + }, + { + "click": [93, 176] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "privacy" + }, + { + "move": [448, 267] + }, + { + "wait": 300 + }, + { + "shot": "hover-toggle" + }, + { + "click": [350, 192] + }, + { + "text": "45" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "input-edit" + }, + { + "click": [766, 167] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "pin-setup" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-light-150.json b/crates/accshift-egui/parity/scenes/settings-light-150.json new file mode 100644 index 00000000..d727cc6d --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-light-150.json @@ -0,0 +1,77 @@ +{ + "id": "settings-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "shot": "general" + }, + { + "click": [93, 176] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "privacy" + }, + { + "move": [448, 267] + }, + { + "wait": 300 + }, + { + "shot": "hover-toggle" + }, + { + "click": [350, 192] + }, + { + "text": "45" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "input-edit" + }, + { + "click": [766, 167] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "pin-setup" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-platforms-ctrlf-glass-dark-100.json b/crates/accshift-egui/parity/scenes/settings-platforms-ctrlf-glass-dark-100.json new file mode 100644 index 00000000..e8fa71f4 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-platforms-ctrlf-glass-dark-100.json @@ -0,0 +1,62 @@ +{ + "id": "settings-platforms-ctrlf-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "key": "Ctrl+F" + }, + { + "wait": 300 + }, + { + "shot": "ctrl-f" + }, + { + "text": "st" + }, + { + "click": [700, 380] + }, + { + "wait": 300 + }, + { + "click": [93, 104] + }, + { + "move": [430, 5] + }, + { + "wait": 400 + }, + { + "key": "Ctrl+F" + }, + { + "wait": 300 + }, + { + "shot": "ctrl-f-select" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-platforms-fr-light-150.json b/crates/accshift-egui/parity/scenes/settings-platforms-fr-light-150.json new file mode 100644 index 00000000..f90232c9 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-platforms-fr-light-150.json @@ -0,0 +1,110 @@ +{ + "id": "settings-platforms-fr-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "fr", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [93, 141] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "platforms" + }, + { + "move": [350, 238] + }, + { + "wait": 300 + }, + { + "shot": "hover-chip" + }, + { + "click": [350, 292] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "shot": "toggled" + }, + { + "click": [400, 124] + }, + { + "text": "ub" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "search" + }, + { + "text": "zz" + }, + { + "wait": 300 + }, + { + "shot": "no-results" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "click": [700, 380] + }, + { + "wait": 300 + }, + { + "scroll": [350, 300, 600] + }, + { + "wait": 400 + }, + { + "shot": "scrolled" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-platforms-glass-dark-100.json b/crates/accshift-egui/parity/scenes/settings-platforms-glass-dark-100.json new file mode 100644 index 00000000..40068deb --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-platforms-glass-dark-100.json @@ -0,0 +1,110 @@ +{ + "id": "settings-platforms-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [93, 141] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "platforms" + }, + { + "move": [350, 238] + }, + { + "wait": 300 + }, + { + "shot": "hover-chip" + }, + { + "click": [350, 292] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "shot": "toggled" + }, + { + "click": [400, 124] + }, + { + "text": "ub" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "search" + }, + { + "text": "zz" + }, + { + "wait": 300 + }, + { + "shot": "no-results" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "click": [700, 380] + }, + { + "wait": 300 + }, + { + "scroll": [350, 300, 600] + }, + { + "wait": 400 + }, + { + "shot": "scrolled" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-platforms-light-150.json b/crates/accshift-egui/parity/scenes/settings-platforms-light-150.json new file mode 100644 index 00000000..33abd919 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-platforms-light-150.json @@ -0,0 +1,110 @@ +{ + "id": "settings-platforms-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [93, 141] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "platforms" + }, + { + "move": [350, 238] + }, + { + "wait": 300 + }, + { + "shot": "hover-chip" + }, + { + "click": [350, 292] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "shot": "toggled" + }, + { + "click": [400, 124] + }, + { + "text": "ub" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "search" + }, + { + "text": "zz" + }, + { + "wait": 300 + }, + { + "shot": "no-results" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "click": [700, 380] + }, + { + "wait": 300 + }, + { + "scroll": [350, 300, 600] + }, + { + "wait": 400 + }, + { + "shot": "scrolled" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-platforms-wide-light-100.json b/crates/accshift-egui/parity/scenes/settings-platforms-wide-light-100.json new file mode 100644 index 00000000..86416448 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-platforms-wide-light-100.json @@ -0,0 +1,38 @@ +{ + "id": "settings-platforms-wide-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 1100, + "height": 600, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [93, 141] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "platforms" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-riot-glass-dark-100.json b/crates/accshift-egui/parity/scenes/settings-riot-glass-dark-100.json new file mode 100644 index 00000000..3ec100b7 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-riot-glass-dark-100.json @@ -0,0 +1,86 @@ +{ + "id": "settings-riot-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [93, 280] + }, + { + "move": [430, 5] + }, + { + "wait": 600 + }, + { + "shot": "riot" + }, + { + "move": [776, 192] + }, + { + "wait": 300 + }, + { + "shot": "hover-choose" + }, + { + "click": [353, 167] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "toggled" + }, + { + "click": [700, 192] + }, + { + "key": "End" + }, + { + "text": "x" + }, + { + "move": [430, 5] + }, + { + "wait": 900 + }, + { + "shot": "typing" + }, + { + "key": "Tab" + }, + { + "wait": 300 + }, + { + "shot": "focus-choose" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-riot-light-150.json b/crates/accshift-egui/parity/scenes/settings-riot-light-150.json new file mode 100644 index 00000000..14b74187 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-riot-light-150.json @@ -0,0 +1,86 @@ +{ + "id": "settings-riot-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [93, 280] + }, + { + "move": [430, 5] + }, + { + "wait": 600 + }, + { + "shot": "riot" + }, + { + "move": [776, 192] + }, + { + "wait": 300 + }, + { + "shot": "hover-choose" + }, + { + "click": [353, 167] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "toggled" + }, + { + "click": [700, 192] + }, + { + "key": "End" + }, + { + "text": "x" + }, + { + "move": [430, 5] + }, + { + "wait": 900 + }, + { + "shot": "typing" + }, + { + "key": "Tab" + }, + { + "wait": 300 + }, + { + "shot": "focus-choose" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-roblox-glass-dark-100.json b/crates/accshift-egui/parity/scenes/settings-roblox-glass-dark-100.json new file mode 100644 index 00000000..dcbcdc54 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-roblox-glass-dark-100.json @@ -0,0 +1,74 @@ +{ + "id": "settings-roblox-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [93, 315] + }, + { + "move": [430, 5] + }, + { + "wait": 600 + }, + { + "shot": "roblox" + }, + { + "click": [353, 242] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "health-off" + }, + { + "click": [640, 209] + }, + { + "text": "abc" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "cookie" + }, + { + "move": [789, 209] + }, + { + "wait": 300 + }, + { + "shot": "hover-add" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-roblox-light-150.json b/crates/accshift-egui/parity/scenes/settings-roblox-light-150.json new file mode 100644 index 00000000..51a8295d --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-roblox-light-150.json @@ -0,0 +1,74 @@ +{ + "id": "settings-roblox-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [93, 315] + }, + { + "move": [430, 5] + }, + { + "wait": 600 + }, + { + "shot": "roblox" + }, + { + "click": [353, 242] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "health-off" + }, + { + "click": [640, 209] + }, + { + "text": "abc" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "cookie" + }, + { + "move": [789, 209] + }, + { + "wait": 300 + }, + { + "shot": "hover-add" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-steam-glass-dark-100.json b/crates/accshift-egui/parity/scenes/settings-steam-glass-dark-100.json new file mode 100644 index 00000000..2bc5ed95 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-steam-glass-dark-100.json @@ -0,0 +1,57 @@ +{ + "id": "settings-steam-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 1000, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "click": [87, 18] }, + { "move": [430, 5] }, + { "wait": 700 }, + { "click": [93, 244] }, + { "move": [430, 5] }, + { "wait": 1000 }, + { + "dom": "steam", + "select": ".settings-content *" + }, + { "shot": "steam" }, + { "move": [767, 524] }, + { "wait": 300 }, + { "shot": "hover-refresh" }, + { "move": [780, 447] }, + { "wait": 300 }, + { "shot": "hover-clear" }, + { "click": [675, 167] }, + { "move": [430, 5] }, + { "wait": 500 }, + { "shot": "admin-on" }, + { "click": [640, 447] }, + { "text": "abc" }, + { "move": [430, 5] }, + { "wait": 900 }, + { "shot": "api-key" }, + { "key": "Tab" }, + { "wait": 300 }, + { "shot": "focus-clear" }, + { "key": "Enter" }, + { "wait": 700 }, + { "shot": "clear-failed" }, + { "click": [325, 810] }, + { "text": "http://localhost:9" }, + { "key": "Tab" }, + { "move": [430, 5] }, + { "wait": 700 }, + { "shot": "cs2-save-failed" }, + { "click": [767, 524] }, + { "move": [430, 5] }, + { "wait": 1200 }, + { "shot": "refreshed" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/settings-steam-light-150.json b/crates/accshift-egui/parity/scenes/settings-steam-light-150.json new file mode 100644 index 00000000..af6dbf1b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/settings-steam-light-150.json @@ -0,0 +1,57 @@ +{ + "id": "settings-steam-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 1000, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "click": [87, 18] }, + { "move": [430, 5] }, + { "wait": 700 }, + { "click": [93, 244] }, + { "move": [430, 5] }, + { "wait": 1000 }, + { + "dom": "steam", + "select": ".settings-content *" + }, + { "shot": "steam" }, + { "move": [767, 524] }, + { "wait": 300 }, + { "shot": "hover-refresh" }, + { "move": [780, 447] }, + { "wait": 300 }, + { "shot": "hover-clear" }, + { "click": [675, 167] }, + { "move": [430, 5] }, + { "wait": 500 }, + { "shot": "admin-on" }, + { "click": [640, 447] }, + { "text": "abc" }, + { "move": [430, 5] }, + { "wait": 900 }, + { "shot": "api-key" }, + { "key": "Tab" }, + { "wait": 300 }, + { "shot": "focus-clear" }, + { "key": "Enter" }, + { "wait": 700 }, + { "shot": "clear-failed" }, + { "click": [325, 810] }, + { "text": "http://localhost:9" }, + { "key": "Tab" }, + { "move": [430, 5] }, + { "wait": 700 }, + { "shot": "cs2-save-failed" }, + { "click": [767, 524] }, + { "move": [430, 5] }, + { "wait": 1200 }, + { "shot": "refreshed" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/spike-main.json b/crates/accshift-egui/parity/scenes/spike-main.json new file mode 100644 index 00000000..93b125a4 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/spike-main.json @@ -0,0 +1,50 @@ +{ + "id": "spike-main", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "move": [755, 298] }, + { "wait": 500 }, + { "shot": "hover", "alias": "egui-0-hover" }, + { "click": [315, 152] }, + { "wait": 600 }, + { "shot": "armed", "alias": "egui-2-armed" }, + { "click": [639, 60] }, + { "text": "ra" }, + { "wait": 600 }, + { "shot": "search", "alias": "egui-5-search" }, + { "click": [741, 60] }, + { "wait": 400 }, + { "click": [315, 152] }, + { "wait": 300 }, + { "click": [315, 152] }, + { "wait": 1400 }, + { "move": [95, 152] }, + { "wait": 500 }, + { "shot": "switched", "alias": "egui-9-switched" }, + { "click": [447, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "riot", "alias": "egui-riot" }, + { "click": [481, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "roblox", "alias": "egui-roblox" }, + { "click": [413, 18] }, + { "wait": 400 }, + { "click": [95, 152] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "folder", "alias": "egui-folder" }, + { "click": [35, 60] }, + { "wait": 300 }, + { "click": [816, 60] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "list", "alias": "egui-list" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/streamer-glass-dark-100.json b/crates/accshift-egui/parity/scenes/streamer-glass-dark-100.json new file mode 100644 index 00000000..3d5ec110 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/streamer-glass-dark-100.json @@ -0,0 +1,61 @@ +{ + "id": "streamer-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "streaming": true, + "seed": { + "SETTINGS": { + "streamerMode": "auto", + "animations": "off" + } + }, + "steps": [ + { + "move": [430, 300] + }, + { + "wait": 1500 + }, + { + "shot": "overlay" + }, + { + "dom": "overlay", + "select": ".streamer-overlay, .streamer-overlay *" + }, + { + "move": [351, 268] + }, + { + "wait": 300 + }, + { + "shot": "hover-disable" + }, + { + "move": [472, 268] + }, + { + "wait": 300 + }, + { + "shot": "hover-subtle" + }, + { + "move": [842, 18] + }, + { + "wait": 300 + }, + { + "shot": "close-hover" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/streamer-glass-dark-150.json b/crates/accshift-egui/parity/scenes/streamer-glass-dark-150.json new file mode 100644 index 00000000..1bad1547 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/streamer-glass-dark-150.json @@ -0,0 +1,61 @@ +{ + "id": "streamer-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "streaming": true, + "seed": { + "SETTINGS": { + "streamerMode": "auto", + "animations": "off" + } + }, + "steps": [ + { + "move": [430, 300] + }, + { + "wait": 1500 + }, + { + "shot": "overlay" + }, + { + "dom": "overlay", + "select": ".streamer-overlay, .streamer-overlay *" + }, + { + "move": [351, 268] + }, + { + "wait": 300 + }, + { + "shot": "hover-disable" + }, + { + "move": [472, 268] + }, + { + "wait": 300 + }, + { + "shot": "hover-subtle" + }, + { + "move": [842, 18] + }, + { + "wait": 300 + }, + { + "shot": "close-hover" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/streamer-light-100.json b/crates/accshift-egui/parity/scenes/streamer-light-100.json new file mode 100644 index 00000000..e97b4f4e --- /dev/null +++ b/crates/accshift-egui/parity/scenes/streamer-light-100.json @@ -0,0 +1,61 @@ +{ + "id": "streamer-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "streaming": true, + "seed": { + "SETTINGS": { + "streamerMode": "auto", + "animations": "off" + } + }, + "steps": [ + { + "move": [430, 300] + }, + { + "wait": 1500 + }, + { + "shot": "overlay" + }, + { + "dom": "overlay", + "select": ".streamer-overlay, .streamer-overlay *" + }, + { + "move": [351, 268] + }, + { + "wait": 300 + }, + { + "shot": "hover-disable" + }, + { + "move": [472, 268] + }, + { + "wait": 300 + }, + { + "shot": "hover-subtle" + }, + { + "move": [842, 18] + }, + { + "wait": 300 + }, + { + "shot": "close-hover" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/streamer-light-150.json b/crates/accshift-egui/parity/scenes/streamer-light-150.json new file mode 100644 index 00000000..8e447cc1 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/streamer-light-150.json @@ -0,0 +1,61 @@ +{ + "id": "streamer-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "streaming": true, + "seed": { + "SETTINGS": { + "streamerMode": "auto", + "animations": "off" + } + }, + "steps": [ + { + "move": [430, 300] + }, + { + "wait": 1500 + }, + { + "shot": "overlay" + }, + { + "dom": "overlay", + "select": ".streamer-overlay, .streamer-overlay *" + }, + { + "move": [351, 268] + }, + { + "wait": 300 + }, + { + "shot": "hover-disable" + }, + { + "move": [472, 268] + }, + { + "wait": 300 + }, + { + "shot": "hover-subtle" + }, + { + "move": [842, 18] + }, + { + "wait": 300 + }, + { + "shot": "close-hover" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/switch-fail-glass-dark-100.json b/crates/accshift-egui/parity/scenes/switch-fail-glass-dark-100.json new file mode 100644 index 00000000..83aea9d1 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/switch-fail-glass-dark-100.json @@ -0,0 +1,51 @@ +{ + "id": "switch-fail-glass-dark-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "fail": { + "platform_switch_account": "Steam is running as administrator" + }, + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "click": [205, 150] + }, + { + "wait": 500 + }, + { + "click": [205, 150] + }, + { + "wait": 800 + }, + { + "shot": "failed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/switch-fail-light-150.json b/crates/accshift-egui/parity/scenes/switch-fail-light-150.json new file mode 100644 index 00000000..0c8ab10e --- /dev/null +++ b/crates/accshift-egui/parity/scenes/switch-fail-light-150.json @@ -0,0 +1,51 @@ +{ + "id": "switch-fail-light-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "fail": { + "platform_switch_account": "Steam could not be restarted" + }, + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "click": [205, 150] + }, + { + "wait": 500 + }, + { + "click": [205, 150] + }, + { + "wait": 800 + }, + { + "shot": "failed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/switch-glass-dark-100.json b/crates/accshift-egui/parity/scenes/switch-glass-dark-100.json new file mode 100644 index 00000000..3eaeead7 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/switch-glass-dark-100.json @@ -0,0 +1,57 @@ +{ + "id": "switch-glass-dark-100", + "scenario": "slow", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "click": [645, 150] + }, + { + "wait": 500 + }, + { + "shot": "armed" + }, + { + "click": [645, 150] + }, + { + "wait": 600 + }, + { + "shot": "switching" + }, + { + "wait": 4600 + }, + { + "shot": "switched" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/switch-light-150.json b/crates/accshift-egui/parity/scenes/switch-light-150.json new file mode 100644 index 00000000..877e6751 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/switch-light-150.json @@ -0,0 +1,57 @@ +{ + "id": "switch-light-150", + "scenario": "slow", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "click": [645, 150] + }, + { + "wait": 500 + }, + { + "shot": "armed" + }, + { + "click": [645, 150] + }, + { + "wait": 600 + }, + { + "shot": "switching" + }, + { + "wait": 4600 + }, + { + "shot": "switched" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/theme-editor-glass-dark-100.json b/crates/accshift-egui/parity/scenes/theme-editor-glass-dark-100.json new file mode 100644 index 00000000..c1859883 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/theme-editor-glass-dark-100.json @@ -0,0 +1,143 @@ +{ + "id": "theme-editor-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [566, 376] + }, + { + "move": [430, 5] + }, + { + "wait": 600 + }, + { + "shot": "open" + }, + { + "click": [560, 306] + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "text": "40 60 90" + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "edited" + }, + { + "text": "x" + }, + { + "wait": 400 + }, + { + "shot": "invalid" + }, + { + "click": [747, 306] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "reset" + }, + { + "click": [650, 215] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "glass" + }, + { + "scroll": [430, 220, 300] + }, + { + "wait": 400 + }, + { + "shot": "tokens" + }, + { + "scroll": [430, 220, 4000] + }, + { + "wait": 400 + }, + { + "shot": "bottom" + }, + { + "click": [400, 230] + }, + { + "text": ".a{}" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "css" + }, + { + "key": "Escape" + }, + { + "wait": 600 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/theme-editor-light-150.json b/crates/accshift-egui/parity/scenes/theme-editor-light-150.json new file mode 100644 index 00000000..4671d1fc --- /dev/null +++ b/crates/accshift-egui/parity/scenes/theme-editor-light-150.json @@ -0,0 +1,143 @@ +{ + "id": "theme-editor-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [566, 376] + }, + { + "move": [430, 5] + }, + { + "wait": 600 + }, + { + "shot": "open" + }, + { + "click": [560, 306] + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "key": "Backspace" + }, + { + "text": "40 60 90" + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "edited" + }, + { + "text": "x" + }, + { + "wait": 400 + }, + { + "shot": "invalid" + }, + { + "click": [747, 306] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "reset" + }, + { + "click": [650, 215] + }, + { + "move": [430, 5] + }, + { + "wait": 500 + }, + { + "shot": "glass" + }, + { + "scroll": [430, 220, 300] + }, + { + "wait": 400 + }, + { + "shot": "tokens" + }, + { + "scroll": [430, 220, 4000] + }, + { + "wait": 400 + }, + { + "shot": "bottom" + }, + { + "click": [400, 230] + }, + { + "text": ".a{}" + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "css" + }, + { + "key": "Escape" + }, + { + "wait": 600 + }, + { + "shot": "closed" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/theme-import-glass-dark-100.json b/crates/accshift-egui/parity/scenes/theme-import-glass-dark-100.json new file mode 100644 index 00000000..990d9f9d --- /dev/null +++ b/crates/accshift-egui/parity/scenes/theme-import-glass-dark-100.json @@ -0,0 +1,39 @@ +{ + "id": "theme-import-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "clipboard": "{\"schemaVersion\": 3, \"id\": \"ember\", \"name\": \"Ember\", \"colorScheme\": \"dark\", \"extends\": \"glass-dark\", \"tokens\": {\"accent\": \"#f97316\"}}", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [668, 376] + }, + { + "move": [430, 5] + }, + { + "wait": 1200 + }, + { + "shot": "imported" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/theme-import-invalid-glass-dark-150.json b/crates/accshift-egui/parity/scenes/theme-import-invalid-glass-dark-150.json new file mode 100644 index 00000000..bf5fdd0a --- /dev/null +++ b/crates/accshift-egui/parity/scenes/theme-import-invalid-glass-dark-150.json @@ -0,0 +1,39 @@ +{ + "id": "theme-import-invalid-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "clipboard": "{", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [668, 376] + }, + { + "move": [430, 5] + }, + { + "wait": 1200 + }, + { + "shot": "invalid" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/theme-import-light-150.json b/crates/accshift-egui/parity/scenes/theme-import-light-150.json new file mode 100644 index 00000000..44478a59 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/theme-import-light-150.json @@ -0,0 +1,39 @@ +{ + "id": "theme-import-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "clipboard": "{\"schemaVersion\": 2, \"id\": \"light\", \"name\": \"Paper\", \"colorScheme\": \"light\", \"extends\": \"light\", \"tokens\": {\"accent\": \"#0d9488\"}}", + "steps": [ + { + "wait": 600 + }, + { + "click": [87, 18] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "click": [668, 376] + }, + { + "move": [430, 5] + }, + { + "wait": 1200 + }, + { + "shot": "migrated" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-hover-100.json b/crates/accshift-egui/parity/scenes/titlebar-hover-100.json new file mode 100644 index 00000000..67274d06 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-hover-100.json @@ -0,0 +1,35 @@ +{ + "id": "titlebar-hover-100", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "move": [23, 18] }, + { "wait": 600 }, + { "shot": "refresh" }, + { "move": [55, 18] }, + { "wait": 600 }, + { "shot": "add" }, + { "move": [87, 18] }, + { "wait": 600 }, + { "shot": "settings" }, + { "move": [119, 18] }, + { "wait": 600 }, + { "shot": "bulk-edit" }, + { "move": [447, 18] }, + { "wait": 600 }, + { "shot": "tab-riot" }, + { "move": [745, 18] }, + { "wait": 600 }, + { "shot": "minimize" }, + { "move": [791, 18] }, + { "wait": 600 }, + { "shot": "maximize" }, + { "move": [837, 18] }, + { "wait": 600 }, + { "shot": "close" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-hover-125.json b/crates/accshift-egui/parity/scenes/titlebar-hover-125.json new file mode 100644 index 00000000..8a1a4acb --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-hover-125.json @@ -0,0 +1,35 @@ +{ + "id": "titlebar-hover-125", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.25 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "move": [23, 18] }, + { "wait": 600 }, + { "shot": "refresh" }, + { "move": [55, 18] }, + { "wait": 600 }, + { "shot": "add" }, + { "move": [87, 18] }, + { "wait": 600 }, + { "shot": "settings" }, + { "move": [119, 18] }, + { "wait": 600 }, + { "shot": "bulk-edit" }, + { "move": [447, 18] }, + { "wait": 600 }, + { "shot": "tab-riot" }, + { "move": [745, 18] }, + { "wait": 600 }, + { "shot": "minimize" }, + { "move": [791, 18] }, + { "wait": 600 }, + { "shot": "maximize" }, + { "move": [837, 18] }, + { "wait": 600 }, + { "shot": "close" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-hover-150.json b/crates/accshift-egui/parity/scenes/titlebar-hover-150.json new file mode 100644 index 00000000..7c5dac16 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-hover-150.json @@ -0,0 +1,35 @@ +{ + "id": "titlebar-hover-150", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "move": [23, 18] }, + { "wait": 600 }, + { "shot": "refresh" }, + { "move": [55, 18] }, + { "wait": 600 }, + { "shot": "add" }, + { "move": [87, 18] }, + { "wait": 600 }, + { "shot": "settings" }, + { "move": [119, 18] }, + { "wait": 600 }, + { "shot": "bulk-edit" }, + { "move": [447, 18] }, + { "wait": 600 }, + { "shot": "tab-riot" }, + { "move": [745, 18] }, + { "wait": 600 }, + { "shot": "minimize" }, + { "move": [791, 18] }, + { "wait": 600 }, + { "shot": "maximize" }, + { "move": [837, 18] }, + { "wait": 600 }, + { "shot": "close" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-linux-100.json b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-100.json new file mode 100644 index 00000000..8b10c34b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-100.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-linux-100", + "scenario": "demo", + "os": "linux", + "viewport": { "width": 860, "height": 440, "scale": 1 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-linux-125.json b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-125.json new file mode 100644 index 00000000..ee543d95 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-125.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-linux-125", + "scenario": "demo", + "os": "linux", + "viewport": { "width": 860, "height": 440, "scale": 1.25 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-linux-150.json b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-150.json new file mode 100644 index 00000000..f2a7c342 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-150.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-linux-150", + "scenario": "demo", + "os": "linux", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-macos-100.json b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-100.json new file mode 100644 index 00000000..5c781cce --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-100.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-macos-100", + "scenario": "demo", + "os": "macos", + "viewport": { "width": 860, "height": 440, "scale": 1 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-macos-125.json b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-125.json new file mode 100644 index 00000000..c0757deb --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-125.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-macos-125", + "scenario": "demo", + "os": "macos", + "viewport": { "width": 860, "height": 440, "scale": 1.25 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-macos-150.json b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-150.json new file mode 100644 index 00000000..90c14c80 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-150.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-macos-150", + "scenario": "demo", + "os": "macos", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-windows-100.json b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-100.json new file mode 100644 index 00000000..9a99b86d --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-100.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-windows-100", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-windows-125.json b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-125.json new file mode 100644 index 00000000..f19718ba --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-125.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-windows-125", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.25 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-windows-150.json b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-150.json new file mode 100644 index 00000000..b24fd9df --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-150.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-windows-150", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-tabs-100.json b/crates/accshift-egui/parity/scenes/titlebar-tabs-100.json new file mode 100644 index 00000000..fe8ede27 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-tabs-100.json @@ -0,0 +1,27 @@ +{ + "id": "titlebar-tabs-100", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "click": [379, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "personas" }, + { "click": [413, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "steam" }, + { "click": [447, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "riot" }, + { "click": [481, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "roblox" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-tabs-125.json b/crates/accshift-egui/parity/scenes/titlebar-tabs-125.json new file mode 100644 index 00000000..980a220e --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-tabs-125.json @@ -0,0 +1,27 @@ +{ + "id": "titlebar-tabs-125", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.25 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "click": [379, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "personas" }, + { "click": [413, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "steam" }, + { "click": [447, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "riot" }, + { "click": [481, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "roblox" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-tabs-150.json b/crates/accshift-egui/parity/scenes/titlebar-tabs-150.json new file mode 100644 index 00000000..c51357d8 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-tabs-150.json @@ -0,0 +1,27 @@ +{ + "id": "titlebar-tabs-150", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "click": [379, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "personas" }, + { "click": [413, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "steam" }, + { "click": [447, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "riot" }, + { "click": [481, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "roblox" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/typography-100.json b/crates/accshift-egui/parity/scenes/typography-100.json new file mode 100644 index 00000000..0677c3ab --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-100.json @@ -0,0 +1,22 @@ +{ + "id": "typography-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 680, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/typography-125.json b/crates/accshift-egui/parity/scenes/typography-125.json new file mode 100644 index 00000000..f64dfc5b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-125.json @@ -0,0 +1,22 @@ +{ + "id": "typography-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 680, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/typography-150.json b/crates/accshift-egui/parity/scenes/typography-150.json new file mode 100644 index 00000000..4feeb798 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-150.json @@ -0,0 +1,22 @@ +{ + "id": "typography-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 680, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/typography-display-100.json b/crates/accshift-egui/parity/scenes/typography-display-100.json new file mode 100644 index 00000000..de256d51 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-display-100.json @@ -0,0 +1,22 @@ +{ + "id": "typography-display-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography-display.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/typography-display-125.json b/crates/accshift-egui/parity/scenes/typography-display-125.json new file mode 100644 index 00000000..f221bfbd --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-display-125.json @@ -0,0 +1,22 @@ +{ + "id": "typography-display-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography-display.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/typography-display-150.json b/crates/accshift-egui/parity/scenes/typography-display-150.json new file mode 100644 index 00000000..039ddb87 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-display-150.json @@ -0,0 +1,22 @@ +{ + "id": "typography-display-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography-display.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/wallpaper-100.json b/crates/accshift-egui/parity/scenes/wallpaper-100.json new file mode 100644 index 00000000..aee875e4 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/wallpaper-100.json @@ -0,0 +1,50 @@ +{ + "id": "wallpaper-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "liquid-glass", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "grid" + }, + { + "move": [645, 150] + }, + { + "wait": 500 + }, + { + "shot": "hover" + } + ], + "wallpaper": { + "image": "wallpaper.png", + "rect": [0, 0, 1920, 1080], + "window": [300, 200] + } +} diff --git a/crates/accshift-egui/parity/scenes/wallpaper-150.json b/crates/accshift-egui/parity/scenes/wallpaper-150.json new file mode 100644 index 00000000..ed24fd35 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/wallpaper-150.json @@ -0,0 +1,50 @@ +{ + "id": "wallpaper-150", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "liquid-glass", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "grid" + }, + { + "move": [645, 150] + }, + { + "wait": 500 + }, + { + "shot": "hover" + } + ], + "wallpaper": { + "image": "wallpaper.png", + "rect": [0, 0, 1920, 1080], + "window": [300, 200] + } +} diff --git a/crates/accshift-egui/parity/scenes/wallpaper-edge-100.json b/crates/accshift-egui/parity/scenes/wallpaper-edge-100.json new file mode 100644 index 00000000..6c685a33 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/wallpaper-edge-100.json @@ -0,0 +1,50 @@ +{ + "id": "wallpaper-edge-100", + "scenario": "dev", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "liquid-glass", + "locale": "en", + "seed": { + "SETTINGS": { + "viewMode": "grid", + "animations": "off", + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": false + } + } + }, + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 3000 + }, + { + "shot": "grid" + }, + { + "move": [645, 150] + }, + { + "wait": 500 + }, + { + "shot": "hover" + } + ], + "wallpaper": { + "image": "wallpaper.png", + "rect": [0, 0, 1920, 1080], + "window": [1500, 700] + } +} diff --git a/crates/accshift-egui/parity/scenes/wallpaper.png b/crates/accshift-egui/parity/scenes/wallpaper.png new file mode 100644 index 00000000..2156cab6 Binary files /dev/null and b/crates/accshift-egui/parity/scenes/wallpaper.png differ diff --git a/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-100.json b/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-100.json new file mode 100644 index 00000000..125c208d --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-100.json @@ -0,0 +1,162 @@ +{ + "id": "workspace-deep-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "deep" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-150.json b/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-150.json new file mode 100644 index 00000000..1d4cb139 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-150.json @@ -0,0 +1,162 @@ +{ + "id": "workspace-deep-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "deep" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-deep-light-100.json b/crates/accshift-egui/parity/scenes/workspace-deep-light-100.json new file mode 100644 index 00000000..3884f4eb --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-deep-light-100.json @@ -0,0 +1,162 @@ +{ + "id": "workspace-deep-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "deep" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-deep-light-150.json b/crates/accshift-egui/parity/scenes/workspace-deep-light-150.json new file mode 100644 index 00000000..226cfccf --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-deep-light-150.json @@ -0,0 +1,162 @@ +{ + "id": "workspace-deep-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "deep" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-glass-dark-100.json b/crates/accshift-egui/parity/scenes/workspace-glass-dark-100.json new file mode 100644 index 00000000..3d4c372d --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-glass-dark-100.json @@ -0,0 +1,98 @@ +{ + "id": "workspace-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "root" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-folder" + }, + { + "move": [783, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-grid" + }, + { + "move": [815, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-list" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "smurfs" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-back" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [639, 60] + }, + { + "text": "ra" + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "search" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-glass-dark-150.json b/crates/accshift-egui/parity/scenes/workspace-glass-dark-150.json new file mode 100644 index 00000000..b6ff06b3 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-glass-dark-150.json @@ -0,0 +1,98 @@ +{ + "id": "workspace-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "root" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-folder" + }, + { + "move": [783, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-grid" + }, + { + "move": [815, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-list" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "smurfs" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-back" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [639, 60] + }, + { + "text": "ra" + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "search" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-huge-100.json b/crates/accshift-egui/parity/scenes/workspace-huge-100.json new file mode 100644 index 00000000..e60d099b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-huge-100.json @@ -0,0 +1,38 @@ +{ + "id": "workspace-huge-100", + "scenario": "huge", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "top" + }, + { + "scroll": [430, 260, 1942] + }, + { + "move": [250, 18] + }, + { + "wait": 900 + }, + { + "shot": "half" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-huge-150.json b/crates/accshift-egui/parity/scenes/workspace-huge-150.json new file mode 100644 index 00000000..3d4abdf8 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-huge-150.json @@ -0,0 +1,38 @@ +{ + "id": "workspace-huge-150", + "scenario": "huge", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "top" + }, + { + "scroll": [430, 260, 1942] + }, + { + "move": [250, 18] + }, + { + "wait": 900 + }, + { + "shot": "half" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-light-100.json b/crates/accshift-egui/parity/scenes/workspace-light-100.json new file mode 100644 index 00000000..ea0cb08f --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-light-100.json @@ -0,0 +1,98 @@ +{ + "id": "workspace-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "root" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-folder" + }, + { + "move": [783, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-grid" + }, + { + "move": [815, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-list" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "smurfs" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-back" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [639, 60] + }, + { + "text": "ra" + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "search" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-light-150.json b/crates/accshift-egui/parity/scenes/workspace-light-150.json new file mode 100644 index 00000000..9e1e6bfc --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-light-150.json @@ -0,0 +1,98 @@ +{ + "id": "workspace-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "root" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-folder" + }, + { + "move": [783, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-grid" + }, + { + "move": [815, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-list" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "smurfs" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-back" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [639, 60] + }, + { + "text": "ra" + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "search" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-100.json b/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-100.json new file mode 100644 index 00000000..506231d2 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-100.json @@ -0,0 +1,146 @@ +{ + "id": "workspace-sections-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "sections" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-150.json b/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-150.json new file mode 100644 index 00000000..caba0def --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-150.json @@ -0,0 +1,146 @@ +{ + "id": "workspace-sections-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "sections" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-sections-light-100.json b/crates/accshift-egui/parity/scenes/workspace-sections-light-100.json new file mode 100644 index 00000000..320534c6 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-sections-light-100.json @@ -0,0 +1,146 @@ +{ + "id": "workspace-sections-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "sections" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-sections-light-150.json b/crates/accshift-egui/parity/scenes/workspace-sections-light-150.json new file mode 100644 index 00000000..d6f476be --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-sections-light-150.json @@ -0,0 +1,146 @@ +{ + "id": "workspace-sections-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "sections" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/zoom-110-light-150.json b/crates/accshift-egui/parity/scenes/zoom-110-light-150.json new file mode 100644 index 00000000..bdcb1dd3 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/zoom-110-light-150.json @@ -0,0 +1,43 @@ +{ + "id": "zoom-110-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "SETTINGS": { + "uiScalePercent": 110 + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "grid" + }, + { + "click": [96, 20] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "shot": "settings" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/zoom-125-glass-dark-100.json b/crates/accshift-egui/parity/scenes/zoom-125-glass-dark-100.json new file mode 100644 index 00000000..dbc76aab --- /dev/null +++ b/crates/accshift-egui/parity/scenes/zoom-125-glass-dark-100.json @@ -0,0 +1,43 @@ +{ + "id": "zoom-125-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "uiScalePercent": 125 + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "grid" + }, + { + "click": [109, 22] + }, + { + "move": [430, 5] + }, + { + "wait": 700 + }, + { + "shot": "settings" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/zoom-90-glass-dark-150.json b/crates/accshift-egui/parity/scenes/zoom-90-glass-dark-150.json new file mode 100644 index 00000000..22868b77 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/zoom-90-glass-dark-150.json @@ -0,0 +1,31 @@ +{ + "id": "zoom-90-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "uiScalePercent": 90 + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [430, 5] + }, + { + "wait": 300 + }, + { + "shot": "grid" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/zoom-keys-glass-dark-100.json b/crates/accshift-egui/parity/scenes/zoom-keys-glass-dark-100.json new file mode 100644 index 00000000..c9f51a6c --- /dev/null +++ b/crates/accshift-egui/parity/scenes/zoom-keys-glass-dark-100.json @@ -0,0 +1,55 @@ +{ + "id": "zoom-keys-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "SETTINGS": { + "uiScalePercent": 100 + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [430, 5] + }, + { + "key": "Ctrl+=" + }, + { + "key": "Ctrl+=" + }, + { + "wait": 400 + }, + { + "shot": "ctrl-plus" + }, + { + "key": "Ctrl+-" + }, + { + "wait": 400 + }, + { + "shot": "ctrl-minus" + }, + { + "key": "Ctrl+0" + }, + { + "wait": 400 + }, + { + "shot": "reset" + } + ] +} diff --git a/crates/accshift-egui/src/app/accounts.rs b/crates/accshift-egui/src/app/accounts.rs new file mode 100644 index 00000000..0ca44bde --- /dev/null +++ b/crates/accshift-egui/src/app/accounts.rs @@ -0,0 +1,672 @@ +//! The account side of the workspace: M2's loading and display pipeline +//! per platform tab, the folder navigation and search, and the avatar +//! textures. One [`Accounts`] lives in [`super::Booted`]. +//! +//! A frame, before drawing ([`Accounts::update`]): poll the loading jobs +//! with the previous frame's visible ids, tick the search debounce, compute +//! the display lists, feed `track_priming` the new visible ids, then build +//! the [`DisplayView`] the workspace draws. Repaint deadlines from the loader +//! and the debounce are requested from egui. +//! +//! The Steam tab also runs the CS2 bridge queue (`loadCs2BridgeData` when +//! the tab shows accounts, `triggerCs2BridgeCheck` after a switch) and keeps +//! each card's CS2 section and username badge in [`Cs2Cards`]. +//! +//! The add-account flow ([`PlatformAddFlow`]) starts from the loader's +//! `AddFlowStarted`, polls its setup on the same frame tick and puts its +//! pending card, forced open on the setup sections, in the lists. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; +use std::time::Instant; + +use accshift_app::accounts::loader::{AccountLoader, LoadOptions}; +use accshift_app::accounts::loading::{AccountLoading, LoadingContext}; +use accshift_app::accounts::profile_cache::{ProfileCaches, UrlPolicy}; +use accshift_app::accounts::LoadingEvent; +use accshift_app::cs2_bridge::Cs2BridgeQueue; +use accshift_app::display::{ + build_display_view, build_mock_view, CardContext, DisplayBody, DisplayLists, DisplayPipeline, + DisplayView, DragPreview, FolderNavigation, PipelineInput, +}; +use accshift_app::drag::DragManager; +use accshift_app::i18n::Locale; +use accshift_app::jobs::Jobs; +use accshift_app::platform_add_flow::{ + AddFlowContext, AddFlowEvent, AddFlowView, PlatformAddFlow, SetupExtension, +}; +use accshift_app::platforms::card::{CardSection, UsernameBadge}; +use accshift_app::platforms::descriptors::PlatformDescriptor; +use accshift_app::platforms::steam::cs2::{cs2_extension_sections, cs2_username_badge}; +use accshift_app::platforms::{self as app_platforms, PlatformAccount, PlatformRegistry}; +use accshift_app::stores::ClientStores; +use accshift_app::time::Clock; +use egui::Context; +use serde_json::Value; + +use super::avatars::AvatarTextures; +use super::platforms::RuntimeOs; + +/// `RuntimeOs` of the egui shell in the app crate's type. +pub fn app_os(os: RuntimeOs) -> app_platforms::RuntimeOs { + match os { + RuntimeOs::Windows => app_platforms::RuntimeOs::Windows, + RuntimeOs::Linux => app_platforms::RuntimeOs::Linux, + RuntimeOs::Macos => app_platforms::RuntimeOs::Macos, + RuntimeOs::Unknown => app_platforms::RuntimeOs::Unknown, + } +} + +/// `loadAccounts(silent, showRefreshedToast, forceRefresh, checkBans, +/// deferBackground)`. +fn load_options(silent: bool, defer_background: bool) -> LoadOptions { + LoadOptions { + silent, + show_refreshed_toast: false, + force_refresh: false, + check_bans: false, + defer_background, + } +} + +/// One account's CS2 bridge content: `cs2ExtensionSections` and +/// `cs2UsernameBadge`. +#[derive(Clone, Debug, Default)] +pub struct Cs2Card { + pub sections: Vec, + pub badge: Option, +} + +/// The CS2 content of the Steam tab's cards, rebuilt when the bridge cache +/// changes (`getCs2BridgeVersion`). Empty on every other tab. +#[derive(Default)] +pub struct Cs2Cards { + pub queue: Cs2BridgeQueue, + /// The tab and account list version the load effect last ran for: it + /// reruns when either changes, as the `$effect` reading `activeTab` and + /// `loader.accounts`. + effect: Option<(String, u64)>, + /// The bridge version and account list version the map was built from. + built: Option<(u64, u64)>, + by_id: HashMap, +} + +impl Cs2Cards { + /// The card content of `account_id`, if the bridge has a row for it. + pub fn card(&self, account_id: &str) -> Option<&Cs2Card> { + self.by_id.get(account_id) + } + + /// One frame: queue the tab's load and the switch checks, run the queue, + /// then rebuild the map when the cache moved. + fn update( + &mut self, + jobs: &Jobs, + registry: &PlatformRegistry, + tab: &str, + loader: &AccountLoader, + checks: Vec, + now_ms: f64, + ) { + let Some(steam) = registry.adapter("steam") else { + return; + }; + let accounts = loader.accounts(); + let accounts_version = loader.accounts_version(); + for steam_id in checks { + self.queue.check(steam_id); + } + let effect = self.effect.as_ref().map(|(t, v)| (t.as_str(), *v)); + if effect != Some((tab, accounts_version)) { + self.effect = Some((tab.to_string(), accounts_version)); + let state = steam.state(); + self.queue + .ensure_loaded(&state.cs2, tab, accounts.len(), now_ms); + } + self.queue.tick(jobs, &steam, now_ms); + if tab != "steam" { + self.by_id.clear(); + self.built = None; + return; + } + let state = steam.state(); + let key = (state.cs2.version(), accounts_version); + if self.built == Some(key) { + return; + } + self.built = Some(key); + self.by_id = accounts + .iter() + .filter_map(|account| { + let id = &account.id; + let data = state.cs2.data(id); + let card = Cs2Card { + sections: cs2_extension_sections(data), + badge: cs2_username_badge(data), + }; + (card.badge.is_some() || !card.sections.is_empty()).then(|| (id.clone(), card)) + }) + .collect(); + } +} + +/// The shipped platforms plus the user descriptors of the boot payload. +pub fn platform_registry(user_platforms: &[Value]) -> PlatformRegistry { + let mut registry = PlatformRegistry::shipped(); + let descriptors: Vec = user_platforms + .iter() + .filter_map(PlatformDescriptor::from_value) + .collect(); + registry.register_user_platforms(&descriptors); + registry +} + +/// Everything the workspace reads, owned by the model. +pub struct Accounts { + pub registry: PlatformRegistry, + pub loading: AccountLoading, + pub pipeline: DisplayPipeline, + pub navigation: FolderNavigation, + pub clock: Clock, + pub runtime_os: app_platforms::RuntimeOs, + /// The last computed lists (their `visible_account_ids` feed the next + /// poll, as the grid's `visibleRenderedAccountIds` does in TS). + pub lists: DisplayLists, + pub view: DisplayView, + /// The view drawn last frame: a collapsing section slides its cards out + /// from it. + pub prev_view: DisplayView, + pub avatars: AvatarTextures, + pub cs2: Cs2Cards, + /// `onboarding.mockActive`: the tour's mock accounts replace the grid. + pub tour: Option>, + /// `bulkEditSelectedIds` while the bulk edit mode is open: the cards + /// turn accent when selected and switch on one click. + pub bulk_selection: Option>, + /// `createDragManager`: the card being pressed or dragged. + pub drag: DragManager, + /// The keys of the dragged card's `{#each}` as last rendered. + drag_order: Vec, + /// `createPlatformAddFlowController`. + pub add_flow: PlatformAddFlow, + /// The pending setup card of this frame, and its setup sections. + pub setup: Option<(PlatformAccount, SetupExtension)>, +} + +/// The add flow's view of the app on `tab`. +fn add_flow_view<'a>(navigation: &'a FolderNavigation, tab: &'a str) -> AddFlowView<'a> { + AddFlowView { + active_tab: tab, + current_folder_id: navigation.current_folder_id(), + is_searching: navigation.is_searching(), + } +} + +impl Accounts { + /// The `removeAccount` glue of `App.svelte`: drop it from the loader, + /// sync the folders with the accounts left, refresh the items. + pub fn remove_account( + &mut self, + stores: &mut ClientStores, + active_tab: &str, + account_id: &str, + ) { + self.loading.loader.remove_account(account_id); + let ids: Vec = self + .loading + .loader + .accounts() + .iter() + .map(|account| account.id.clone()) + .collect(); + stores.sync_accounts(&ids, active_tab); + self.navigation.refresh_current_items(stores, active_tab); + } + + /// The boot wiring: the loader on the active tab over `registry` (see + /// [`platform_registry`]), and the first `loadAccounts(false, false, + /// false, false, true)` of `App.svelte`'s mount. + pub fn boot( + jobs: &Jobs, + stores: &mut ClientStores, + registry: PlatformRegistry, + active_tab: &str, + runtime_os: RuntimeOs, + clock: Clock, + mock: bool, + ) -> Accounts { + // The mock's avatars are local SVG files; the real backend only + // trusts http(s). + let policy = if mock { + UrlPolicy::HttpOrFile + } else { + UrlPolicy::HttpOnly + }; + let caches = Arc::new(ProfileCaches::new(policy, clock.now_ms())); + let loading = AccountLoading::new(Some(active_tab.to_string()), caches); + let mut accounts = Accounts { + registry, + loading, + pipeline: DisplayPipeline::new(), + navigation: FolderNavigation::new(), + clock, + runtime_os: app_os(runtime_os), + lists: DisplayLists::default(), + view: DisplayView { + error: None, + body: DisplayBody::Loading, + }, + prev_view: DisplayView { + error: None, + body: DisplayBody::Loading, + }, + avatars: AvatarTextures::new(policy, jobs.backend().avatar_cache_dir()), + cs2: Cs2Cards::default(), + tour: None, + bulk_selection: None, + drag: DragManager::default(), + drag_order: Vec::new(), + add_flow: PlatformAddFlow::new(), + setup: None, + }; + accounts + .navigation + .refresh_current_items(stores, active_tab); + accounts.load(jobs, stores, active_tab, load_options(false, true)); + accounts + } + + /// Runs a loading handler on `tab` with the last frame's visible ids. + fn with_loading( + &mut self, + jobs: &Jobs, + stores: &mut ClientStores, + tab: &str, + run: impl FnOnce(&mut AccountLoading, &mut LoadingContext<'_>) -> R, + ) -> R { + let visible = self.lists.visible_account_ids.clone(); + let mut ctx = LoadingContext { + jobs, + registry: &self.registry, + stores, + active_tab: tab, + runtime_os: self.runtime_os, + visible_ids: &visible, + is_searching: self.navigation.is_searching(), + clock: &self.clock, + now: Instant::now(), + }; + run(&mut self.loading, &mut ctx) + } + + /// `loadAccounts(options)` on `tab`. + pub fn load( + &mut self, + jobs: &Jobs, + stores: &mut ClientStores, + tab: &str, + options: LoadOptions, + ) { + self.with_loading(jobs, stores, tab, |loading, ctx| { + loading.load_accounts(ctx, options) + }); + } + + /// `handleAccountSwitch(account)` on `tab`: false when a persona switch + /// runs, a switch already runs or the tab has no adapter. The result + /// lands as [`LoadingEvent::SwitchFinished`] from [`Self::update`]. + pub fn switch( + &mut self, + jobs: &Jobs, + stores: &mut ClientStores, + tab: &str, + account: &PlatformAccount, + persona_switching: bool, + ) -> bool { + self.with_loading(jobs, stores, tab, |loading, ctx| { + loading.handle_account_switch(ctx, account, persona_switching) + }) + } + + /// `handleAddAccountClick`: refused while the tab is unusable, an add + /// runs or a setup is open. A setup the platform starts lands as + /// `AddFlowStarted` from [`Self::update`]. + pub fn add_account(&mut self, jobs: &Jobs, stores: &mut ClientStores, tab: &str) -> bool { + let active = self.add_flow.is_active(); + self.with_loading(jobs, stores, tab, |loading, ctx| { + loading.handle_add_account_click(ctx, active) + }) + } + + /// Runs an add flow handler with the app's view of `tab`. + fn with_add_flow( + &mut self, + jobs: &Jobs, + tab: &str, + now: Instant, + run: impl FnOnce(&mut PlatformAddFlow, &AddFlowContext<'_>) -> R, + ) -> R { + let ctx = AddFlowContext { + jobs, + registry: &self.registry, + view: add_flow_view(&self.navigation, tab), + caches: self.loading.caches(), + clock: &self.clock, + now, + }; + run(&mut self.add_flow, &ctx) + } + + /// `addFlow.cancel()`. + pub fn cancel_add_flow(&mut self, jobs: &Jobs, tab: &str) { + self.with_add_flow(jobs, tab, Instant::now(), |flow, ctx| flow.cancel(ctx)); + } + + /// `addFlow.cancelIfConflicting(platformId, accountId?)`. + pub fn cancel_add_flow_if_conflicting( + &mut self, + jobs: &Jobs, + tab: &str, + platform_id: &str, + account_id: Option<&str>, + ) { + self.with_add_flow(jobs, tab, Instant::now(), |flow, ctx| { + flow.cancel_if_conflicting(ctx, platform_id, account_id) + }); + } + + /// True when `account_id` is the pending setup card's. + pub fn is_pending_setup(&self, account_id: &str) -> bool { + self.setup + .as_ref() + .is_some_and(|(account, _)| account.id == account_id) + } + + /// `handleTabChange` once the tab is known usable and different: + /// everything in flight dropped, the folder reset, the accounts loaded + /// silently, the search cleared. + pub fn change_tab(&mut self, jobs: &Jobs, stores: &mut ClientStores, tab: &str) { + self.loading.switch_tab(&self.registry, tab); + self.navigation.set_current_folder_id(None); + self.navigation.refresh_current_items(stores, tab); + self.load(jobs, stores, tab, load_options(true, false)); + self.set_search("", Instant::now()); + self.pipeline.clear_collapsed_sections(); + } + + /// `handleRefreshClick`: the active tab again, not silent, with the + /// "refreshed" toast and the ban check. + pub fn refresh(&mut self, jobs: &Jobs, stores: &mut ClientStores, tab: &str) { + self.with_loading(jobs, stores, tab, |loading, ctx| { + loading.handle_refresh_click(ctx) + }); + } + + /// `navigateTo(folderId)` without history: the folder, its items. + pub fn navigate_to(&mut self, stores: &ClientStores, tab: &str, folder: Option) { + self.navigation.set_current_folder_id(folder); + self.navigation.refresh_current_items(stores, tab); + } + + /// `navigateToParentFolder`: the parent folder, and the search cleared. + pub fn navigate_back(&mut self, stores: &ClientStores, tab: &str) { + let Some(current) = self.navigation.current_folder_id().map(str::to_string) else { + return; + }; + let parent = stores + .folders() + .folder(¤t) + .and_then(|folder| folder.parent_id.clone()); + self.navigate_to(stores, tab, parent); + self.set_search("", Instant::now()); + } + + /// `getItems` of `createCardFocus`: the cards in visual order, folders + /// then accounts, the cards of a collapsed section left out. + pub fn card_focus_items(&self) -> Vec { + self.card_focus_iter().cloned().collect() + } + + /// A hash of [`Self::card_focus_items`]: the card list changed when it + /// does. + pub fn card_focus_signature(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for item in self.card_focus_iter() { + item.hash(&mut hasher); + } + hasher.finish() + } + + fn card_focus_iter(&self) -> Box + '_> { + match &self.lists.sections { + Some(sections) => Box::new( + sections + .iter() + .filter(|section| !self.pipeline.is_section_collapsed(section)) + .flat_map(|section| section.folder_items.iter().chain(§ion.account_items)), + ), + None => Box::new( + self.lists + .folder_items + .iter() + .chain(&self.lists.account_items), + ), + } + } + + /// `navigation.searchQuery = value` and the pipeline's debounce. + pub fn set_search(&mut self, query: &str, now: Instant) { + if query == self.navigation.search_query() { + return; + } + self.navigation.set_search_query(query); + self.pipeline.set_query(query, now); + } + + /// One frame of the account side. `grid_hidden`: the workspace is not + /// drawn (settings or personas open), so nothing primes. Returns the + /// loader events the app owns (toasts, the switch outcome and its + /// minimize) and the add flow's (its toasts, the account it added). + #[allow(clippy::too_many_arguments)] + pub fn update( + &mut self, + ctx: &Context, + jobs: &Jobs, + stores: &mut ClientStores, + tab: &str, + expanded_folders: bool, + grid_hidden: bool, + locale: Locale, + now: Instant, + ) -> (Vec, Vec) { + let visible = std::mem::take(&mut self.lists.visible_account_ids); + let events = { + let mut lctx = LoadingContext { + jobs, + registry: &self.registry, + stores, + active_tab: tab, + runtime_os: self.runtime_os, + visible_ids: &visible, + is_searching: self.navigation.is_searching(), + clock: &self.clock, + now, + }; + self.loading.poll(&mut lctx) + }; + self.lists.visible_account_ids = visible; + let mut checks = Vec::new(); + let mut rest = Vec::new(); + for event in events { + match event { + LoadingEvent::AccountsLoaded { platform_id } => { + if platform_id == tab { + self.navigation.refresh_current_items(stores, tab); + } + } + LoadingEvent::Cs2Check { steam_id } => checks.push(steam_id), + LoadingEvent::AddFlowStarted { + platform_id, + status, + } => self.with_add_flow(jobs, tab, now, |flow, ctx| { + flow.start(ctx, &platform_id, status) + }), + event => rest.push(event), + } + } + let mut added = Vec::new(); + let flow_events = { + let ctx = AddFlowContext { + jobs, + registry: &self.registry, + view: add_flow_view(&self.navigation, tab), + caches: self.loading.caches(), + clock: &self.clock, + now, + }; + self.add_flow.tick(&ctx, stores) + }; + for event in flow_events { + match event { + AddFlowEvent::LoadAccounts(options) => self.load(jobs, stores, tab, options), + event => added.push(event), + } + } + let view = add_flow_view(&self.navigation, tab); + self.setup = self.add_flow.pending_setup_account(&view).map(|pending| { + let extension = self + .add_flow + .setup_extension_content(&pending.id, locale) + .unwrap_or_default(); + (pending.to_account(locale), extension) + }); + self.cs2.update( + jobs, + &self.registry, + tab, + &self.loading.loader, + checks, + self.clock.now_ms(), + ); + self.pipeline.tick(now); + self.lists = self.pipeline.compute(&PipelineInput { + navigation: &self.navigation, + drag: DragPreview { + is_dragging: self.drag.is_dragging(), + item: self.drag.drag_item(), + preview_index: self.drag.preview_index(), + }, + accounts: self.loading.loader.accounts(), + pending_setup_account: self.setup.as_ref().map(|(account, _)| account), + expanded_folders, + active_tab: tab, + stores, + }); + self.track_drag_order(ctx); + { + let lctx = LoadingContext { + jobs, + registry: &self.registry, + stores, + active_tab: tab, + runtime_os: self.runtime_os, + visible_ids: &self.lists.visible_account_ids, + is_searching: self.navigation.is_searching(), + clock: &self.clock, + now, + }; + self.loading.track_priming(&lctx, grid_hidden); + } + let adapter = self.registry.adapter(tab); + let card_ctx = CardContext { + loader: &self.loading.loader, + stores, + navigation: &self.navigation, + platform: self.registry.definition(tab), + adapter: adapter.as_deref(), + active_tab: tab, + pending_setup_account: self.setup.as_ref().map(|(account, _)| account), + bulk_selection: self.bulk_selection.as_ref(), + drag_item: self.drag.drag_item(), + collapsed_sections: self.pipeline.collapsed_sections(), + }; + let view = match &self.tour { + Some(mock) => build_mock_view(&card_ctx, mock), + None => build_display_view(&card_ctx, &self.lists), + }; + self.prev_view = std::mem::replace(&mut self.view, view); + self.avatars.poll(ctx, jobs); + let deadlines = [ + self.loading.next_deadline(now), + self.pipeline.next_deadline(), + self.add_flow.next_deadline(), + ]; + if let Some(at) = deadlines.into_iter().flatten().min() { + ctx.request_repaint_after(at.saturating_duration_since(now)); + } + (rest, added) + } + + /// A preview reorder moving the dragged card's node takes `:active` + /// off it, as Chromium does when Svelte moves the node, and every node + /// it moves replays its entrance. + fn track_drag_order(&mut self, ctx: &egui::Context) { + // From the press on: the move that starts the drag can show the + // preview in the same frame. + let item = match self.drag.drag_item() { + Some(item) if self.drag.is_dragging() => item, + _ => match self.drag.pressed() { + Some(card) => &card.item, + None => { + self.drag_order.clear(); + return; + } + }, + }; + let ids = |items: &[accshift_app::folders::ItemRef]| -> Vec { + items.iter().map(|i| i.id.clone()).collect() + }; + let order = match (&self.lists.sections, item.kind) { + (Some(sections), accshift_app::folders::ItemKind::Folder) => sections + .iter() + .map(|s| { + s.folder + .as_ref() + .map_or("__root__".into(), |f| f.id.clone()) + }) + .collect(), + (_, accshift_app::folders::ItemKind::Folder) => ids(&self.lists.folder_items), + (_, accshift_app::folders::ItemKind::Account) => ids(&self.lists.account_items), + }; + if !self.drag_order.is_empty() && order != self.drag_order { + let moved = super::drag::each_moved(&self.drag_order, &order); + if moved.contains(&item.id) { + crate::ui::components::drop_held(ctx); + } + let section = self.lists.sections.is_some() + && item.kind == accshift_app::folders::ItemKind::Folder; + let nodes = moved + .into_iter() + .map(|id| { + if section { + crate::screens::workspace::Moved::Section(id) + } else { + crate::screens::workspace::Moved::Card(item.kind, id) + } + }) + .collect(); + crate::screens::workspace::remount(ctx, nodes); + } + self.drag_order = order; + } + + /// Nothing left to land: no job, no debounce, no avatar download. + pub fn settled(&self) -> bool { + !self.loading.loader.loading() + && !self.loading.has_pending_work() + && !self.cs2.queue.is_busy() + && !self.add_flow.is_busy() + && self.pipeline.next_deadline().is_none() + } +} diff --git a/crates/accshift-egui/src/app/avatar_pack.rs b/crates/accshift-egui/src/app/avatar_pack.rs new file mode 100644 index 00000000..4e69f6c1 --- /dev/null +++ b/crates/accshift-egui/src/app/avatar_pack.rs @@ -0,0 +1,239 @@ +//! The decoded avatars of the last session, so a card shows its picture on +//! the frame it mounts, with no fade: one file of the http avatars the +//! session uploaded, by URL and device size, read whole when the workspace +//! boots. Decoding them again took 100 to 150 ms on the job pool, then the +//! late pictures faded in for 220 ms. +//! +//! Avatar URLs of Steam and Roblox name their content, so an entry never +//! goes stale; a new picture is a new URL. The file is rewritten only when +//! the session decoded an avatar it lacked, keeping the ones this session +//! used first, under [`MAX_BYTES`]. + +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use egui::{Color32, ColorImage}; + +const MAGIC: &[u8; 8] = b"ASAVPK01"; +const FILE: &str = "decoded.bin"; +/// 16 MB: 800 avatars at 72 px. +const MAX_BYTES: usize = 16 << 20; + +/// An entry: URL, device size, pixels. +pub type Entry = (String, u32, Arc); + +type Loaded = HashMap<(String, u32), Arc>; + +/// The read [`prefetch`] started, for the [`Pack::read`] of that folder. +static PREFETCH: Mutex)>> = Mutex::new(None); + +/// Starts reading the pack in `dir` on a thread, from `main`: its read and +/// parse (5 to 8 ms) then overlap the event loop and window creation +/// instead of delaying the first frame. +pub fn prefetch(dir: PathBuf) { + let at = dir.clone(); + let read = std::thread::spawn(move || load(&at)); + if let Ok(mut slot) = PREFETCH.lock() { + *slot = Some((dir, read)); + } +} + +fn load(dir: &Path) -> Loaded { + std::fs::read(dir.join(FILE)) + .ok() + .and_then(|bytes| parse(&bytes)) + .unwrap_or_default() +} + +/// The pack of the last session, taken entry by entry as cards ask for them. +#[derive(Default)] +pub struct Pack { + dir: Option, + loaded: HashMap<(String, u32), Arc>, + /// A decode landed that the file lacks. + pub dirty: bool, +} + +impl Pack { + /// Reads the pack in `dir` (the avatar disk cache); empty when there is + /// none or it does not parse. + pub fn read(dir: Option) -> Pack { + let prefetched = PREFETCH.lock().ok().and_then(|mut slot| slot.take()); + let loaded = match (dir.as_deref(), prefetched) { + (Some(dir), Some((at, read))) if at == dir => read.join().unwrap_or_default(), + (Some(dir), _) => load(dir), + (None, _) => Loaded::default(), + }; + Pack { + dir, + loaded, + dirty: false, + } + } + + pub fn len(&self) -> usize { + self.loaded.len() + } + + pub fn is_empty(&self) -> bool { + self.loaded.is_empty() + } + + /// The pixels of `url` at `px`, once. + pub fn take(&mut self, url: &str, px: u32) -> Option> { + if self.is_empty() { + return None; + } + self.loaded.remove(&(url.to_string(), px)) + } + + /// What to write: `used` (this session's), then what is left of the + /// last pack, under the size cap. None when nothing new was decoded. + pub fn take_write(&mut self, used: Vec) -> Option<(PathBuf, Vec)> { + if !std::mem::take(&mut self.dirty) { + return None; + } + let dir = self.dir.clone()?; + let mut total = 0; + let rest = self + .loaded + .iter() + .map(|((url, px), image)| (url.clone(), *px, Arc::clone(image))); + let entries = used + .into_iter() + .chain(rest) + .take_while(|(url, _, image)| { + total += url.len() + 16 + image.pixels.len() * 4; + total <= MAX_BYTES + }) + .collect(); + Some((dir, entries)) + } +} + +/// Writes `entries` to `dir`, through a temporary file so a reader never +/// sees half of it. +pub fn write(dir: &Path, entries: &[Entry]) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + let mut out = Vec::with_capacity( + 12 + entries + .iter() + .map(|(url, _, image)| url.len() + 16 + image.pixels.len() * 4) + .sum::(), + ); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&(entries.len() as u32).to_le_bytes()); + for (url, px, image) in entries { + out.extend_from_slice(&(url.len() as u32).to_le_bytes()); + out.extend_from_slice(url.as_bytes()); + for n in [*px, image.size[0] as u32, image.size[1] as u32] { + out.extend_from_slice(&n.to_le_bytes()); + } + for c in &image.pixels { + out.extend_from_slice(&c.to_array()); + } + } + let tmp = dir.join(format!("{FILE}.{}.tmp", std::process::id())); + let mut file = std::fs::File::create(&tmp)?; + file.write_all(&out)?; + drop(file); + std::fs::rename(&tmp, dir.join(FILE)).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + }) +} + +fn parse(bytes: &[u8]) -> Option { + let mut rest = bytes.strip_prefix(MAGIC)?; + let u32_at = |rest: &mut &[u8]| -> Option { + let (n, tail) = rest.split_first_chunk::<4>()?; + *rest = tail; + Some(u32::from_le_bytes(*n)) + }; + let count = u32_at(&mut rest)?; + let mut map = HashMap::with_capacity(count.min(4096) as usize); + for _ in 0..count { + let len = u32_at(&mut rest)? as usize; + let url = std::str::from_utf8(rest.get(..len)?).ok()?.to_string(); + rest = &rest[len..]; + let px = u32_at(&mut rest)?; + let (w, h) = (u32_at(&mut rest)? as usize, u32_at(&mut rest)? as usize); + let n = w.checked_mul(h)?.checked_mul(4)?; + let pixels = rest + .get(..n)? + .as_chunks::<4>() + .0 + .iter() + .map(|&[r, g, b, a]| Color32::from_rgba_premultiplied(r, g, b, a)) + .collect(); + rest = &rest[n..]; + map.insert((url, px), Arc::new(ColorImage::new([w, h], pixels))); + } + Some(map) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn image(seed: u8) -> Arc { + let pixels = (0..6) + .map(|i| Color32::from_rgba_premultiplied(seed, i, 0, 200)) + .collect(); + Arc::new(ColorImage::new([3, 2], pixels)) + } + + fn dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("accshift-pack-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + #[test] + fn a_written_pack_reads_back() { + let dir = dir("round"); + let a = image(1); + write(&dir, &[("https://a/1.jpg".into(), 68, Arc::clone(&a))]).unwrap(); + let mut pack = Pack::read(Some(dir.clone())); + assert_eq!(pack.len(), 1); + assert_eq!(pack.take("https://a/1.jpg", 102), None); + let back = pack.take("https://a/1.jpg", 68).unwrap(); + assert_eq!(back.size, [3, 2]); + assert_eq!(back.pixels, a.pixels); + assert_eq!(pack.take("https://a/1.jpg", 68), None, "taken once"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn it_writes_only_after_a_new_decode_and_keeps_unused_entries() { + let dir = dir("dirty"); + write( + &dir, + &[("u1".into(), 68, image(1)), ("u2".into(), 68, image(2))], + ) + .unwrap(); + let mut pack = Pack::read(Some(dir.clone())); + let used = pack.take("u1", 68).unwrap(); + assert!(pack + .take_write(vec![("u1".into(), 68, used.clone())]) + .is_none()); + pack.dirty = true; + let (_, entries) = pack + .take_write(vec![("u1".into(), 68, used), ("u3".into(), 68, image(3))]) + .unwrap(); + let urls: Vec<_> = entries.iter().map(|e| e.0.as_str()).collect(); + assert_eq!(urls, ["u1", "u3", "u2"]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_broken_file_reads_as_empty() { + let dir = dir("broken"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(FILE), b"ASAVPK01\x05\0\0\0\x10").unwrap(); + assert!(Pack::read(Some(dir.clone())).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/accshift-egui/src/app/avatars.rs b/crates/accshift-egui/src/app/avatars.rs new file mode 100644 index 00000000..b2b59fd4 --- /dev/null +++ b/crates/accshift-egui/src/app/avatars.rs @@ -0,0 +1,506 @@ +//! The avatar texture cache of the workspace. +//! +//! M2's [`AvatarImages`] turns an avatar URL into an [`AvatarImage`] (a +//! local file at once, an http download through the job pool, read from +//! [`AvatarDiskCache`] when a past launch stored it). Here each +//! image is decoded off the UI thread through [`Jobs`] at the device size +//! the card paints it, then uploaded on the UI thread, keyed by the image's +//! stable key and that size. The card fades the picture in on its own +//! (`avatar_fade_in`), unless it was there when the card mounted: the +//! pictures of the last session come decoded from [`super::avatar_pack`]. +//! +//! Decoders: SVG (resvg, the mock's avatars), PNG, and JPEG (what Steam's +//! CDN serves). A bitmap is cropped to its centred square (`object-fit: +//! cover` in a square box) and scaled to the box as WebView2 scales it +//! ([`crate::ui::resample`]). + +use std::collections::HashMap; +use std::io::Cursor; +use std::sync::{Arc, OnceLock}; + +use accshift_app::accounts::avatars::{ + fetch_avatar_image_cached, stable_host, AvatarDiskCache, AvatarFetch, AvatarImage, + AvatarImages, HttpAvatarClient, +}; +use accshift_app::accounts::profile_cache::UrlPolicy; +use accshift_app::jobs::{Jobs, Pending, Poll}; +use egui::{Color32, ColorImage, Context, TextureHandle, TextureId, TextureOptions}; + +use super::avatar_pack::{self, Pack}; + +/// What a card can draw for an avatar URL this frame. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum AvatarTexture { + Ready { + texture: TextureId, + size: [usize; 2], + }, + /// Downloading or decoding. + Pending, + /// Not drawable: the card shows its initials. + Failed, +} + +enum Entry { + Decoding(Pending>), + /// The texture, and its pixels for the blurred copy. + Ready(TextureHandle, Arc), + Failed, +} + +/// URL to image (M2) and image to texture (here). +pub struct AvatarTextures { + images: AvatarImages, + /// Built by the first download that misses the disk cache, on the job + /// pool: a launch served from the cache never builds it. + http: Arc, String>>>, + downloads: Vec<(String, Pending>)>, + textures: HashMap<(String, u32), Entry>, + /// `blur(6px) brightness(.5)` copies, by image, box size and sigma. + blurred: HashMap<(String, u32, u32), TextureHandle>, + pack: Pack, + /// The context of the last [`Self::poll`], to upload a packed picture + /// the frame a card asks for it. + ctx: Option, +} + +/// Starts reading the pack of decoded pictures in the avatar disk cache +/// `cache` on a thread, for the [`AvatarTextures::new`] of the same cache. +pub fn prefetch_pack(cache: std::path::PathBuf) { + avatar_pack::prefetch(cache.join("decoded")); +} + +impl AvatarTextures { + /// `cache`: the avatar disk cache, whose `decoded` folder holds the + /// pack of decoded pictures (read here unless [`prefetch_pack`] started + /// it: a few MB). + pub fn new(policy: UrlPolicy, cache: Option) -> Self { + let pack = Pack::read(cache.map(|dir| dir.join("decoded"))); + crate::bench::mark_with("avatar_pack", &format!("\"entries\":{}", pack.len())); + Self { + images: AvatarImages::new(policy), + http: Arc::default(), + downloads: Vec::new(), + textures: HashMap::new(), + blurred: HashMap::new(), + pack, + ctx: None, + } + } + + /// Lands finished downloads and decodes, and stores the pack once a + /// decode it lacked landed and nothing runs. Call once per frame. + pub fn poll(&mut self, ctx: &Context, jobs: &Jobs) { + if self.ctx.is_none() { + self.ctx = Some(ctx.clone()); + } + let images = &mut self.images; + self.downloads + .retain_mut(|(url, pending)| match pending.poll() { + Poll::Waiting => true, + Poll::Ready(result) => { + images.finish(url, result); + false + } + Poll::Gone => { + images.finish(url, Err("download job died".into())); + false + } + }); + for ((key, px), entry) in self.textures.iter_mut() { + let Entry::Decoding(pending) = entry else { + continue; + }; + match pending.poll() { + Poll::Waiting => {} + Poll::Ready(Ok(image)) => { + let image = Arc::new(image); + self.pack.dirty |= stable_host(key); + *entry = Entry::Ready( + ctx.load_texture( + format!("avatar-{key}-{px}"), + (*image).clone(), + TextureOptions::LINEAR, + ), + image, + ); + } + Poll::Ready(Err(_)) | Poll::Gone => *entry = Entry::Failed, + } + } + if self.pack.dirty && !self.busy() { + let used = self + .textures + .iter() + .filter_map(|((key, px), entry)| match entry { + Entry::Ready(_, image) if stable_host(key) => { + Some((key.clone(), *px, Arc::clone(image))) + } + _ => None, + }) + .collect(); + if let Some((dir, entries)) = self.pack.take_write(used) { + drop(jobs.run(move |_| avatar_pack::write(&dir, &entries))); + } + } + } + + /// The texture of `url` at `px` from the pack or an earlier upload. An + /// http image's key is its URL. + fn uploaded(&mut self, url: &str, px: u32) -> Option { + let key = (url.to_string(), px); + if !self.textures.contains_key(&key) { + let image = self.pack.take(url, px)?; + let ctx = self.ctx.as_ref()?; + let texture = ctx.load_texture( + format!("avatar-{url}-{px}"), + (*image).clone(), + TextureOptions::LINEAR, + ); + self.textures + .insert(key.clone(), Entry::Ready(texture, image)); + } + match self.textures.get(&key)? { + Entry::Ready(texture, _) => Some(AvatarTexture::Ready { + texture: texture.id(), + size: texture.size(), + }), + _ => None, + } + } + + /// The texture of `url` at `px` device pixels, starting its download or + /// decode if needed. + pub fn get(&mut self, jobs: &Jobs, url: &str, px: u32) -> AvatarTexture { + if let Some(texture) = self.uploaded(url, px) { + return texture; + } + let image = match self.images.request(url) { + AvatarFetch::Ready(image) => image, + AvatarFetch::Waiting => return AvatarTexture::Pending, + AvatarFetch::Failed => return AvatarTexture::Failed, + AvatarFetch::Start(url) => { + let http = Arc::clone(&self.http); + let target = url.clone(); + let pending = jobs.run(move |backend| { + let cache = backend.avatar_cache_dir().map(AvatarDiskCache::new); + let client = || { + http.get_or_init(|| HttpAvatarClient::new().map(Arc::new)) + .clone() + }; + fetch_avatar_image_cached(&target, client, cache.as_ref()) + }); + self.downloads.push((url, pending)); + return AvatarTexture::Pending; + } + }; + let key = (image.key().to_string(), px); + let entry = self + .textures + .entry(key) + .or_insert_with(|| Entry::Decoding(jobs.run(move |_| decode(&image, px)))); + match entry { + Entry::Decoding(_) => AvatarTexture::Pending, + Entry::Ready(texture, _) => AvatarTexture::Ready { + texture: texture.id(), + size: texture.size(), + }, + Entry::Failed => AvatarTexture::Failed, + } + } + + /// The picture of `url` decoded for a `px` box, covered into that box + /// and filtered with `blur(sigma) brightness(.5)` (`sigma` in device + /// px), once it is decoded. Made on the UI thread on first use: the box + /// is small and the card wants it the frame it arms. + pub fn blurred(&mut self, ctx: &Context, url: &str, px: u32, sigma: f32) -> Option { + // A packed picture never went through `images`. + let key = if self.textures.contains_key(&(url.to_string(), px)) { + url.to_string() + } else { + let AvatarFetch::Ready(image) = self.images.request(url) else { + return None; + }; + image.key().to_string() + }; + let Some(Entry::Ready(_, pixels)) = self.textures.get(&(key.clone(), px)) else { + return None; + }; + let slot = (key, px, (sigma * 64.0).round() as u32); + if let Some(texture) = self.blurred.get(&slot) { + return Some(texture.id()); + } + let filtered = blur_cover(pixels, px.max(1) as usize, sigma, 0.5); + let texture = ctx.load_texture( + format!("avatar-blur-{}-{px}", slot.0), + filtered, + TextureOptions::LINEAR, + ); + let id = texture.id(); + self.blurred.insert(slot, texture); + Some(id) + } + + /// Downloads or decodes still running. + pub fn busy(&self) -> bool { + !self.downloads.is_empty() + || self + .textures + .values() + .any(|entry| matches!(entry, Entry::Decoding(_))) + } +} + +/// `object-fit: cover` of `image` into a `px` square (bilinear), then CSS +/// `blur(sigma)` (a gaussian with that standard deviation, transparent +/// outside the box, in premultiplied sRGB like Skia) and +/// `brightness(factor)`. +pub fn blur_cover(image: &ColorImage, px: usize, sigma: f32, factor: f32) -> ColorImage { + let [w, h] = image.size; + let side = w.min(h).max(1) as f32; + let (x0, y0) = ((w as f32 - side) / 2.0, (h as f32 - side) / 2.0); + let sample = |x: f32, y: f32| -> [f32; 4] { + let x = (x - 0.5).clamp(0.0, (w - 1) as f32); + let y = (y - 0.5).clamp(0.0, (h - 1) as f32); + let (xi, yi) = (x.floor() as usize, y.floor() as usize); + let (xj, yj) = ((xi + 1).min(w - 1), (yi + 1).min(h - 1)); + let (fx, fy) = (x - xi as f32, y - yi as f32); + let px = |x: usize, y: usize| image.pixels[y * w + x].to_array().map(f32::from); + let (a, b, c, d) = (px(xi, yi), px(xj, yi), px(xi, yj), px(xj, yj)); + std::array::from_fn(|k| { + let top = a[k] + (b[k] - a[k]) * fx; + let bottom = c[k] + (d[k] - c[k]) * fx; + top + (bottom - top) * fy + }) + }; + let scale = side / px as f32; + let mut buf: Vec<[f32; 4]> = (0..px * px) + .map(|i| { + let (x, y) = ((i % px) as f32 + 0.5, (i / px) as f32 + 0.5); + sample(x0 + x * scale, y0 + y * scale) + }) + .collect(); + let radius = (3.0 * sigma).ceil().max(0.0) as isize; + let kernel: Vec = { + let raw: Vec = (-radius..=radius) + .map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma).max(1e-6)).exp()) + .collect(); + let sum: f32 = raw.iter().sum(); + raw.into_iter().map(|k| k / sum).collect() + }; + let n = px as isize; + for horizontal in [true, false] { + let src = buf.clone(); + for y in 0..n { + for x in 0..n { + let mut acc = [0.0f32; 4]; + for (k, weight) in kernel.iter().enumerate() { + let d = k as isize - radius; + let (sx, sy) = if horizontal { (x + d, y) } else { (x, y + d) }; + if sx < 0 || sy < 0 || sx >= n || sy >= n { + continue; + } + let p = src[(sy * n + sx) as usize]; + for c in 0..4 { + acc[c] += p[c] * weight; + } + } + buf[(y * n + x) as usize] = acc; + } + } + } + let pixels = buf + .into_iter() + .map(|[r, g, b, a]| { + let q = |v: f32| v.round().clamp(0.0, 255.0) as u8; + Color32::from_rgba_premultiplied(q(r * factor), q(g * factor), q(b * factor), q(a)) + }) + .collect(); + ColorImage::new([px, px], pixels) +} + +/// Decodes an avatar for a `px` device pixel box, covering it like +/// `object-fit: cover`: SVGs rasterize at that size, bitmaps are cropped to +/// their centred square then scaled to it. +pub fn decode(image: &AvatarImage, px: u32) -> Result { + let bytes: Arc<[u8]> = match image { + AvatarImage::File { path, .. } => std::fs::read(path) + .map_err(|e| format!("{}: {e}", path.display()))? + .into(), + AvatarImage::Bytes { bytes, .. } => Arc::clone(bytes), + }; + if bytes.starts_with(b"\x89PNG") { + return Ok(cover(&decode_png(&bytes)?, px)); + } + if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + return Ok(cover(&decode_jpeg(&bytes)?, px)); + } + if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + return Ok(cover(&decode_webp(&bytes)?, px)); + } + if is_svg(&bytes) { + return crate::avatars::raster(&bytes, px.max(1)); + } + Err("unsupported avatar format".into()) +} + +/// The centred square of `image`, scaled to `px` device pixels. +fn cover(image: &ColorImage, px: u32) -> ColorImage { + let [w, h] = image.size; + let side = w.min(h); + let square = if w == h { + image.clone() + } else { + let (x0, y0) = ((w - side) / 2, (h - side) / 2); + let pixels = (0..side) + .flat_map(|y| image.pixels[(y0 + y) * w + x0..][..side].iter().copied()) + .collect(); + ColorImage::new([side, side], pixels) + }; + crate::ui::resample::resample(&square, px.max(1) as usize) +} + +/// A baseline or progressive JPEG, as sRGB (Blink applies no profile to a +/// JPEG that carries none, which is what Steam serves). +fn decode_jpeg(bytes: &[u8]) -> Result { + let decoded = image::load_from_memory_with_format(bytes, image::ImageFormat::Jpeg) + .map_err(|e| e.to_string())? + .into_rgb8(); + let (w, h) = (decoded.width() as usize, decoded.height() as usize); + let pixels = decoded + .as_raw() + .as_chunks::<3>() + .0 + .iter() + .map(|&[r, g, b]| Color32::from_rgb(r, g, b)) + .collect(); + Ok(ColorImage::new([w, h], pixels)) +} + +/// A still WebP, lossy or lossless: the covers Chromium's canvas encoded. +fn decode_webp(bytes: &[u8]) -> Result { + let mut decoder = + image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(|e| e.to_string())?; + let (w, h) = decoder.dimensions(); + let mut buf = vec![0; decoder.output_buffer_size().ok_or("webp too large")?]; + decoder.read_image(&mut buf).map_err(|e| e.to_string())?; + let pixels = if decoder.has_alpha() { + buf.as_chunks::<4>() + .0 + .iter() + .map(|&[r, g, b, a]| Color32::from_rgba_unmultiplied(r, g, b, a)) + .collect() + } else { + buf.as_chunks::<3>() + .0 + .iter() + .map(|&[r, g, b]| Color32::from_rgb(r, g, b)) + .collect() + }; + Ok(ColorImage::new([w as usize, h as usize], pixels)) +} + +fn is_svg(bytes: &[u8]) -> bool { + let head = &bytes[..bytes.len().min(512)]; + let text = String::from_utf8_lossy(head); + let text = text.trim_start_matches('\u{feff}').trim_start(); + text.starts_with(" Result { + let mut decoder = png::Decoder::new(Cursor::new(bytes)); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder.read_info().map_err(|e| e.to_string())?; + let mut buf = vec![0; reader.output_buffer_size().ok_or("png too large")?]; + let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?; + let data = &buf[..info.buffer_size()]; + let (w, h) = (info.width as usize, info.height as usize); + let pixels: Vec = match info.color_type { + png::ColorType::Rgba => data + .as_chunks::<4>() + .0 + .iter() + .map(|&[r, g, b, a]| Color32::from_rgba_unmultiplied(r, g, b, a)) + .collect(), + png::ColorType::Rgb => data + .as_chunks::<3>() + .0 + .iter() + .map(|&[r, g, b]| Color32::from_rgb(r, g, b)) + .collect(), + png::ColorType::GrayscaleAlpha => data + .as_chunks::<2>() + .0 + .iter() + .map(|&[g, a]| Color32::from_rgba_unmultiplied(g, g, g, a)) + .collect(), + png::ColorType::Grayscale => data.iter().map(|&g| Color32::from_gray(g)).collect(), + png::ColorType::Indexed => return Err("indexed png not expanded".into()), + }; + if pixels.len() != w * h { + return Err("png size mismatch".into()); + } + Ok(ColorImage::new([w, h], pixels)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_svg_rasterizes_at_the_device_size() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("parity/avatars/1.svg"); + let image = AvatarImage::File { + key: "1".into(), + path: root, + }; + let decoded = decode(&image, 102).unwrap(); + assert_eq!(decoded.size, [102, 102]); + } + + #[test] + fn png_round_trips() { + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, 2, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer + .write_image_data(&[255, 0, 0, 255, 0, 0, 255, 128]) + .unwrap(); + } + assert_eq!( + decode_png(&out).unwrap().pixels[0], + Color32::from_rgb(255, 0, 0) + ); + let image = AvatarImage::Bytes { + key: "k".into(), + bytes: out.into(), + }; + // A 2 x 1 picture covers the box with its left pixel. + let decoded = decode(&image, 4).unwrap(); + assert_eq!(decoded.size, [4, 4]); + assert_eq!(decoded.pixels[5], Color32::from_rgb(255, 0, 0)); + } + + #[test] + fn jpeg_decodes_and_covers_the_box() { + let root = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("parity/avatars/photo.jpg"); + let image = AvatarImage::File { + key: "photo".into(), + path: root, + }; + let decoded = decode(&image, 102).unwrap(); + assert_eq!(decoded.size, [102, 102]); + // The face's yellow at the centre, through the JPEG's chroma. + let c = decoded.pixels[50 * 102 + 51]; + assert!(c.r() > 220 && c.g() > 180 && c.b() < 130, "{c:?}"); + let broken = AvatarImage::Bytes { + key: "j".into(), + bytes: vec![0xff, 0xd8, 0xff, 0xe0].into(), + }; + assert!(decode(&broken, 68).is_err()); + } +} diff --git a/crates/accshift-egui/src/app/clock.rs b/crates/accshift-egui/src/app/clock.rs new file mode 100644 index 00000000..f2696f7c --- /dev/null +++ b/crates/accshift-egui/src/app/clock.rs @@ -0,0 +1,65 @@ +//! The mock clock of the reference app. +//! +//! The Tauri mock pins `Date.now()` and an argument-less `new Date()` to the +//! scenario's `NOW` when the document starts, then lets them advance with +//! `performance.now()` (`PIN_CLOCK` in `parity/harness.mjs`). The egui side +//! does the same, so relative times ("2 days ago") read the same in both. + +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Copy, Debug)] +pub struct Clock { + /// `NOW * 1000` of the mock scenario, or `None` for the wall clock. + pinned_ms: Option, + start: Instant, +} + +impl Clock { + /// The real clock. + pub fn wall() -> Clock { + Clock { + pinned_ms: None, + start: Instant::now(), + } + } + + /// `Date.now()` starts at `now_s * 1000` and advances from here. + pub fn pinned(now_s: u64) -> Clock { + Clock { + pinned_ms: Some(now_s as f64 * 1000.0), + start: Instant::now(), + } + } + + pub fn is_pinned(&self) -> bool { + self.pinned_ms.is_some() + } + + /// `Date.now()`: milliseconds since the Unix epoch. + pub fn now_ms(&self) -> f64 { + match self.pinned_ms { + Some(base) => base + self.start.elapsed().as_secs_f64() * 1000.0, + None => SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or(0.0), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pinned_clock_starts_at_now_and_advances() { + let clock = Clock::pinned(1_791_000_000); + let first = clock.now_ms(); + assert!((1_791_000_000_000.0..1_791_000_000_000.0 + 1000.0).contains(&first)); + std::thread::sleep(std::time::Duration::from_millis(20)); + let later = clock.now_ms(); + assert!(later - first >= 19.0, "advances like performance.now()"); + assert!(Clock::wall().now_ms() > 1_700_000_000_000.0); + assert!(clock.is_pinned() && !Clock::wall().is_pinned()); + } +} diff --git a/crates/accshift-egui/src/app/deep_link.rs b/crates/accshift-egui/src/app/deep_link.rs new file mode 100644 index 00000000..f6e2310e --- /dev/null +++ b/crates/accshift-egui/src/app/deep_link.rs @@ -0,0 +1,278 @@ +//! Deep links: `createDeepLinkController` (`useDeepLink.svelte.ts`) wired +//! the way `App.svelte` wires it. The controller is the one of +//! `accshift-app` ([`DeepLinkController`]); this module answers its actions +//! with the app's own: the confirm dialog, the tab change, the loads and the +//! switch. +//! +//! URLs arrive from a second launch and from the scenes' `deeplink` step, +//! through [`AppModel::open_urls`]. The URL of a cold start is not replayed: +//! `onOpenUrl` only listens for `deep-link://new-url`, which the plugin +//! emits before the page listens. +//! +//! The controller is polled once boot finished, since its view reads the +//! stores; a link that comes earlier waits for it, as `waitUntil(isBootReady)` +//! does. + +use std::time::{Duration, Instant}; + +use accshift_app::accounts::loader::LoadOptions; +use accshift_app::accounts::loading::AccountLoading; +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::actions::dialogs::{ConfirmConfig, ConfirmTicket}; +use accshift_app::error::BackendResult; +use accshift_app::jobs::{Pending, Poll}; +use accshift_app::lifecycle::deep_link::{ + DeepLinkAction, DeepLinkAnswer, DeepLinkController, DeepLinkView, +}; +use accshift_app::platforms::PlatformAccount; +use accshift_app::text::Text; +use egui::Context; + +use super::{Action, AppModel}; + +/// What the controller's last action waits for. +enum Waiting { + /// `loadPlatformAccounts(platformId)`. + PlatformAccounts(Pending>>), + /// `loadAccounts(true)` on the visible tab: answered once no load is in + /// flight, whether it landed or failed (the TS promise settles either way). + Reload, + /// `confirmSwitch`: the confirm dialog's ticket. + Confirm(ConfirmTicket), + /// `switchToAccount`: the switch of this account id. + Switch(String), +} + +/// The controller and what it waits for. +#[derive(Default)] +pub(super) struct DeepLinks { + controller: DeepLinkController, + waiting: Option, +} + +impl DeepLinks { + /// `deepLink.start()` from `onMount`. + pub(super) fn started() -> Self { + let mut links = Self::default(); + links.controller.start(); + links + } +} + +/// How often a busy controller is polled for its timeouts and waits (boot, +/// the loader). +const POLL_EVERY: Duration = Duration::from_millis(100); + +/// `account.displayName || account.username || account.id`. +fn account_label(account: &PlatformAccount) -> String { + [&account.display_name, &account.username, &account.id] + .into_iter() + .find(|value| !value.is_empty()) + .cloned() + .unwrap_or_default() +} + +/// What the controller is answered with, or why it stops. +enum Step { + Answer(DeepLinkAnswer), + /// `handleUrl`'s `catch`: the work in flight threw. + Fail, +} + +impl AppModel { + /// `onOpenUrl`'s handler: each URL goes to `handleUrl`. + pub fn open_urls(&mut self, ctx: &Context, urls: &[String]) { + self.deep_links.controller.on_urls(urls, Instant::now()); + ctx.request_repaint(); + } + + /// Settles what the last action waits for, then lets the controller go + /// as far as the app allows. Once per frame, after the loaders ran. + pub(super) fn tick_deep_links(&mut self, ctx: &Context) { + let step = match self.deep_links.waiting.take() { + None => None, + Some(Waiting::PlatformAccounts(mut job)) => match job.poll() { + Poll::Waiting => { + self.deep_links.waiting = Some(Waiting::PlatformAccounts(job)); + return; + } + Poll::Ready(Ok(accounts)) => { + Some(Step::Answer(DeepLinkAnswer::PlatformAccounts(accounts))) + } + Poll::Ready(Err(_)) | Poll::Gone => Some(Step::Fail), + }, + Some(Waiting::Reload) => { + let loading = self + .booted + .as_ref() + .is_some_and(|b| b.accounts.loading.loads_in_flight()); + if loading { + self.deep_links.waiting = Some(Waiting::Reload); + return; + } + Some(Step::Answer(DeepLinkAnswer::AccountsLoaded)) + } + // The dialog and the loader answer these. + Some(waiting) => { + self.deep_links.waiting = Some(waiting); + return; + } + }; + match step { + Some(step) => self.step_deep_link(ctx, step), + None => { + if !self.deep_links.controller.is_busy() { + return; + } + let now = Instant::now(); + let actions = + self.with_deep_link_view(|controller, view| controller.poll(view, now)); + self.apply_deep_link_actions(ctx, actions); + } + } + if self.deep_links.controller.is_busy() && self.deep_links.waiting.is_none() { + ctx.request_repaint_after(POLL_EVERY); + } + } + + /// A settled confirm: `confirmSwitch`'s answer, when it asked. + pub(super) fn settle_deep_link_confirm( + &mut self, + ctx: &Context, + ticket: ConfirmTicket, + allowed: bool, + ) { + if !matches!(&self.deep_links.waiting, Some(Waiting::Confirm(pending)) if *pending == ticket) + { + return; + } + self.deep_links.waiting = None; + self.step_deep_link(ctx, Step::Answer(DeepLinkAnswer::Confirmed(allowed))); + } + + /// A finished switch: `switchToAccount`'s answer, when it ran it. + pub(super) fn deep_link_switch_finished( + &mut self, + ctx: &Context, + account_id: &str, + succeeded: bool, + ) { + if !matches!(&self.deep_links.waiting, Some(Waiting::Switch(id)) if id == account_id) { + return; + } + self.deep_links.waiting = None; + self.step_deep_link(ctx, Step::Answer(DeepLinkAnswer::Switched(succeeded))); + } + + fn step_deep_link(&mut self, ctx: &Context, step: Step) { + let now = Instant::now(); + let actions = self.with_deep_link_view(|controller, view| match step { + Step::Answer(answer) => controller.answer(answer, view, now), + Step::Fail => controller.fail(view, now), + }); + self.apply_deep_link_actions(ctx, actions); + } + + /// The controller with what it reads of the app. Nothing before boot. + fn with_deep_link_view( + &mut self, + run: impl FnOnce(&mut DeepLinkController, &DeepLinkView<'_>) -> Vec, + ) -> Vec { + let Some(b) = self.booted.as_ref() else { + return Vec::new(); + }; + let loader = &b.accounts.loading.loader; + let view = DeepLinkView { + boot_ready: true, + pin_locked: b.secure.is_pin_locked(), + settings: b.stores.settings(), + registry: &b.accounts.registry, + runtime_os: b.accounts.runtime_os, + active_tab: &b.nav.active_tab, + loader_loading: loader.loading(), + accounts: loader.accounts(), + }; + run(&mut self.deep_links.controller, &view) + } + + /// The deps of `createDeepLinkController` in `App.svelte`. + fn apply_deep_link_actions(&mut self, ctx: &Context, actions: Vec) { + for action in actions { + match action { + DeepLinkAction::Toast(text) => { + self.toasts + .push(Toast::new(text, ToastKind::Default), Instant::now()); + } + DeepLinkAction::LoadPlatformAccounts(platform_id) => { + let job = self.booted.as_ref().and_then(|b| { + AccountLoading::load_platform_accounts( + &self.jobs, + &b.accounts.registry, + &platform_id, + ) + }); + match job { + Some(job) => self.deep_links.waiting = Some(Waiting::PlatformAccounts(job)), + // `ensurePlatformLoaded` found no adapter: `[]`. + None => self.step_deep_link( + ctx, + Step::Answer(DeepLinkAnswer::PlatformAccounts(Vec::new())), + ), + } + } + DeepLinkAction::LoadAccounts => { + if let Some(b) = self.booted.as_mut() { + let tab = b.nav.active_tab.clone(); + b.accounts.load( + &self.jobs, + &mut b.stores, + &tab, + LoadOptions { + silent: true, + show_refreshed_toast: false, + force_refresh: false, + check_bans: false, + defer_background: true, + }, + ); + } + self.deep_links.waiting = Some(Waiting::Reload); + } + DeepLinkAction::Confirm { + account, + platform_name, + } => { + let mut effects = Vec::new(); + let ticket = self.dialogs.request_confirm( + ConfirmConfig { + title: Text::key("dialog.deepLinkSwitchTitle"), + message: Text::with( + "dialog.deepLinkSwitchMessage", + [ + ("platform", platform_name), + ("account", account_label(&account)), + ], + ), + confirm_label: Some(Text::key("dialog.deepLinkSwitchConfirm")), + confirm_color: None, + }, + &mut effects, + ); + self.deep_links.waiting = Some(Waiting::Confirm(ticket)); + self.apply_effects(ctx, effects); + } + DeepLinkAction::ChangeTab(tab) => { + self.apply_one(ctx, Action::TabChange(tab)); + self.step_deep_link(ctx, Step::Answer(DeepLinkAnswer::TabChanged)); + } + DeepLinkAction::Switch(account) => { + if self.switch_account(&account) { + self.deep_links.waiting = Some(Waiting::Switch(account.id)); + } else { + self.step_deep_link(ctx, Step::Answer(DeepLinkAnswer::Switched(false))); + } + } + } + } + } +} diff --git a/crates/accshift-egui/src/app/drag.rs b/crates/accshift-egui/src/app/drag.rs new file mode 100644 index 00000000..147443c9 --- /dev/null +++ b/crates/accshift-egui/src/app/drag.rs @@ -0,0 +1,341 @@ +//! The card drag of `App.svelte`: the grid wrapper's `mousedown`, the +//! document's `mousemove`, `scroll` and `mouseup` listeners and the frame's +//! `updateDragAt`, read against the boxes the workspace drew last frame +//! (`elementFromPoint` and `getBoundingClientRect` read the DOM before it +//! re-renders too). + +use accshift_app::drag::{PointHit, PressedCard, SlotRect, Slots, StoreHost}; +use accshift_app::folders::{ItemKind, ItemRef}; +use egui::{Context, Event, Id, PointerButton, Pos2, Rect}; + +use super::AppModel; +use crate::screens::workspace::{Ghost, Workspace}; +use crate::ui::components::bulk_edit::COVER_ID; + +/// `closest("[data-account-id], [data-folder-id]")` under a press: a card, +/// else the `.section` wrapper holding the point. +fn pressed_card(ws: &Workspace, pos: Pos2) -> Option { + let section_of = |pos: Pos2| { + ws.sections + .iter() + .find(|(_, r)| r.contains(pos)) + .and_then(|(folder, _)| folder.clone()) + }; + if let Some((item, _)) = ws.cards.iter().rev().find(|(_, r)| r.contains(pos)) { + return Some(PressedCard { + item: item.clone(), + section_card: false, + section_folder: section_of(pos), + }); + } + let folder = section_of(pos)?; + Some(PressedCard { + item: ItemRef::folder(&folder), + section_card: true, + section_folder: Some(folder), + }) +} + +/// `elementFromPoint(x, y)?.closest("[data-folder-id], [data-back-card], +/// [data-account-id]")`: the ghost lets the pointer through. +fn point_hit(ctx: &Context, ws: &Workspace, cover: &[Rect], pos: Pos2) -> PointHit { + if !ctx.content_rect().contains(pos) { + return PointHit::Nothing; + } + if !ws.viewport.is_some_and(|r| r.contains(pos)) || cover.iter().any(|r| r.contains(pos)) { + return PointHit::Grid; + } + if let Some((item, _)) = ws.cards.iter().rev().find(|(_, r)| r.contains(pos)) { + return match item.kind { + ItemKind::Account => PointHit::Account, + ItemKind::Folder => PointHit::Folder { + id: item.id.clone(), + section_card: false, + }, + }; + } + if ws.back.is_some_and(|r| r.contains(pos)) { + return PointHit::Back; + } + match ws.sections.iter().find(|(_, r)| r.contains(pos)) { + Some((Some(id), _)) => PointHit::Folder { + id: id.clone(), + section_card: true, + }, + _ => PointHit::Grid, + } +} + +/// `refreshSlotRects`: the section wrappers when a folder moves among +/// sections, else the grid's (or list's) cards of the dragged kind. +fn slots(ws: &Workspace, item: &ItemRef) -> Slots { + let rect = |r: &Rect| SlotRect { + left: r.left(), + top: r.top(), + width: r.width(), + height: r.height(), + }; + if ws.sections_mode && item.kind == ItemKind::Folder { + return Slots { + rects: ws + .sections + .iter() + .filter(|(folder, _)| folder.is_some()) + .map(|(_, r)| rect(r)) + .collect(), + list_mode: true, + }; + } + Slots { + rects: ws + .cards + .iter() + .filter(|(card, _)| card.kind == item.kind) + .map(|(_, r)| rect(r)) + .collect(), + list_mode: ws.list_mode, + } +} + +impl AppModel { + /// One frame of the drag, before the lists are computed: the preview + /// order and a drop show in the frame that caused them. + pub(super) fn track_drag(&mut self, ctx: &Context) { + let Some(b) = self.booted.as_mut() else { + return; + }; + let cover: Vec = ctx + .data(|d| d.get_temp(Id::unique(COVER_ID))) + .unwrap_or_default(); + let hit = ctx.viewport(|vp| vp.hits.click.map(|w| w.id)); + let tab = b.nav.active_tab.clone(); + let events = ctx.input(|i| i.events.clone()); + let ws = &mut b.workspace; + let accounts = &mut b.accounts; + let stores = &mut b.stores; + for event in events { + match event { + Event::PointerButton { + pos, + button, + pressed: true, + .. + } => { + let primary = button == PointerButton::Primary; + b.drag_held |= primary; + // `handleWorkspaceMouseDown`: bulk edit paints instead + // and a search keeps the order. + if b.bulk.is_open() || accounts.navigation.is_searching() { + continue; + } + // The grid wrapper's `onmousedown`: nothing else on top. + let on_workspace = ws.drawn_last_frame(ctx) + && ws.viewport.is_some_and(|r| r.contains(pos)) + && hit.is_none_or(|id| ws.owns(id)) + && !cover.iter().any(|r| r.contains(pos)); + let card = on_workspace.then(|| pressed_card(ws, pos)).flatten(); + accounts + .drag + .mouse_down(primary, card, ws.sections_mode, (pos.x, pos.y)); + } + Event::PointerMoved(pos) => { + if accounts.drag.mouse_move((pos.x, pos.y), b.drag_held) { + let Some(item) = accounts.drag.drag_item().cloned() else { + continue; + }; + let host = StoreHost { + navigation: &accounts.navigation, + stores, + }; + accounts.drag.set_slots(slots(ws, &item), &host); + b.drag_scroll = ws.scroll(); + ws.ghost = ws.bounding_rect(ctx, &item).map(|rect| Ghost { + item, + rect, + anchor: pos, + id: Id::unique(("drag-ghost", ctx.cumulative_frame_nr())), + }); + } + } + Event::PointerButton { + button: PointerButton::Primary, + pressed: false, + .. + } => { + b.drag_held = false; + let out = { + let host = StoreHost { + navigation: &accounts.navigation, + stores, + }; + accounts.drag.mouse_up(&host) + }; + if let Some(commit) = out.commit { + commit.apply(stores, &tab); + } + if out.refresh { + accounts.navigation.refresh_current_items(stores, &tab); + } + } + _ => {} + } + } + if accounts.drag.is_dragging() { + // `handleDocScroll`: the slots moved with the content. + if ws.scroll() != b.drag_scroll { + b.drag_scroll = ws.scroll(); + accounts.drag.scrolled(); + } + let host = StoreHost { + navigation: &accounts.navigation, + stores, + }; + if accounts.drag.rects_stale() { + if let Some(item) = accounts.drag.drag_item().cloned() { + accounts.drag.set_slots(slots(ws, &item), &host); + } + } + let (x, y) = accounts.drag.pointer(); + let under = point_hit(ctx, ws, &cover, Pos2::new(x, y)); + accounts.drag.update(&under, &host); + } + crate::ui::components::set_click_eaten(ctx, accounts.drag.eats_click()); + } +} + +/// The effects' linked list of a keyed `{#each}`, by index into the old +/// order. +struct EachList { + prev: Vec>, + next: Vec>, + first: Option, +} + +impl EachList { + /// `link(state, a, b)`. + fn link(&mut self, a: Option, b: Option) { + match a { + None => self.first = b, + Some(a) => self.next[a] = b, + } + if let Some(b) = b { + self.prev[b] = a; + } + } +} + +/// Svelte 5's keyed `{#each}` reconcile (`reconcile` in `each.js`) over a +/// reorder of the same keys: the keys whose node it moves. Chromium drops +/// `:active` from a node it moves (`ActiveChainNodeDetached`) and restarts +/// its CSS animations. +pub(super) fn each_moved(old: &[String], new: &[String]) -> Vec { + let n = old.len(); + let index = |k: &str| old.iter().position(|o| o == k); + if new.len() != n { + return Vec::new(); + } + let mut list = EachList { + prev: (0..n).map(|i| i.checked_sub(1)).collect(), + next: (0..n).map(|i| (i + 1 < n).then_some(i + 1)).collect(), + first: (n > 0).then_some(0), + }; + let mut current = list.first; + let mut prev: Option = None; + let mut seen = std::collections::BTreeSet::new(); + let mut matched: Vec = Vec::new(); + let mut stashed: Vec = Vec::new(); + let mut moved = std::collections::BTreeSet::new(); + let mut i = 0; + while i < n { + let Some(effect) = index(&new[i]) else { + return Vec::new(); + }; + i += 1; + if Some(effect) != current { + if seen.contains(&effect) { + if !matched.is_empty() && matched.len() < stashed.len() { + // Later items move to the front. + let start = stashed[0]; + prev = list.prev[start]; + let (a, b) = (matched[0], matched[matched.len() - 1]); + moved.extend(matched.iter().copied()); + for s in &stashed { + seen.remove(s); + } + list.link(list.prev[a], list.next[b]); + list.link(prev, Some(a)); + list.link(Some(b), Some(start)); + current = Some(start); + prev = Some(b); + i -= 1; + matched.clear(); + stashed.clear(); + } else { + // Earlier items move to the back. + seen.remove(&effect); + moved.insert(effect); + list.link(list.prev[effect], list.next[effect]); + let after = match prev { + None => list.first, + Some(p) => list.next[p], + }; + list.link(Some(effect), after); + list.link(prev, Some(effect)); + prev = Some(effect); + } + continue; + } + matched.clear(); + stashed.clear(); + while let Some(c) = current.filter(|&c| c != effect) { + seen.insert(c); + stashed.push(c); + current = list.next[c]; + } + if current.is_none() { + continue; + } + } + matched.push(effect); + prev = Some(effect); + current = list.next[effect]; + } + moved.into_iter().map(|i| old[i].clone()).collect() +} + +#[cfg(test)] +mod tests { + use super::each_moved; + + fn keys(s: &str) -> Vec { + s.chars().map(String::from).collect() + } + + fn each_moves(old: &[String], new: &[String], key: &str) -> bool { + each_moved(old, new).iter().any(|k| k == key) + } + + #[test] + fn a_card_moved_forward_is_the_node_svelte_moves() { + assert!(each_moves(&keys("abcde"), &keys("acbde"), "b")); + assert!(each_moves(&keys("abcde"), &keys("acdeb"), "b")); + } + + #[test] + fn one_slot_back_moves_the_neighbour_instead() { + assert!(!each_moves(&keys("abcde"), &keys("acbde"), "c")); + assert!(each_moves(&keys("abcde"), &keys("acbde"), "b")); + assert!(each_moves(&keys("abcde"), &keys("eabcd"), "e")); + assert!(!each_moves(&keys("abcde"), &keys("eabcd"), "a")); + } + + #[test] + fn an_unchanged_order_moves_nothing() { + assert!(each_moved(&keys("abc"), &keys("abc")).is_empty()); + } + + #[test] + fn later_items_to_the_front_move_as_a_block() { + assert_eq!(each_moved(&keys("abcde"), &keys("deabc")), keys("de")); + } +} diff --git a/crates/accshift-egui/src/app/keys.rs b/crates/accshift-egui/src/app/keys.rs new file mode 100644 index 00000000..566110e0 --- /dev/null +++ b/crates/accshift-egui/src/app/keys.rs @@ -0,0 +1,637 @@ +//! The keyboard: `createKeyboardController` with the app's binding table +//! (`accshift_app::actions::keyboard`), fed with the frame's key presses +//! before any component draws. The TS controller listens on `window`, so a +//! field's own `keydown` handler runs first; here the table runs first, and +//! the bindings that would reach a field (`enter`, arrows, plain letters) +//! never match inside one, so the order does not show. A binding that takes +//! a key removes it from the frame's input, as `preventDefault` plus +//! `stopPropagation` keep it from the focused element. + +use std::time::Instant; + +use accshift_app::actions::keyboard::CARD_NAV_SCOPES; +use accshift_app::actions::keyboard::{ + current_key_scope, Direction, KeyAction, KeyEvent, KeyHost, KeyOutcome, KeyScope, +}; +use accshift_app::display::DisplayItem; +use accshift_app::folders::{ItemKind, ItemRef}; +use accshift_app::stores::ViewMode; +use egui::{Context, Event, Id, Key}; + +use super::platforms::RuntimeOs; +use super::ui_scale::ZoomKey; +use super::{Action, AppModel, MenuTarget}; +use crate::ui::components::bulk_edit::BulkEvent; +use crate::ui::components::focus; + +/// The search field's widget id (`searchInputRef`). +pub const SEARCH_ID: &str = "workspace-search"; + +/// DOM `key` and `code` for an egui key, `None` for one no binding reads. +/// Letters follow Shift like `e.key`; `code` is the US position. +fn dom_key(key: Key, shift: bool) -> Option<(String, String)> { + let named = |k: &str| Some((k.to_string(), k.to_string())); + let name = key.name(); + match key { + Key::Escape + | Key::Enter + | Key::Tab + | Key::Backspace + | Key::Delete + | Key::Home + | Key::End => named(name), + // egui names them "Left" and so on. + Key::ArrowLeft => named("ArrowLeft"), + Key::ArrowRight => named("ArrowRight"), + Key::ArrowUp => named("ArrowUp"), + Key::ArrowDown => named("ArrowDown"), + Key::Space => Some((" ".into(), "Space".into())), + Key::Plus => Some(("+".into(), "Equal".into())), + Key::Equals => Some(("=".into(), "Equal".into())), + Key::Minus => Some(("-".into(), "Minus".into())), + Key::Comma => Some((",".into(), "Comma".into())), + k if k == crate::CONTEXT_MENU_KEY => named("ContextMenu"), + _ => { + if let Some(rest) = name.strip_prefix('F').filter(|r| r.parse::().is_ok()) { + return Some((format!("F{rest}"), format!("F{rest}"))); + } + let mut chars = name.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphabetic() => { + let lower = c.to_ascii_lowercase(); + let shown = if shift { c.to_ascii_uppercase() } else { lower }; + Some((shown.to_string(), format!("Key{}", c.to_ascii_uppercase()))) + } + (Some(c), None) if c.is_ascii_digit() => Some((c.to_string(), format!("Digit{c}"))), + _ => None, + } + } + } +} + +/// A key press as the controller reads it. The physical key gives `code`, +/// so `mod+digit1` holds on an AZERTY row. +fn key_event(event: &Event, editable: bool) -> Option { + let Event::Key { + key, + physical_key, + pressed: true, + modifiers, + .. + } = event + else { + return None; + }; + let (dom, _) = dom_key(*key, modifiers.shift)?; + let code = physical_key + .and_then(|k| dom_key(k, false)) + .or_else(|| dom_key(*key, false)) + .map(|(_, code)| code) + .unwrap_or_default(); + Some(KeyEvent { + key: dom, + code, + ctrl: modifiers.ctrl, + meta: modifiers.mac_cmd, + shift: modifiers.shift, + alt: modifiers.alt, + editable_target: editable, + }) +} + +/// What the bindings read of the app, taken before they run, and what they +/// asked for, applied after. +struct Host { + in_folder: bool, + enabled: Vec, + unavailable: Vec, + active_tab: String, + has_input_dialog: bool, + search_focused: bool, + bulk_edit_allowed: bool, + actions: Vec, + /// `getItems()` of the card focus. + items: Vec, + /// `cardFocus.focusedId`, moved by the arrows. + focused: Option, + /// `measureColumns()`. + columns: usize, + /// No control other than a card holds real focus. + owns_activation: bool, + /// Accounts the view renders (`renderedAccountMap`). + rendered: Vec, + /// Folders that exist (`getFolder`). + folders: Vec, + /// The active adapter labels accounts (`setAccountLabel`). + can_label: bool, + /// The cards on screen (`findElement`). + on_screen: Vec, + /// The focus moved: `syncDom` scrolls it into view. + moved: bool, + cards: Vec, + /// The personas panel shows its wizard (`editing`). + personas_editing: bool, + /// Escape went to the wizard (`personasPanelRef.handleEscape()`). + personas_escape: bool, + /// A card is pressed or dragged. + drag_in_flight: bool, + /// Escape cancelled it (`drag.cancelFromEscape()`). + drag_escape: bool, +} + +/// What a binding asked of the focused card. +enum CardEffect { + Activate(ItemRef), + Rename(ItemRef), + Menu(ItemRef), +} + +impl Host { + fn exists(&self, item: &ItemRef) -> bool { + match item.kind { + ItemKind::Folder => self.folders.contains(&item.id), + ItemKind::Account => self.rendered.contains(&item.id), + } + } +} + +impl KeyHost for Host { + fn in_folder(&self) -> bool { + self.in_folder + } + fn enabled_platform_ids(&self) -> Vec { + self.enabled.clone() + } + fn is_platform_unavailable(&self, platform_id: &str) -> bool { + self.unavailable.iter().any(|id| id == platform_id) + } + fn active_tab(&self) -> String { + self.active_tab.clone() + } + fn has_input_dialog(&self) -> bool { + self.has_input_dialog + } + fn search_input_focused(&self) -> bool { + self.search_focused + } + fn focused_item(&self) -> Option { + let id = self.focused.as_ref()?; + self.items.iter().find(|item| &item.id == id).cloned() + } + fn card_focus_owns_activation_key(&self, _event: &KeyEvent) -> bool { + self.owns_activation + } + fn is_bulk_edit_toggle_allowed(&self) -> bool { + self.bulk_edit_allowed + } + fn cancel_drag_from_escape(&mut self) -> bool { + self.drag_escape = self.drag_in_flight; + self.drag_in_flight + } + /// `PersonasPanel.handleEscape`: the wizard steps back, the grid lets + /// the panel close. + fn personas_panel_escape(&mut self) -> bool { + self.personas_escape = self.personas_editing; + self.personas_editing + } + /// `cardFocus.move`: the first card when none is focused, then by one + /// or by a row; past either end it stays. + fn card_focus_move(&mut self, direction: Direction) -> bool { + let Some(first) = self.items.first() else { + return false; + }; + let current = self + .focused + .as_ref() + .and_then(|id| self.items.iter().position(|item| &item.id == id)); + let Some(current) = current else { + self.focused = Some(first.id.clone()); + self.moved = true; + return true; + }; + let columns = self.columns.max(1) as isize; + let delta = match direction { + Direction::Left => -1, + Direction::Right => 1, + Direction::Up => -columns, + Direction::Down => columns, + }; + let next = current as isize + delta; + if next < 0 || next >= self.items.len() as isize { + return true; + } + self.focused = Some(self.items[next as usize].id.clone()); + self.moved = true; + true + } + /// `activateFocusedCard`: a folder opens, an account switches. + fn activate_focused_card(&mut self) -> bool { + let Some(item) = self + .focused_item() + .filter(|item| item.kind == ItemKind::Folder || self.rendered.contains(&item.id)) + else { + return false; + }; + self.cards.push(CardEffect::Activate(item)); + true + } + /// `renameFocusedCard`. + fn rename_focused_card(&mut self) -> bool { + let Some(item) = self + .focused_item() + .filter(|item| self.exists(item) && (item.kind == ItemKind::Folder || self.can_label)) + else { + return false; + }; + self.cards.push(CardEffect::Rename(item)); + true + } + /// `openFocusedCardContextMenu`: the menu opens at the card's centre. + fn open_focused_card_context_menu(&mut self) -> bool { + let Some(item) = self + .focused_item() + .filter(|item| self.on_screen.contains(item) && self.exists(item)) + else { + return false; + }; + self.cards.push(CardEffect::Menu(item)); + true + } + fn act(&mut self, action: KeyAction) { + self.actions.push(action); + } +} + +impl AppModel { + /// `createKeyScopeResolver`. + pub(super) fn key_scope(&self) -> KeyScope { + let Some(b) = self.booted.as_ref() else { + return KeyScope::Locked; + }; + current_key_scope(|scope| match scope { + KeyScope::Locked => { + b.secure.is_pin_locked() || b.streamer.active() || b.secure.render_suspended() + } + KeyScope::Palette => self.palette.is_some(), + KeyScope::Onboarding => self.onboarding.tour.open(), + KeyScope::Dialog => { + self.dialogs.input_dialog().is_some() || self.dialogs.confirm_dialog().is_some() + } + KeyScope::ContextMenu => self.dialogs.context_menu().is_some(), + KeyScope::BulkEdit => b.bulk.is_open(), + KeyScope::Settings => b.nav.show_settings, + KeyScope::Personas => b.nav.show_personas, + KeyScope::App => false, + }) + } + + /// What the bindings read, as the app stands now. + fn key_host(&self, ctx: &Context) -> Option { + let b = self.booted.as_ref()?; + let os = b.runtime_os; + let enabled: Vec = b + .platforms + .iter() + .filter(|p| b.stores.settings().enabled_platforms.contains(&p.id)) + .map(|p| p.id.clone()) + .collect(); + let unavailable = b + .platforms + .iter() + .filter(|p| enabled.contains(&p.id) && !p.supports(os)) + .map(|p| p.id.clone()) + .collect(); + let active = b.platforms.iter().find(|p| p.id == b.nav.active_tab); + let ws = &b.workspace; + let focused_widget = ctx.memory(|m| m.focused()); + let rendered = b + .accounts + .view + .items() + .iter() + .filter_map(|item| match item { + DisplayItem::Account(a) => Some(a.account.id.clone()), + _ => None, + }) + .collect(); + let items = b.accounts.card_focus_items(); + let folders = items + .iter() + .filter(|item| item.kind == ItemKind::Folder) + .filter(|item| b.stores.folders().folder(&item.id).is_some()) + .map(|item| item.id.clone()) + .collect(); + let can_label = b + .accounts + .registry + .adapter(&b.nav.active_tab) + .is_some_and(|adapter| adapter.supports_account_labels()); + Some(Host { + in_folder: b.accounts.navigation.current_folder_id().is_some(), + enabled, + unavailable, + active_tab: b.nav.active_tab.clone(), + has_input_dialog: self.dialogs.input_dialog().is_some(), + search_focused: ctx.memory(|m| m.has_focus(Id::unique(SEARCH_ID))), + bulk_edit_allowed: active.is_some_and(|p| p.bulk_edit && p.supports(os)) + && !b.nav.show_settings, + actions: Vec::new(), + items, + focused: ws.kb_focus.clone(), + columns: if b.stores.view_mode() == ViewMode::List { + 1 + } else { + ws.columns + }, + owns_activation: focused_widget.is_none_or(|id| ws.owns(id)), + rendered, + folders, + can_label, + on_screen: ws.cards.iter().map(|(item, _)| item.clone()).collect(), + moved: false, + cards: Vec::new(), + personas_editing: self + .personas + .panel + .as_ref() + .is_some_and(|p| p.editing.is_some()), + personas_escape: false, + drag_in_flight: b.accounts.drag.in_flight(), + drag_escape: false, + }) + } + + /// Runs the frame's key presses through the binding table, one after + /// the other as `keydown` events: each sees what the previous one did. + /// Taken keys leave the frame's input. + pub(super) fn dispatch_keys(&mut self, ctx: &Context) { + let Some(os) = self.booted.as_ref().map(|b| b.runtime_os) else { + return; + }; + let is_mac = os == RuntimeOs::Macos; + let events = ctx.input(|i| i.events.clone()); + let mut taken = vec![false; events.len()]; + for (index, event) in events.iter().enumerate() { + let Some(key) = key_event(event, focus::text_field_focused(ctx)) else { + continue; + }; + let Some(mut host) = self.key_host(ctx) else { + return; + }; + let scope = self.key_scope(); + if let KeyOutcome::Handled { consume, .. } = + self.keyboard.handle(&key, scope, is_mac, &mut host) + { + taken[index] = consume; + } + if host.moved { + if let Some(b) = self.booted.as_mut() { + b.workspace.kb_focus = host.focused.clone(); + b.workspace.kb_reveal = true; + } + ctx.request_repaint(); + } + if host.personas_escape { + self.wizard_escape(ctx); + } + if host.drag_escape { + if let Some(b) = self.booted.as_mut() { + b.accounts.drag.cancel_from_escape(); + } + ctx.request_repaint(); + } + for action in host.actions { + self.apply_key(ctx, action); + } + for effect in host.cards { + self.apply_card_effect(ctx, effect); + } + } + if taken.iter().any(|t| *t) { + ctx.input_mut(|i| { + let mut n = 0; + i.events.retain(|_| { + n += 1; + !taken[n - 1] + }); + }); + } + } + + /// `cardFocus.clear()`. + fn clear_card_focus(&mut self) { + if let Some(b) = self.booted.as_mut() { + b.workspace.kb_focus = None; + b.workspace.kb_reveal = false; + } + } + + /// What drops the card focus besides Escape, run before the keys: the + /// `$effect` on the tab, folder, query, view mode and settings panel, + /// a pointer press anywhere and real focus landing outside the grid + /// (both only in the scopes the grid takes keys in). + pub(super) fn track_card_focus(&mut self, ctx: &Context) { + let scope = self.key_scope(); + let Some(b) = self.booted.as_mut() else { + return; + }; + let context = ( + b.nav.active_tab.clone(), + b.accounts + .navigation + .current_folder_id() + .map(str::to_string), + b.accounts.navigation.search_query().to_string(), + b.stores.view_mode() == ViewMode::List, + b.nav.show_settings, + ); + let changed = self.card_focus_context.as_ref() != Some(&context); + self.card_focus_context = Some(context); + let focused = ctx.memory(|m| m.focused()); + let focus_moved = focused != self.card_focus_widget; + self.card_focus_widget = focused; + let cards = b.accounts.card_focus_signature(); + let cards_changed = self.card_focus_cards != Some(cards); + self.card_focus_cards = Some(cards); + let ws = &mut b.workspace; + if changed { + ws.kb_focus = None; + } + // The `$effect` on the rendered cards: `syncDom` scrolls the focused + // card back into view. + if cards_changed && ws.kb_focus.is_some() { + ws.kb_reveal = true; + } + if !CARD_NAV_SCOPES.contains(&scope) { + return; + } + let pressed = ctx.input(|i| { + i.events + .iter() + .any(|e| matches!(e, Event::PointerButton { pressed: true, .. })) + }); + let outside = focus_moved && focused.is_some_and(|id| !ws.owns(id)); + if pressed || outside { + ws.kb_focus = None; + } + } + + fn apply_card_effect(&mut self, ctx: &Context, effect: CardEffect) { + let Some(b) = self.booted.as_ref() else { + return; + }; + let account = |id: &str| { + b.accounts.view.items().iter().find_map(|i| match i { + DisplayItem::Account(a) if a.account.id == id => Some(a.account.clone()), + _ => None, + }) + }; + let folder = |id: &str| b.stores.folders().folder(id).cloned(); + match effect { + CardEffect::Activate(item) => match item.kind { + ItemKind::Folder => self.apply_one(ctx, Action::NavigateToFolder(Some(item.id))), + ItemKind::Account => { + self.apply_one(ctx, Action::AccountActivate(item.id.clone())); + self.apply_one(ctx, Action::AccountSwitch(item.id)); + } + }, + CardEffect::Rename(item) => match item.kind { + ItemKind::Folder => { + if let Some(folder) = folder(&item.id) { + self.dialogs.open_rename_folder_dialog(&folder); + } + } + ItemKind::Account => { + if let Some(account) = account(&item.id) { + let adapter = b.accounts.registry.adapter(&b.nav.active_tab); + self.dialogs + .open_rename_account_dialog(adapter.as_deref(), &account); + } + } + }, + CardEffect::Menu(item) => { + let at = b.workspace.card_rect(&item).map(|r| r.center()); + let target = match item.kind { + ItemKind::Folder => folder(&item.id).map(MenuTarget::Folder), + ItemKind::Account => account(&item.id).map(MenuTarget::Account), + }; + if let (Some(target), Some(at)) = (target, at) { + self.apply_one(ctx, Action::ContextMenu(target, at)); + } + } + } + } + + fn apply_key(&mut self, ctx: &Context, action: KeyAction) { + match action { + KeyAction::CloseContextMenu => { + if self.dialogs.context_menu().is_some() { + self.close_context_menu(ctx); + } + } + KeyAction::SetPaletteOpen(open) => self.set_palette_open(ctx, open), + KeyAction::CloseInputDialog => self.dialogs.close_input_dialog(), + KeyAction::CloseConfirmDialog => { + let effects = self.dialogs.close_confirm_dialog(); + self.apply_effects(ctx, effects); + } + KeyAction::ClosePersonas => { + if let Some(b) = self.booted.as_mut() { + b.nav.show_personas = false; + } + } + KeyAction::ClearSearchQuery => { + if let Some(b) = self.booted.as_mut() { + b.accounts.set_search("", Instant::now()); + } + } + KeyAction::ClearCardFocus => self.clear_card_focus(), + KeyAction::BlurSearch => ctx.memory_mut(|m| m.surrender_focus(Id::unique(SEARCH_ID))), + KeyAction::FocusSearch => ctx.memory_mut(|m| m.request_focus(Id::unique(SEARCH_ID))), + // The settings panel has no search field yet. + KeyAction::FocusSettingsSearch => self.focus_platform_search(), + KeyAction::AddAccount => self.apply_one(ctx, Action::AddAccount), + KeyAction::NewFolder => self.dialogs.open_new_folder_dialog(), + KeyAction::Refresh => self.apply_one(ctx, Action::Refresh), + KeyAction::ToggleBulkEdit => self.apply_one(ctx, Action::BulkEdit), + KeyAction::ToggleSettingsPanel => self.toggle_settings_panel(), + KeyAction::OpenPersonas => self.apply_one(ctx, Action::OpenPersonas), + KeyAction::ToggleViewMode => { + let next = match self.booted.as_ref().map(|b| b.stores.view_mode()) { + Some(ViewMode::Grid) => ViewMode::List, + _ => ViewMode::Grid, + }; + self.apply_one(ctx, Action::SetViewMode(next)); + } + KeyAction::ChangeTab(tab) => self.apply_one(ctx, Action::TabChange(tab)), + KeyAction::ZoomIn => self.zoom_key(ZoomKey::In, Instant::now()), + KeyAction::ZoomOut => self.zoom_key(ZoomKey::Out, Instant::now()), + KeyAction::ResetZoom => self.zoom_key(ZoomKey::Reset, Instant::now()), + KeyAction::NavigateBack => self.apply_one(ctx, Action::NavigateBack), + KeyAction::ToggleBulkEditAccount(id) => { + if let Some(b) = self.booted.as_mut() { + b.bulk.toggle_account(&id); + } + } + KeyAction::BulkEditSelectAll => { + self.apply_one(ctx, Action::Bulk(BulkEvent::SelectAll)); + } + KeyAction::BulkEditDeselectAll => { + self.apply_one(ctx, Action::Bulk(BulkEvent::DeselectAll)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::Modifiers; + + fn press(key: Key, physical: Option, modifiers: Modifiers) -> Event { + Event::Key { + key, + physical_key: physical, + pressed: true, + repeat: false, + modifiers, + } + } + + #[test] + fn egui_keys_read_as_dom_keys() { + let ev = key_event(&press(Key::K, None, Modifiers::CTRL), false).unwrap(); + assert_eq!( + (ev.key.as_str(), ev.code.as_str(), ev.ctrl), + ("k", "KeyK", true) + ); + let ev = key_event( + &press(Key::N, None, Modifiers::CTRL | Modifiers::SHIFT), + false, + ); + assert_eq!(ev.unwrap().key, "N"); + // AZERTY: the logical key is "&", the physical one the digit row. + let ev = key_event(&press(Key::Num1, Some(Key::Num1), Modifiers::CTRL), false).unwrap(); + assert_eq!(ev.code, "Digit1"); + let ev = key_event(&press(Key::F5, None, Modifiers::NONE), true).unwrap(); + assert_eq!((ev.key.as_str(), ev.editable_target), ("F5", true)); + let ev = key_event(&press(Key::Space, None, Modifiers::NONE), false).unwrap(); + assert_eq!((ev.key.as_str(), ev.code.as_str()), (" ", "Space")); + let ev = key_event(&press(Key::ArrowDown, None, Modifiers::NONE), false).unwrap(); + assert_eq!( + (ev.key.as_str(), ev.code.as_str()), + ("ArrowDown", "ArrowDown") + ); + let ev = key_event(&press(Key::Escape, None, Modifiers::NONE), false).unwrap(); + assert_eq!(ev.key, "Escape"); + let ev = key_event(&press(Key::Plus, None, Modifiers::CTRL), false).unwrap(); + assert_eq!(ev.code, "Equal"); + assert!(key_event(&press(Key::Backtick, None, Modifiers::NONE), false).is_none()); + let menu = crate::CONTEXT_MENU_KEY; + let ev = key_event(&press(menu, Some(menu), Modifiers::NONE), false).unwrap(); + assert_eq!( + (ev.key.as_str(), ev.code.as_str()), + ("ContextMenu", "ContextMenu") + ); + } +} diff --git a/crates/accshift-egui/src/app/mod.rs b/crates/accshift-egui/src/app/mod.rs new file mode 100644 index 00000000..e77a5258 --- /dev/null +++ b/crates/accshift-egui/src/app/mod.rs @@ -0,0 +1,2063 @@ +//! The egui application: `AppModel` owns everything `App.svelte` and its +//! controllers hold, and draws one frame in the DOM order of `App.svelte`. +//! +//! - backend: [`MockBackend`] on `parity/data/.json` in a scene +//! run, [`RealBackend`] on the user's app folders otherwise, both behind +//! `Arc`; +//! - [`Jobs`]: every backend call runs off the UI thread, and a result wakes +//! the UI through `Context::request_repaint`; +//! - [`ClientStores`] loaded from the boot payload's storage snapshot; +//! - the theme: [`ThemeRegistry`] with the custom themes, the +//! [`ThemeApplication`] of the current settings and OS, its +//! [`ResolvedTheme`] and the typed [`Vars`], recomputed when the theme id, +//! the background opacity, the motion setting or the OS change; +//! - the locale ([`Locale`]) for `accshift_app::i18n`; +//! - navigation: the active platform tab, the enabled platforms, settings +//! and personas panels; +//! - [`Action`]s returned by components, applied after the frame is drawn. +//! +//! A frame: take finished jobs, then draw (`shell::draw`), then apply the +//! actions. Before the boot payload lands nothing is drawn, like +//! `.app-frame { opacity: 0 }` before `boot-ready`. + +pub mod accounts; +mod avatar_pack; +pub mod avatars; +pub mod clock; +mod deep_link; +mod drag; +pub(crate) mod keys; +pub mod onboarding; +mod palette; +pub mod persona_wizard; +pub mod personas; +pub mod platforms; +mod secure; +pub mod settings_panel; +mod shell; +pub mod theme_editor; +mod ui_scale; +mod updates; +pub mod vars; +mod wallpaper; + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use accshift_app::accounts::LoadingEvent; +use accshift_app::actions::dialogs::{ + finish_rename_account, rename_account_blocking, AppDialogs, AppMenuEntry, DialogContext, +}; +use accshift_app::actions::keyboard::KeyboardController; +use accshift_app::actions::toasts::AddToastOptions; +use accshift_app::actions::{card_actions, load_options, shell as app_shell, AppEffect}; +use accshift_app::backend::Backend; +use accshift_app::error::BackendResult; +use accshift_app::folders::{FolderInfo, ItemKind}; +use accshift_app::i18n::Locale; +use accshift_app::jobs::{Jobs, Pending, Poll}; +use accshift_app::lifecycle::app_shell::first_run_platforms; +use accshift_app::menu::{ + build_context_menu_items, PlatformTask, SubmenuLoader, TaskEffect, TaskOutcome, + ACCOUNT_CONTEXT_GROUP_ORDER, +}; +use accshift_app::mock::{resolve_avatars, MockBackend}; +use accshift_app::platform_add_flow::{AddFlowEvent, SetupChipAction}; +use accshift_app::platforms::PlatformAccount; +use accshift_app::real::RealBackend; +use accshift_app::secure::screen::SecureScreen; +use accshift_app::settings::AnimationsMode; +use accshift_app::storage::{self, LegacyLocalStorage, StoreId}; +use accshift_app::stores::{ClientStores, StoreContext, ViewMode}; +use accshift_app::streamer::StreamerModeController; +use accshift_app::text::Text; +use accshift_app::types::BootPayload; +use accshift_theme::{ + theme_application, window_backdrop, AppTheme, CustomThemePayload, ResolvedTheme, + ThemeApplication, ThemeRegistry, WindowBackdrop, +}; +use egui::{Context, Event, Pos2, Rect, TextureHandle, Ui, ViewportCommand}; +use serde_json::Value; + +use crate::screens::workspace::Workspace; +use crate::ui::anim::{self, Motion}; +use crate::ui::components::bulk_edit::{BulkEvent, COVER_ID}; +use crate::ui::components::command_palette::PaletteEvent; +use crate::ui::components::context_menu::{self, MenuEvent, SubmenuLoad}; +use crate::ui::components::controls::field_menu; +use crate::ui::components::dialog::DialogEvent; +use crate::ui::components::secure_overlays::{PinEvent, StreamerEvent}; +use crate::ui::components::toast::ToastEvent; +use accounts::Accounts; +use clock::Clock; +use platforms::{PlatformDef, RuntimeOs}; +use vars::Vars; + +/// What a component asks for. Applied by [`AppModel::apply`] after drawing. +#[derive(Clone, Debug, PartialEq)] +pub enum Action { + /// Title bar refresh: reload the active platform's accounts. + Refresh, + /// Title bar plus: start the add-account flow. + AddAccount, + /// Title bar gear: toggle the settings panel. + OpenSettings, + /// Personas tab. + OpenPersonas, + /// Bulk edit toggle. + BulkEdit, + /// The bulk edit bar or its settings dialog. + Bulk(BulkEvent), + /// Update call to action. + ApplyUpdate, + /// A platform tab. + TabChange(String), + Minimize, + ToggleMaximize, + Close, + /// Mousedown on the bar outside a button. + StartDrag, + /// Where the maximize button is, for Windows 11 Snap Layouts + /// (`set_maximize_button_rect`), `None` when there is none (macOS). + SetMaximizeButtonRect(Option), + /// The settings panel (`settings_panel`). + Settings(settings_panel::SettingsAction), + /// Breadcrumb, folder card or section header: `navigateTo(folderId)`, + /// `None` being the root. + NavigateToFolder(Option), + /// The back card: `navigateToParentFolder`. + NavigateBack, + /// The search input's new value. + SearchChanged(String), + SetViewMode(ViewMode), + /// A section header's chevron: its collapse key. + ToggleSection(String), + /// A card click (`onAccountActivate`): drops a setup of the tab unless + /// it is this card's. + AccountActivate(String), + /// A clickable chip of the setup card's extension: section, chip. + SetupChip(usize, usize), + /// `onAccountSwitch`: a card's switch (double click or Enter on a card, + /// a click on a list row). + AccountSwitch(String), + /// A toast was hovered, dismissed or its action clicked. + Toast(ToastEvent), + /// The PIN lock: a new attempt, its window buttons, a drag. + Pin(PinEvent), + /// From the streamer overlay. + Streamer(StreamerEvent), + /// A right click on a card, a folder or the workspace background, at + /// this window point (`openAccountContextMenu` and its siblings). + ContextMenu(MenuTarget, Pos2), + /// The menu opened as `open_id` reported something. + Menu(u64, MenuEvent), + /// `.content` scrolled. + ContentScrolled, + /// The command palette mounted as this number reported something. + Palette(u64, PaletteEvent), + /// The input dialog mounted as this number reported something. + InputDialog(u64, DialogEvent), + /// The confirm dialog mounted as this number reported something. + ConfirmDialog(u64, DialogEvent), + /// The first-run onboarding. + Onboarding(onboarding::OnboardingEvent), + /// The personas panel. + Personas(personas::PersonasEvent), +} + +/// What a right click opened the menu on. +#[derive(Clone, Debug, PartialEq)] +pub enum MenuTarget { + Account(PlatformAccount), + Folder(FolderInfo), + Background, +} + +/// How a scene sets up the mock, the same fields `parity/ref.mjs` writes into +/// `accshift.mock.overrides`. +#[derive(Clone, Debug, Default)] +pub struct MockSetup { + pub scenario: String, + pub os: Option, + pub theme: Option, + pub locale: Option, + pub seed: Option, + /// `overrides.streaming`: streaming software "runs". + pub streaming: bool, + /// `overrides.cs2`: the CS2 bridge rows, the bridge enabled. + pub cs2: Option>, + /// `overrides.onboarding`: the first-run onboarding was never completed. + pub onboarding: bool, + /// `overrides.failures`: commands that fail, with their message. + pub failures: Vec<(String, String)>, + /// `overrides.setup`: the statuses of an add-account setup. + pub setup: Option>, + /// `overrides.wallpaper`, then the window's outer position and scale + /// factor `realign` reads (physical px). + pub wallpaper: Option<(Value, [i32; 2], f32)>, + /// `parity/data`. + pub data_dir: PathBuf, + /// The repo root, to resolve the mock avatar paths. + pub repo_root: Option, +} + +impl MockSetup { + /// `theme` and `locale` patch `client.settings`; a `seed` key is a store + /// id or the suffix of its `CLIENT_STORE_` constant, an object merges over + /// the scenario's store and anything else replaces it. Avatar paths in a + /// seed resolve against the repo like the scenario's own. + pub fn patch_stores(&self, stores: &mut std::collections::BTreeMap) { + let settings_id = storage::CLIENT_STORE_SETTINGS; + let mut settings = stores + .get(settings_id) + .cloned() + .filter(Value::is_object) + .unwrap_or_else(|| Value::Object(Default::default())); + if let Some(theme) = &self.theme { + settings["themeId"] = Value::String(theme.clone()); + } + if let Some(locale) = &self.locale { + settings["language"] = Value::String(locale.clone()); + } + stores.insert(settings_id.to_string(), settings); + let Some(Value::Object(seed)) = &self.seed else { + return; + }; + for (key, value) in seed { + let id = store_id_for_seed(key).unwrap_or(key.as_str()).to_string(); + let value = resolve_avatars(value.clone(), self.repo_root.as_deref()); + let merged = match (value, stores.get(&id)) { + (Value::Object(over), Some(Value::Object(base))) => { + let mut out = base.clone(); + out.extend(over); + Value::Object(out) + } + (value, _) => value, + }; + stores.insert(id, merged); + } + } +} + +/// `CLIENT_STORE_` of `src/lib/storage/storeIds.ts` for a seed key. +/// The suffixes follow the ids (`client.view-mode` is `VIEW_MODE`), except +/// the caches, which a seed names by their full id. +fn store_id_for_seed(key: &str) -> Option { + storage::CLIENT_STORE_IDS.into_iter().find(|id| { + id.strip_prefix("client.") + .is_some_and(|rest| rest.replace('-', "_").eq_ignore_ascii_case(key)) + }) +} + +/// The inputs a theme application depends on. +#[derive(Clone, Debug, PartialEq)] +struct ThemeKey { + theme_id: String, + opacity: i64, + os: RuntimeOs, + animations: AnimationsMode, + custom_themes: usize, + /// A wallpaper snapshot is up (`liquidBackdrop.wallpaper !== null`). + wallpaper: bool, +} + +/// The theme in effect. +pub struct ThemeState { + key: ThemeKey, + pub theme: AppTheme, + pub application: ThemeApplication, + pub resolved: ResolvedTheme, + pub vars: Vars, + /// What the window shell must put behind a transparent window. + pub backdrop: WindowBackdrop, + pub motion: Motion, +} + +/// Navigation sub-state (`createPlatformShellState`, `useAppNavigation`, +/// `usePersonaSwitch`). +#[derive(Clone, Debug, Default)] +pub struct Navigation { + pub active_tab: String, + pub show_settings: bool, + pub show_personas: bool, +} + +impl Navigation { + /// `panelKey` of `App.svelte`. + pub fn panel_key(&self) -> String { + if self.show_settings { + "__settings__".into() + } else if self.show_personas { + "__personas__".into() + } else { + self.active_tab.clone() + } + } +} + +/// Everything that exists once the boot payload is in. +pub struct Booted { + pub runtime_os: RuntimeOs, + pub stores: ClientStores, + pub platforms: Vec, + pub registry: ThemeRegistry, + pub theme: ThemeState, + /// The theme editor's draft while it paints the app + /// (`previewSnapshot`): settings changes do not re-apply over it. + pub theme_preview: Option, + pub locale: Locale, + pub nav: Navigation, + /// `panelKey`: a change remounts the panel (`{#key panelKey}`). + pub panel: String, + pub init_error: Option, + /// Accounts, display pipeline, folder navigation, avatar textures. + pub accounts: Accounts, + /// The workspace's own state (scroll, mounts, slides). + pub workspace: Workspace, + /// The AFK cover and the PIN lock. + pub secure: SecureScreen, + /// `createStreamerModeController`. + pub streamer: StreamerModeController, + /// What the backend last read in the descriptor folder (the boot + /// payload's, then the Platforms tab's last reload). + pub user_platforms: Option, + /// `getDetectedPlatforms`: what first-run detection found and can use, + /// empty when it did not run or found nothing. + pub detected_platforms: Vec, + /// `createPersonaController`. + pub personas: accshift_app::actions::personas::PersonaController, + /// `createBulkEditController`: the mode, its selection and the bar. + pub bulk: accshift_app::bulk_edit::BulkEdit, + /// Bumped each time a bar step mounts (`{#if step}`): its entrance + /// animation starts over. + pub bulk_mount: u64, + bulk_key: Option, + /// The primary button is down (`e.buttons` of the drag's moves). + drag_held: bool, + /// The workspace scroll the drag slots were measured at. + drag_scroll: f32, + /// `handleAppMounted` ran. + secure_mounted: bool, + /// The settings panel was open last frame (`handleSettingsClosed`). + secure_in_settings: bool, +} + +/// The boot payload and, on a fresh install, what launcher detection found. +struct Boot { + payload: BootPayload, + detected: Vec, +} + +type BootResult = BackendResult; + +/// `enableDetectedPlatformsOnFirstRun`, once the stores are read: without +/// stored settings, the usable platforms `detected` found become the enabled +/// set, the first one the default. Returns `setDetectedPlatforms`' list. +fn enable_detected_platforms_on_first_run( + stores: &mut ClientStores, + detected: &[String], + registry: &accshift_app::platforms::PlatformRegistry, + os: accshift_app::platforms::RuntimeOs, +) -> Vec { + if stores.has_stored_settings() { + return Vec::new(); + } + let first = first_run_platforms(detected, registry, os); + if let Some((enabled, default)) = first.enable { + let mut next = stores.settings().clone(); + next.enabled_platforms = enabled; + next.default_platform_id = default; + stores.save_settings(&next); + } + first.detected +} + +/// `get_boot_payload`, then `platform_detect_installed` when no settings +/// were ever stored (`enableDetectedPlatformsOnFirstRun`, awaited before the +/// first tab is picked). Asked in the same job so boot waits once; an error +/// counts as nothing found, as in TS. +fn start_boot(jobs: &Jobs) -> Pending { + jobs.run(|backend| { + let payload = backend.get_boot_payload()?; + let fresh = !payload + .storage_snapshot + .stores + .contains_key(storage::CLIENT_STORE_SETTINGS); + let detected = if fresh { + backend.platform_detect_installed().unwrap_or_default() + } else { + Vec::new() + }; + Ok(Boot { payload, detected }) + }) +} +type SubmenuJob = Pending>>; + +/// The application model. See the module documentation. +pub struct AppModel { + backend: Arc, + mock: Option>, + setup: Option, + jobs: Jobs, + clock: Clock, + boot_job: Option>, + /// The `localStorage` of a v0.x install, read before the log session + /// began (main.rs), for `initializeClientStorage`. + legacy: Option, + boot_error: Option, + booted: Option, + saves: Vec<(StoreId, Pending>>)>, + /// `createUiScale`: the zoom shortcuts and the debounced save. + ui_scale: accshift_app::lifecycle::ui_scale::UiScale, + /// `uiScalePercent` changed by a shortcut, until it is saved. + ui_scale_live: Option, + /// Which raster `.app-shell` holds ([`crate::zoom`]). + stage_raster: ui_scale::StageRaster, + /// The recorder's stand-in desktop under glass themes, scene runs only. + desktop: Option, + maximize_button: Option, + /// The open settings panel. + settings_panel: Option, + /// `addToast`'s queue, drawn by `toast::toast_stack`. + toasts: accshift_app::actions::toasts::ToastQueue, + /// `createAppDialogsController`: the context menu and the dialogs. + dialogs: AppDialogs, + /// Bumped on every menu open: a fresh menu mounts. + menu_open_id: u64, + /// The open menu's submenu loaders and their results. + submenus: Vec<(SubmenuLoader, SubmenuLoad)>, + submenu_jobs: Vec<(SubmenuLoader, SubmenuJob)>, + /// Menu tasks and account renames on the job pool. + task_jobs: Vec>, + rename_jobs: Vec>>, + /// The mount number of the open input and confirm dialogs: a dialog + /// that opens mounts fresh (animations, focus). + input_mount: Option, + confirm_mount: Option, + mounts: u64, + /// Chromium's keyboard modality: a key press sets it, a press that + /// focuses an element clears it. Focus rings follow it (`:focus-visible`). + focus_visible: bool, + last_time: f64, + os_reduced_motion: bool, + /// The secure screen asked for keyboard focus in the PIN field. + pin_focus: bool, + /// Frames drawn, for the boot marks. + frames: u64, + ready_marked: bool, + /// Bench marks already written: the boot payload, the accounts, the + /// avatars (bits 0 to 2). + boot_marks: u8, + /// `createKeyboardController` with the app's bindings. + keyboard: KeyboardController, + /// `paletteOpen`: the open command palette. + palette: Option, + /// What the card focus was set under (tab, folder, query, list view, + /// settings): a change clears it. + card_focus_context: Option<(String, Option, String, bool, bool)>, + /// The widget holding real focus last frame (`focusin`). + card_focus_widget: Option, + /// [`Accounts::card_focus_signature`](accounts::Accounts::card_focus_signature) last frame. + card_focus_cards: Option, + /// `createOnboardingTour` and the mounted `TelemetryOnboarding`. + onboarding: onboarding::Onboarding, + /// The persona switch confirm and the mounted personas panel. + personas: personas::PersonasState, + /// `createDeepLinkController`, listening from mount. + deep_links: deep_link::DeepLinks, + /// `createAppUpdater` and its mount timer. + updates: updates::Updates, + /// `createLiquidBackdrop`. + liquid: wallpaper::Liquid, +} + +impl AppModel { + /// A scene run: the mock on `parity/data/.json`, the clock + /// pinned to the scenario's `NOW`, the stand-in desktop behind. + pub fn mock(ctx: &Context, setup: MockSetup) -> Result { + // `accshift.mock.overrides.runtimeOs` patches the scenario's spec + // before the handlers exist, frozen `demo` included, unlike + // `__mock.os` (`set_runtime_os`), which the frozen scenario refuses. + let file = setup.data_dir.join(format!("{}.json", setup.scenario)); + let text = + std::fs::read_to_string(&file).map_err(|e| format!("read {}: {e}", file.display()))?; + let mut scenario: Value = + serde_json::from_str(&text).map_err(|e| format!("{}: {e}", file.display()))?; + if let Some(os) = &setup.os { + scenario["spec"]["runtimeOs"] = Value::String(os.clone()); + } + let mock = MockBackend::from_json(&scenario.to_string(), setup.repo_root.as_deref()) + .map_err(|e| format!("{}: {e}", file.display()))?; + mock.set_streaming(setup.streaming); + mock.set_cs2(setup.cs2.clone()); + mock.set_onboarding(setup.onboarding); + mock.set_setup(setup.setup.clone()); + mock.set_wallpaper( + setup + .wallpaper + .as_ref() + .map(|(snapshot, ..)| snapshot.clone()), + ); + for (command, message) in &setup.failures { + mock.fail(command, Some(message)); + } + let mock = Arc::new(mock); + let clock = Clock::pinned(mock.now()); + let backend: Arc = mock.clone(); + let mut model = AppModel::with_backend(ctx, backend, Some(mock), clock); + if let Some((_, window, scale)) = &setup.wallpaper { + model.liquid.pin_window(*window, *scale); + } + model.setup = Some(setup); + model.desktop = Some(ctx.load_texture( + "desktop", + crate::backdrop::render(860, 440, 2.0), + egui::TextureOptions::LINEAR, + )); + Ok(model) + } + + /// A normal run on the user's real app folders, the backend shared with + /// the window ([`crate::window::Host`]). + pub fn real( + ctx: &Context, + backend: Arc, + legacy: Option, + ) -> AppModel { + let backend: Arc = backend; + let mut model = AppModel::with_backend(ctx, backend, None, Clock::wall()); + model.legacy = legacy; + model.os_reduced_motion = os_prefers_reduced_motion(); + model.updates = updates::Updates::real(Instant::now()); + model + } + + /// Any backend; the boot payload is requested at once. + pub fn with_backend( + ctx: &Context, + backend: Arc, + mock: Option>, + clock: Clock, + ) -> AppModel { + let wake = ctx.clone(); + let jobs = Jobs::new(Arc::clone(&backend), 2, move || wake.request_repaint()); + let boot_job = Some(start_boot(&jobs)); + crate::ui::text::install(ctx, false); + // Chromium hits exactly what is under the pointer: no 5 px slop. + ctx.all_styles_mut(|style| style.interaction.interact_radius = 0.0); + AppModel { + backend, + mock, + setup: None, + jobs, + clock, + boot_job, + legacy: None, + boot_error: None, + booted: None, + saves: Vec::new(), + ui_scale: Default::default(), + ui_scale_live: None, + stage_raster: ui_scale::StageRaster::Unknown, + desktop: None, + maximize_button: None, + settings_panel: None, + toasts: accshift_app::actions::toasts::ToastQueue::new(), + dialogs: AppDialogs::new(), + menu_open_id: 0, + submenus: Vec::new(), + submenu_jobs: Vec::new(), + task_jobs: Vec::new(), + rename_jobs: Vec::new(), + input_mount: None, + confirm_mount: None, + mounts: 0, + // `last_focus_type` starts as none, not mouse: a script focus + // before any click shows its ring (a deep link's dialog). + focus_visible: true, + last_time: 0.0, + os_reduced_motion: false, + pin_focus: false, + frames: 0, + ready_marked: false, + boot_marks: 0, + keyboard: KeyboardController::default(), + palette: None, + card_focus_context: None, + card_focus_widget: None, + card_focus_cards: None, + onboarding: onboarding::Onboarding::default(), + personas: personas::PersonasState::default(), + deep_links: deep_link::DeepLinks::started(), + updates: updates::Updates::off(), + liquid: wallpaper::Liquid::default(), + } + } + + pub fn backend(&self) -> &Arc { + &self.backend + } + + pub fn booted(&self) -> Option<&Booted> { + self.booted.as_ref() + } + + #[cfg(test)] + pub(crate) fn booted_mut(&mut self) -> Option<&mut Booted> { + self.booted.as_mut() + } + + /// A Liquid Glass wallpaper snapshot is up: the shell draws it aligned + /// to the screen. + pub fn wallpaper_shown(&self) -> bool { + self.liquid.present() + } + + /// The OS backdrop the current theme (or its editor preview) asks for. + pub fn window_backdrop(&self) -> Option<&WindowBackdrop> { + self.booted.as_ref().map(|b| &b.theme.backdrop) + } + + /// `closeToTray`: the close button hides to the tray instead of quitting. + /// False until booted, so a close before boot quits normally. + pub fn close_to_tray(&self) -> bool { + self.booted + .as_ref() + .is_some_and(|b| b.stores.settings().close_to_tray) + } + + /// The active UI locale, English until booted. + pub fn locale(&self) -> Locale { + self.booted.as_ref().map_or(Locale::En, |b| b.locale) + } + + pub fn boot_error(&self) -> Option<&str> { + self.boot_error.as_deref() + } + + /// `Date.now()` of the app (pinned in scene runs). + pub fn now_ms(&self) -> f64 { + self.clock.now_ms() + } + + /// The open settings panel. + pub fn settings_panel(&self) -> Option<&settings_panel::SettingsPanel> { + self.settings_panel.as_ref() + } + + /// The toasts `addToast` queued. + pub fn toasts(&self) -> &accshift_app::actions::toasts::ToastQueue { + &self.toasts + } + + /// The context menu and dialogs. + pub fn dialogs(&self) -> &AppDialogs { + &self.dialogs + } + + /// The mount numbers of the open input and confirm dialogs. + pub fn dialog_mounts(&self) -> (Option, Option) { + (self.input_mount, self.confirm_mount) + } + + /// The open command palette: its mount, query and highlighted row. + pub fn palette(&self) -> Option<(u64, &str, usize)> { + self.palette + .as_ref() + .map(|p| (p.mount, p.view.model.query(), p.view.model.active_index())) + } + + /// Where the maximize button was last drawn (Snap Layouts hook). + pub fn maximize_button_rect(&self) -> Option { + self.maximize_button + } + + /// Blocks until the boot payload is in and applied. For tests and tools; + /// a frame never waits. + pub fn wait_boot(&mut self, ctx: &Context) -> Result<(), String> { + if let Some(job) = self.boot_job.take() { + let result = job.wait().unwrap_or_else(|| { + Err(accshift_app::error::BackendError::new("boot job panicked")) + }); + self.finish_boot(ctx, result); + } + match (&self.booted, &self.boot_error) { + (Some(_), _) => Ok(()), + (None, Some(e)) => Err(e.clone()), + (None, None) => Err("boot never ran".into()), + } + } + + /// Ready to be photographed: booted, the accounts loaded, the panel's + /// cards and avatars settled. + pub fn ready(&self) -> bool { + self.booted.as_ref().is_some_and(|b| { + b.accounts.settled() + && !b.accounts.avatars.busy() + && b.workspace + .settled(&b.theme.vars, b.theme.motion, self.last_time) + && self.personas.panel.as_ref().is_none_or(|p| p.settled()) + }) + } + + fn finish_boot(&mut self, ctx: &Context, result: BootResult) { + let Boot { + mut payload, + detected, + } = match result { + Ok(boot) => boot, + Err(error) => { + self.boot_error = Some(error.message); + return; + } + }; + if let Some(setup) = &self.setup { + setup.patch_stores(&mut payload.storage_snapshot.stores); + } + let runtime_os = RuntimeOs::parse(&payload.runtime_os); + let legacy = self.legacy.take(); + let (storage, report) = storage::initialize( + self.backend.as_ref(), + Some(payload.storage_snapshot), + legacy.as_ref(), + self.mock.is_some(), + ); + let user_platforms = payload.user_platforms.clone(); + let loaded: Vec = payload + .user_platforms + .loaded + .iter() + .filter_map(|d| serde_json::to_value(d).ok()) + .collect(); + let platforms = platforms::with_user(&loaded); + let custom: Vec = payload + .custom_themes + .iter() + .filter_map(|t| serde_json::to_value(t).ok()) + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + let mut registry = ThemeRegistry::new(); + registry.apply_custom_theme_payloads(&custom); + let mut store_ctx = StoreContext::shipped(); + store_ctx.platform_ids = platforms.iter().map(|p| p.id.clone()).collect(); + store_ctx.theme_ids = registry.all_themes().into_iter().map(|t| t.id).collect(); + store_ctx.preferred_languages = preferred_languages(); + let mut stores = ClientStores::new(storage, store_ctx); + let platform_registry = accounts::platform_registry(&loaded); + let detected = enable_detected_platforms_on_first_run( + &mut stores, + &detected, + &platform_registry, + accounts::app_os(runtime_os), + ); + if let Some(mock) = &self.mock { + // The TS mock reads the live settings for its PIN gate. + mock.set_pin_settings(&stores.settings().to_value()); + } + let settings = stores.settings(); + let nav = Navigation { + active_tab: platforms::initial_tab( + &platforms, + &settings.enabled_platforms, + &settings.default_platform_id, + runtime_os, + ), + ..Navigation::default() + }; + let locale = stores.language(); + let secure = SecureScreen::new(stores.settings(), Instant::now()); + let streamer = StreamerModeController::new(stores.settings()); + let theme = compute_theme( + ®istry, + &stores, + runtime_os, + self.os_reduced_motion, + self.liquid.present(), + ); + anim::set_motion(ctx, theme.motion); + let panel = nav.panel_key(); + let app_clock = match &self.mock { + Some(mock) => accshift_app::time::Clock::mock(mock.now()), + None => accshift_app::time::Clock::system(), + }; + let accounts = Accounts::boot( + &self.jobs, + &mut stores, + platform_registry, + &nav.active_tab, + runtime_os, + app_clock, + self.mock.is_some(), + ); + let persona_controller = personas::controller(&stores); + self.booted = Some(Booted { + runtime_os, + stores, + platforms, + registry, + theme, + theme_preview: None, + locale, + nav, + panel, + init_error: report.load_error, + accounts, + workspace: Workspace::new(), + secure, + streamer, + user_platforms: Some(user_platforms), + detected_platforms: detected, + personas: persona_controller, + bulk: Default::default(), + bulk_mount: 0, + bulk_key: None, + drag_held: false, + drag_scroll: 0.0, + secure_mounted: false, + secure_in_settings: false, + }); + // `finish_boot` (marks, the startup event); the real shell shows the + // window here. Fire and forget. + drop(self.jobs.run(|backend| backend.finish_boot("egui", None))); + // `onMount`: `onboarding.openIfNeverCompleted()`. + self.onboarding.start(&self.jobs); + } + + /// Settings changed under us (a store write or an external refresh): + /// recompute what derives from them. + fn sync_settings(&mut self, ctx: &Context) { + let Some(b) = self.booted.as_mut() else { + return; + }; + b.stores.sync(); + // `liquidBackdropActive`: the snapshot comes and goes with it. + let liquid = b.theme.theme.liquid && b.runtime_os == RuntimeOs::Windows; + self.liquid.tick(ctx, &self.jobs, liquid, Instant::now()); + let wallpaper = self.liquid.present(); + let key = theme_key(&b.stores, &b.registry, b.runtime_os, wallpaper); + if key != b.theme.key && b.theme_preview.is_none() { + b.theme = compute_theme( + &b.registry, + &b.stores, + b.runtime_os, + self.os_reduced_motion, + wallpaper, + ); + } + b.locale = b.stores.language(); + // Harness scenes clear egui memory: keep the motion flag in it. + anim::set_motion(ctx, b.theme.motion); + } + + /// `flushAppState` on close, after `uiScale.destroy`: the pending UI + /// scale, the settings panel's debounced save, the saves in flight, + /// then every store write still queued, blocking. The first error wins. + pub fn flush_app_state(&mut self) -> Result<(), String> { + let live = self.ui_scale_live; + if let Some(b) = self.booted.as_mut() { + let stored = b.stores.settings().ui_scale_percent; + if let Some(value) = self.ui_scale.destroy(live.unwrap_or(stored)) { + if stored != value { + let mut next = b.stores.settings().clone(); + next.ui_scale_percent = value; + b.stores.save_settings(&next); + } + } + } + self.flush_settings(Instant::now()); + let Some(b) = self.booted.as_mut() else { + return Ok(()); + }; + let mut first_error = None; + for (id, pending) in self.saves.drain(..) { + if let Some(result) = pending.wait() { + if let Err(e) = &result { + first_error.get_or_insert_with(|| e.message.clone()); + } + b.stores.finish_save(id, &result); + } + } + for save in b.stores.take_all_saves() { + let result = self + .backend + .save_client_storage_store(save.store_id, &save.value); + if let Err(e) = &result { + first_error.get_or_insert_with(|| e.message.clone()); + } + b.stores.finish_save(save.store_id, &result); + } + first_error.map_or(Ok(()), Err) + } + + /// Saves the stores asked for, through the job pool. + fn pump_saves(&mut self) { + let Some(b) = self.booted.as_mut() else { + return; + }; + for save in b.stores.take_due_saves(Instant::now()) { + let store_id = save.store_id; + let pending = self + .jobs + .run(move |backend| backend.save_client_storage_store(store_id, &save.value)); + self.saves.push((store_id, pending)); + } + self.saves.retain_mut(|(id, pending)| match pending.poll() { + Poll::Waiting => true, + Poll::Ready(result) => { + b.stores.finish_save(id, &result); + false + } + Poll::Gone => false, + }); + } + + /// One frame. + pub fn frame(&mut self, ui: &mut Ui) { + let _span = crate::bench::span("app"); + let ctx = ui.ctx().clone(); + // Chromium moves focus on mousedown: a press outside the focused + // element blurs it, and a click on a `