From acb842b1871f2e12098aea7c5b7045fe7f3e9700 Mon Sep 17 00:00:00 2001 From: mimaxon1 Date: Thu, 15 Oct 2026 11:04:50 +0300 Subject: [PATCH] feat: rewrite PC Remote desktop runtime in Rust for 2.0 --- .github/workflows/release.yml | 320 ++-- .github/workflows/tests.yml | 45 +- .gitignore | 1 + CHANGELOG.md | 8 + CHANGELOG_RU.md | 8 + Cargo.lock | 2722 +++++++++++++++++++++++++++++++++ Cargo.toml | 42 + README.md | 89 +- README_RU.md | 89 +- SECURITY.md | 23 +- build.rs | 5 + docs/testing.md | 80 +- docs/testing_RU.md | 77 +- src/api.rs | 912 +++++++++++ src/config.rs | 47 + src/main.rs | 88 ++ src/platform.rs | 603 ++++++++ src/state.rs | 728 +++++++++ src/tray.rs | 125 ++ 19 files changed, 5624 insertions(+), 388 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 build.rs create mode 100644 src/api.rs create mode 100644 src/config.rs create mode 100644 src/main.rs create mode 100644 src/platform.rs create mode 100644 src/state.rs create mode 100644 src/tray.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67b0e29..aa2431b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,26 +2,24 @@ name: Release on: push: - tags: - - "v*" + tags: ["v*"] workflow_dispatch: inputs: version: - description: "Version used in package filenames (for example 1.5.0-test)" + description: "Version used in release asset names" required: true - default: "0.0.0-test" + default: "2.0.0-test" env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" permissions: - contents: write + contents: read jobs: - build-release: + build-windows: runs-on: windows-latest timeout-minutes: 35 - steps: - name: Check out repository uses: actions/checkout@v5 @@ -32,161 +30,241 @@ jobs: if ("${{ github.event_name }}" -eq "workflow_dispatch") { $version = "${{ inputs.version }}".TrimStart('v') } - else { - $version = "${{ github.ref_name }}".TrimStart('v') - } - if ([string]::IsNullOrWhiteSpace($version)) { - throw "Package version is empty" - } + else { $version = "${{ github.ref_name }}".TrimStart('v') } + if ([string]::IsNullOrWhiteSpace($version)) { throw "Package version is empty" } "APP_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "PC_REMOTE_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy - - name: Set up Python + - name: Set up Python for Windows package metadata uses: actions/setup-python@v6 with: python-version: "3.13" - cache: pip - - name: Install Python dependencies + - name: Run Rust tests and checks run: | - python -m pip install --upgrade pip - python -m pip install -r requirements.txt pyinstaller tzdata + cargo fmt --all -- --check + cargo test --locked + cargo clippy --locked --all-targets -- -D warnings - - name: Run tests - run: python -m pytest + - name: Build Windows app + run: cargo build --locked --release - - name: Build portable application - run: python build_release.py --reset-settings - - - name: Install Inno Setup - run: choco install innosetup --no-progress -y - - - name: Build installer and portable archive + - name: Stage portable app and create release archives shell: pwsh run: | - New-Item -ItemType Directory -Force -Path release | Out-Null - - Compress-Archive ` - -Path "dist\PC Remote" ` - -DestinationPath "release\PC-Remote-$env:APP_VERSION-windows-x64-portable.zip" ` - -Force + New-Item -ItemType Directory -Force -Path "dist\PC Remote", "release" | Out-Null + Copy-Item "target\release\pc-remote.exe" "dist\PC Remote\PC Remote.exe" + Copy-Item "web\favicon.ico" "dist\PC Remote\favicon.ico" + Compress-Archive -Path "dist\PC Remote" -DestinationPath "release\PC-Remote-$env:APP_VERSION-windows-x64-portable.zip" -Force + choco install innosetup --no-progress -y $iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" - if (-not (Test-Path $iscc)) { - throw "Inno Setup compiler not found at $iscc" - } - + if (-not (Test-Path $iscc)) { throw "Inno Setup compiler not found" } & $iscc "/DAppVersion=$env:APP_VERSION" "installer\PC-Remote.iss" - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } + if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed with exit code $LASTEXITCODE" } - - name: Verify unattended install and ARP metadata + - name: Verify unattended Windows install and uninstall shell: pwsh run: | $setup = Get-ChildItem release -Filter '*-Setup.exe' | Select-Object -First 1 - if (-not $setup) { - throw 'Setup EXE was not produced.' - } - - $install = Start-Process ` - -FilePath $setup.FullName ` - -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-' ` - -Wait -PassThru - if ($install.ExitCode -ne 0) { - throw "Silent installer failed with exit code $($install.ExitCode)" - } - + if (-not $setup) { throw 'Setup EXE was not produced.' } + $install = Start-Process -FilePath $setup.FullName -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-' -Wait -PassThru + if ($install.ExitCode -ne 0) { throw "Installer failed: $($install.ExitCode)" } $installedExe = Join-Path $env:LOCALAPPDATA 'Programs\PC Remote\PC Remote.exe' - if (-not (Test-Path $installedExe)) { - throw "Installed executable not found: $installedExe" - } - - $uninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{8A5675B9-1F20-4E2B-8BB4-D0C42973F9E4}_is1' - $arp = Get-ItemProperty $uninstallKey -ErrorAction Stop - if ($arp.DisplayName -ne "PC Remote $env:APP_VERSION") { - throw "Unexpected ARP DisplayName: $($arp.DisplayName)" - } - if ($arp.DisplayVersion -ne $env:APP_VERSION) { - throw "Unexpected ARP DisplayVersion: $($arp.DisplayVersion)" - } - if ($arp.Publisher -ne 'mimaxon1') { - throw "Unexpected ARP Publisher: $($arp.Publisher)" - } - + if (-not (Test-Path $installedExe)) { throw "Installed executable not found: $installedExe" } + $launched = Start-Process -FilePath $installedExe -ArgumentList '--autostart-status' -Wait -PassThru + if ($launched.ExitCode -ne 0) { throw 'Installed application CLI smoke failed.' } $uninstaller = Join-Path $env:LOCALAPPDATA 'Programs\PC Remote\unins000.exe' - if (-not (Test-Path $uninstaller)) { - throw "Uninstaller not found: $uninstaller" - } - $uninstall = Start-Process ` - -FilePath $uninstaller ` - -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' ` - -Wait -PassThru - if ($uninstall.ExitCode -ne 0) { - throw "Silent uninstaller failed with exit code $($uninstall.ExitCode)" - } + $uninstall = Start-Process -FilePath $uninstaller -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Wait -PassThru + if ($uninstall.ExitCode -ne 0) { throw "Uninstaller failed: $($uninstall.ExitCode)" } - - name: Generate SHA-256 checksums - shell: pwsh - run: | - $lines = Get-ChildItem release -File | - Where-Object { $_.Name -ne "SHA256SUMS.txt" } | - Sort-Object Name | - ForEach-Object { - $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash $($_.Name)" - } - $lines | Set-Content -Path "release\SHA256SUMS.txt" -Encoding ascii - Get-Content "release\SHA256SUMS.txt" - - - name: Generate WinGet, Chocolatey and Scoop metadata + - name: Generate Windows distribution metadata shell: pwsh run: | $setup = Get-ChildItem release -Filter '*-Setup.exe' | Select-Object -First 1 $portable = Get-ChildItem release -Filter '*-portable.zip' | Select-Object -First 1 - if (-not $setup -or -not $portable) { - throw 'Release artifacts required for distribution metadata are missing.' - } - + if (-not $setup -or -not $portable) { throw 'Release assets are missing.' } $setupSha = (Get-FileHash $setup.FullName -Algorithm SHA256).Hash $portableSha = (Get-FileHash $portable.FullName -Algorithm SHA256).Hash - - python scripts/render_distribution.py ` - --version $env:APP_VERSION ` - --setup-sha256 $setupSha ` - --portable-sha256 $portableSha ` - --output release/distribution - - $wingetDir = "release\distribution\winget\manifests\m\mimaxon1\PCRemote\$env:APP_VERSION" - if (-not (Test-Path $wingetDir)) { - throw 'WinGet manifest set was not generated.' - } - + python scripts/render_distribution.py --version $env:APP_VERSION --setup-sha256 $setupSha --portable-sha256 $portableSha --output release/distribution Get-Content "release\distribution\scoop\pc-remote.json" -Raw | ConvertFrom-Json | Out-Null + choco pack "release\distribution\chocolatey\pc-remote.nuspec" --outputdirectory "release\distribution\chocolatey" + if ($LASTEXITCODE -ne 0) { throw "Chocolatey package build failed: $LASTEXITCODE" } - choco pack "release\distribution\chocolatey\pc-remote.nuspec" ` - --outputdirectory "release\distribution\chocolatey" - if ($LASTEXITCODE -ne 0) { - throw "Chocolatey package build failed with exit code $LASTEXITCODE" - } - - - name: Upload build and distribution artifacts + - name: Upload Windows release files uses: actions/upload-artifact@v4 with: - name: pc-remote-${{ env.APP_VERSION }} + name: pc-remote-windows-release path: | release/*.exe release/*.zip - release/SHA256SUMS.txt release/distribution/** if-no-files-found: error + build-macos: + runs-on: ${{ matrix.runner }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + target: aarch64-apple-darwin + architecture: arm64 + - runner: macos-15-intel + target: x86_64-apple-darwin + architecture: x86_64 + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Resolve package version + shell: bash + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + version="${{ inputs.version }}" + version="${version#v}" + else + version="${GITHUB_REF_NAME#v}" + fi + echo "APP_VERSION=$version" >> "$GITHUB_ENV" + echo "PC_REMOTE_VERSION=$version" >> "$GITHUB_ENV" + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + components: rustfmt, clippy + + - name: Run Rust tests and checks + run: | + cargo fmt --all -- --check + cargo test --locked --target ${{ matrix.target }} + cargo clippy --locked --all-targets --target ${{ matrix.target }} -- -D warnings + + - name: Build macOS binary + run: cargo build --locked --release --target ${{ matrix.target }} + + - name: Upload macOS architecture binary + uses: actions/upload-artifact@v4 + with: + name: pc-remote-macos-${{ matrix.architecture }} + path: target/${{ matrix.target }}/release/pc-remote + if-no-files-found: error + + package-macos: + needs: [build-macos] + runs-on: macos-15 + steps: + - name: Resolve package version + shell: bash + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + version="${{ inputs.version }}" + version="${version#v}" + else + version="${GITHUB_REF_NAME#v}" + fi + echo "APP_VERSION=$version" >> "$GITHUB_ENV" + + - name: Download macOS binaries + uses: actions/download-artifact@v4 + with: + pattern: pc-remote-macos-* + path: release/mac-builds + merge-multiple: false + + - name: Package and verify universal macOS app + shell: bash + run: | + set -euo pipefail + version="${APP_VERSION}" + mkdir -p "release/macos/PC Remote.app/Contents/MacOS" + lipo -create \ + "release/mac-builds/pc-remote-macos-arm64/pc-remote" \ + "release/mac-builds/pc-remote-macos-x86_64/pc-remote" \ + -output "release/macos/PC Remote.app/Contents/MacOS/PC Remote" + chmod 755 "release/macos/PC Remote.app/Contents/MacOS/PC Remote" + cat > "release/macos/PC Remote.app/Contents/Info.plist" < + + + CFBundleNamePC Remote + CFBundleDisplayNamePC Remote + CFBundleIdentifiercom.mimaxon1.PCRemote + CFBundleExecutablePC Remote + CFBundlePackageTypeAPPL + CFBundleShortVersionString${version%%-*} + CFBundleVersion${version%%-*} + LSUIElement + LSMinimumSystemVersion12.0 + + PLIST + codesign --force --deep --sign - "release/macos/PC Remote.app" + codesign --verify --deep --strict "release/macos/PC Remote.app" + plutil -lint "release/macos/PC Remote.app/Contents/Info.plist" + lipo -info "release/macos/PC Remote.app/Contents/MacOS/PC Remote" + "release/macos/PC Remote.app/Contents/MacOS/PC Remote" --autostart-status + ditto -c -k --sequesterRsrc --keepParent \ + "release/macos/PC Remote.app" \ + "release/macos/PC-Remote-${version}-macos-universal.zip" + hdiutil create -volname "PC Remote ${version}" \ + -srcfolder "release/macos/PC Remote.app" \ + -ov -format UDZO "release/macos/PC-Remote-${version}-macos-universal.dmg" + hdiutil verify "release/macos/PC-Remote-${version}-macos-universal.dmg" + + - name: Upload universal macOS release files + uses: actions/upload-artifact@v4 + with: + name: pc-remote-macos-universal + path: | + release/macos/*.dmg + release/macos/*.zip + if-no-files-found: error + + publish-release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: [build-windows, package-macos] + runs-on: macos-15 + permissions: + contents: write + steps: + - name: Download Windows release files + uses: actions/download-artifact@v4 + with: + name: pc-remote-windows-release + path: release/windows + + - name: Download universal macOS release files + uses: actions/download-artifact@v4 + with: + name: pc-remote-macos-universal + path: release/macos + + - name: Generate SHA-256 checksums + shell: bash + run: | + set -euo pipefail + cd release + : > SHA256SUMS.txt + for asset in windows/*.exe windows/*.zip macos/*.dmg macos/*.zip; do + (cd "$(dirname "$asset")" && shasum -a 256 "$(basename "$asset")") >> SHA256SUMS.txt + done + cat SHA256SUMS.txt + - name: Publish GitHub release - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v2 with: name: PC Remote ${{ github.ref_name }} generate_release_notes: true files: | - release/*.exe - release/*.zip + release/windows/*.exe + release/windows/*.zip + release/macos/*.dmg + release/macos/*.zip release/SHA256SUMS.txt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 603e5a5..bd490a6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,35 +2,40 @@ name: Tests on: push: - branches: - - main + branches: [main] pull_request: - branches: - - main + branches: [main] workflow_dispatch: -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" +permissions: + contents: read jobs: - pytest: - runs-on: windows-latest - timeout-minutes: 15 - + rust: + name: Rust tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + - os: macos-15 + - os: macos-15-intel steps: - name: Check out repository uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v6 + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable with: - python-version: "3.13" - cache: pip + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --all -- --check - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements.txt + - name: Run Rust tests + run: cargo test --locked - - name: Run test suite - run: python -m pytest + - name: Run Clippy + run: cargo clippy --locked --all-targets -- -D warnings diff --git a/.gitignore b/.gitignore index 97cd61c..7b9d230 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .venv/ venv/ +target/ __pycache__/ *.py[cod] *.pyo diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d1698..4ee0626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ **English** · [Русский](CHANGELOG_RU.md) +## v2.0.0 + +- replaced the desktop Python runtime with a lightweight Rust tray and macOS menu-bar application +- added universal macOS releases for Apple Silicon and Intel, distributed as DMG and ZIP +- kept the phone web controller and legacy settings/PIN migration compatible with existing installations +- applied one serialized per-peer and process-wide PIN attempt limit across login and protected actions +- added Windows and both macOS architectures to Rust CI and tagged release builds + ## v1.4.1 - hardened login rate limiting with `Retry-After` support and clearer controller feedback during PIN lockouts diff --git a/CHANGELOG_RU.md b/CHANGELOG_RU.md index 1b9e58a..5e1b5d0 100644 --- a/CHANGELOG_RU.md +++ b/CHANGELOG_RU.md @@ -2,6 +2,14 @@ [English](CHANGELOG.md) · **Русский** +## v2.0.0 + +- Python runtime приложения заменён на лёгкую Rust-программу в трее Windows и строке меню macOS +- добавлены universal-сборки macOS для Apple Silicon и Intel в форматах DMG и ZIP +- сохранены web-интерфейс телефона и совместимость миграции старых настроек и PIN +- единый последовательный лимит попыток PIN действует для входа и защищённых команд +- CI и сборка релиза проверяют Windows и обе архитектуры macOS + ## v1.4.1 - усилен rate limiting входа: добавлен `Retry-After` и более понятная обратная связь при временной блокировке PIN diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..79edb5d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2722 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[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-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "thiserror 2.0.21", + "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", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pc-remote" +version = "2.0.0" +dependencies = [ + "base64", + "crossbeam-channel", + "hmac", + "image", + "open", + "pbkdf2", + "qrcode", + "rand", + "serde", + "serde_json", + "sha2", + "sysinfo", + "tao", + "tempfile", + "tiny_http", + "tray-icon", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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.17+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[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", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "windows 0.57.0", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[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 = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +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", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "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-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_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_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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[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_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_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_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dccfd33 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "pc-remote" +version = "2.0.0" +edition = "2024" +rust-version = "1.85" +build = "build.rs" + +[dependencies] +base64 = "0.22" +crossbeam-channel = "0.5" +hmac = "0.12" +image = { version = "0.25", default-features = false, features = ["png"] } +open = "5" +pbkdf2 = "0.12" +qrcode = { version = "0.14", default-features = false, features = ["svg"] } +rand = "0.9" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +sysinfo = { version = "0.33", default-features = false, features = ["system"] } +tempfile = "3" +tiny_http = "0.12" +tao = "0.35" +tray-icon = { version = "0.24", default-features = false } +uuid = { version = "1", features = ["v4"] } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Media_Audio", + "Win32_System_Power", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/README.md b/README.md index 1d6df55..814cea4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

PC Remote

- Control your Windows PC from any phone browser.
+ Control a Windows PC or Mac from any phone browser.
No mobile app. No account. No cloud. Run it, scan a QR code, and control the PC over your local network.

@@ -14,7 +14,7 @@

- Download for Windows + Download for Windows or macOS · FAQ

@@ -26,7 +26,7 @@ Latest release

-PC Remote is a lightweight, open-source Windows remote that turns any Android or iPhone browser into a local control panel for your PC. It is designed for people who want a simple local-first alternative to cloud-connected remote-control apps. +PC Remote 2.0 is a lightweight, open-source Windows and macOS remote that turns any Android or iPhone browser into a local control panel for your computer. Its Rust desktop app rests in the system tray or macOS menu bar, without background polling while idle. Use it from the couch, beside a TV, during a presentation, or anywhere else on the same Wi-Fi/LAN. @@ -35,32 +35,32 @@ Use it from the couch, beside a TV, during a presentation, or anywhere else on t - **No phone app required** — open the controller in any modern browser. - **No account or cloud service** — traffic stays on your local network. - **Fast pairing** — run PC Remote and scan the QR code. -- **Ready-made Windows build** — Python is not required for normal use. -- **Useful everyday controls** — apps, windows, volume, media, audio output, and power actions. +- **Ready-made desktop builds** — no Python or mobile app is required for normal use. +- **Useful everyday controls** — apps, windows, volume, media, and available power actions. - **Open source** — inspect it, modify it, or self-host it on your own PC. -If you are looking for an open-source, browser-based Windows remote or a lightweight local alternative to tools such as Unified Remote, PC Remote is built for that use case. +If you are looking for an open-source, browser-based Windows or Mac remote, PC Remote is built for that use case. ## Quick start 1. Open the [latest release](https://github.com/mimaxon1/pc-remote/releases/latest). -2. Download the Windows build. -3. Run `PC Remote.exe`. -4. Open the QR pairing window from the tray icon. +2. Download the installer or portable build for Windows, or the universal macOS app. +3. Run `PC Remote`. +4. Open **Show phone pairing QR code** from the system tray or menu bar. 5. Scan the QR code using a phone connected to the same local network. 6. Start controlling the PC from the browser. -Older releases may provide a portable ZIP only. New tagged releases are configured to publish both a Windows installer and a portable ZIP automatically. +Each 2.0 tagged release publishes a Windows installer, Windows portable ZIP, universal macOS app in a DMG and ZIP, and SHA-256 checksums. ## What you can control - System volume and mute - Media playback controls -- Audio-output device selection +- Current audio-output status - Recent and pinned applications - Launching desktop applications - Opening, minimizing, and closing application windows -- Windows power actions +- Windows shutdown/restart and macOS power actions - Autostart and tray behavior - Light and dark themes - Russian and English web UI @@ -72,7 +72,7 @@ Older releases may provide a portable ZIP only. New tagged releases are configur - Presentation and demo PCs - Local gaming or streaming setups - A second-screen control panel -- Home-lab and self-hosted Windows setups +- Home-lab and self-hosted setups ## Local-first by design @@ -81,17 +81,16 @@ Phone browser | | local Wi-Fi / LAN v -Web controller :8080 - | - v -FastAPI control API :8000 +Single Rust application + +-- web controller :8080 + +-- control API :8000 | +-- audio / media +-- app and window control +-- power actions +-- authentication | -Windows tray companion +Windows tray or macOS menu-bar icon ``` PC Remote does not require an external account or cloud backend. @@ -101,66 +100,56 @@ PC Remote does not require an external account or cloud backend. PC Remote is intended for a trusted local network. Do **not** expose its ports directly to the public internet. - PINs are stored as salted PBKDF2-HMAC-SHA256 hashes -- Session tokens are generated with Python's `secrets` module and kept in memory +- Session tokens come from the operating system's secure random source and stay in memory - CORS is restricted to local origins - Login attempts are rate-limited -- Runtime settings are stored under `%APPDATA%\PC Remote` +- Runtime settings are stored under `%APPDATA%\PC Remote` on Windows or `~/Library/Application Support/PC Remote` on macOS The default transport is HTTP because the application targets local LAN use. For access from outside your home network, use an authenticated VPN or another appropriately secured transport instead of direct port forwarding. See [SECURITY.md](SECURITY.md) for vulnerability reporting. -## Windows packaging +## Downloads and release builds -The project supports two release formats: +Windows releases: -- **Installer** — normal per-user Windows installation with Start Menu shortcut and optional desktop shortcut. +- **Installer** — per-user Windows installation with a Start Menu shortcut. - **Portable ZIP** — extract and run without installation. -Tagged releases (`v*`) are built automatically by GitHub Actions. The release workflow runs tests, builds the PyInstaller bundle, creates the Inno Setup installer, creates the portable ZIP, and publishes SHA-256 checksums. +macOS releases are universal apps for Apple Silicon and Intel, in DMG and ZIP formats. The app is ad-hoc signed but is not notarized yet, so macOS may require an explicit first-open approval. + +Tagged releases (`v*`) build both platforms in GitHub Actions, run the Rust test suite, create the installers and archives, and publish SHA-256 checksums. ## Run from source Requirements: -- Windows 10 or Windows 11 -- Python 3.13+ +- Windows 10/11 or macOS 12+ +- Rust stable - PC and phone connected to the same local network -```powershell -py -3.13 -m venv .venv -& .venv\Scripts\Activate.ps1 -python -m pip install -r requirements.txt -python main.py +```sh +cargo run ``` ## Build from source -```powershell -py -3.13 -m venv .venv -& .venv\Scripts\Activate.ps1 -python -m pip install -r requirements.txt pyinstaller tzdata -python build_release.py +```sh +cargo build --release ``` Portable output: ```text -dist\PC Remote\PC Remote.exe -``` - -To intentionally create a clean first-run build without persisted local settings: - -```powershell -python build_release.py --reset-settings +target\release\pc-remote.exe ``` -To create the installer after the portable build, compile [`installer/PC-Remote.iss`](installer/PC-Remote.iss) with Inno Setup 6. +The Windows installer is built from [`installer/PC-Remote.iss`](installer/PC-Remote.iss) with Inno Setup 6. ## Testing ```powershell -.venv\Scripts\python.exe -m pytest +cargo test --locked ``` Tests also run in GitHub Actions. @@ -169,16 +158,12 @@ Tests also run in GitHub Actions. ```text . -|- main.py FastAPI entry point and startup flow -|- auth.py PIN hashing, pairing, and session tokens -|- gui.py Tray UI and setup/status windows -|- apps.py Application discovery and launch helpers -|- audio.py Windows audio integration +|- src/ Rust API, tray, authentication, and system controls |- web/ Phone web controller -|- tests/ Automated test suite +|- Cargo.toml Rust dependencies and build profile +|- Cargo.lock Locked release dependencies |- installer/ Windows installer definition |- docs/ Documentation and launch material -|- build_release.py Portable Windows build helper ``` ## Documentation diff --git a/README_RU.md b/README_RU.md index 5323cbb..5c68707 100644 --- a/README_RU.md +++ b/README_RU.md @@ -5,7 +5,7 @@

PC Remote

- Управляйте Windows-компьютером из браузера любого телефона.
+ Управляйте Windows-компьютером или Mac из браузера телефона.
Без мобильного приложения, аккаунта и облака. Запустили, отсканировали QR-код — готово.

@@ -14,7 +14,7 @@

- Скачать для Windows + Скачать для Windows или macOS · FAQ

@@ -26,7 +26,7 @@ Latest release

-PC Remote — лёгкий open-source пульт для Windows, который превращает браузер Android или iPhone в локальную панель управления компьютером. Проект рассчитан на тех, кому нужен простой local-first вариант без облачного сервиса и обязательного приложения на телефоне. +PC Remote 2.0 — лёгкий open-source пульт для Windows и macOS, который превращает браузер Android или iPhone в локальную панель управления компьютером. Приложение на Rust спокойно работает в системном трее или строке меню macOS и не опрашивает систему в фоне, когда простаивает. Подходит для дивана и ТВ, презентаций, демонстрационных ПК, стриминга, домашней сети и других сценариев в одном Wi-Fi/LAN. @@ -35,32 +35,32 @@ PC Remote — лёгкий open-source пульт для Windows, который - **На телефон ничего ставить не нужно** — достаточно современного браузера. - **Нет аккаунта и облака** — управление остаётся внутри локальной сети. - **Быстрое подключение** — запуск приложения и QR-код. -- **Есть готовая Windows-сборка** — Python обычному пользователю не нужен. -- **Полезные повседневные функции** — приложения, окна, громкость, мультимедиа, аудиовыход и питание. +- **Есть готовые сборки для компьютера** — обычному пользователю не нужны Python и приложение на телефоне. +- **Полезные повседневные функции** — приложения, окна, громкость, мультимедиа и доступные действия питания. - **Открытый исходный код** — можно проверить, изменить или собрать проект самостоятельно. -Если вы ищете open-source Windows remote, браузерный пульт для ПК или лёгкую локальную альтернативу решениям вроде Unified Remote, PC Remote сделан именно под этот сценарий. +Если вы ищете open-source пульт для Windows или Mac с управлением из браузера, PC Remote сделан именно для этого сценария. ## Быстрый старт 1. Откройте [последний релиз](https://github.com/mimaxon1/pc-remote/releases/latest). -2. Скачайте Windows-сборку. -3. Запустите `PC Remote.exe`. -4. Откройте окно QR-подключения через иконку в трее. +2. Скачайте установщик или portable-сборку для Windows либо universal-сборку для macOS. +3. Запустите `PC Remote`. +4. Нажмите **Show phone pairing QR code** в трее или строке меню. 5. Отсканируйте QR-код телефоном в той же локальной сети. 6. Управляйте компьютером из браузера. -Старые релизы могут содержать только portable ZIP. Новые релизы по тегам настроены на автоматическую публикацию и установщика Windows, и portable ZIP. +Каждый релиз 2.0 по тегу собирает установщик и portable ZIP для Windows, universal-приложение для macOS в DMG и ZIP, а также SHA-256 checksums. ## Что можно управлять - Системной громкостью и mute - Мультимедиа -- Выбором аудиовыхода +- Состоянием текущего аудиовыхода - Недавними и закреплёнными приложениями - Запуском desktop-приложений - Открытием, сворачиванием и закрытием окон -- Действиями питания Windows +- Выключением и перезапуском Windows и доступными действиями питания macOS - Автозапуском и поведением приложения в трее - Светлой и тёмной темой - Русским и английским web-интерфейсом @@ -72,7 +72,7 @@ PC Remote — лёгкий open-source пульт для Windows, который - Презентации и демонстрационные компьютеры - Игровые и стриминговые сетапы - Второй экран как панель управления -- Домашняя лаборатория и self-hosted Windows +- Домашняя лаборатория и self-hosted-системы ## Local-first архитектура @@ -81,17 +81,16 @@ PC Remote — лёгкий open-source пульт для Windows, который | | локальный Wi-Fi / LAN v -Web-контроллер :8080 - | - v -FastAPI API :8000 +Одно приложение Rust + +-- web-контроллер :8080 + +-- API :8000 | +-- аудио / мультимедиа +-- приложения и окна +-- управление питанием +-- аутентификация | -Приложение в системном трее Windows +Трей Windows или строка меню macOS ``` Внешний аккаунт и облачный backend для работы не нужны. @@ -101,66 +100,56 @@ FastAPI API :8000 PC Remote рассчитан на доверенную локальную сеть. **Не публикуйте его порты напрямую в интернет.** - PIN-коды хранятся как salted PBKDF2-HMAC-SHA256 хеши -- Сессионные токены создаются через Python `secrets` и хранятся в памяти +- Сессионные токены создаются генератором случайных чисел ОС и хранятся в памяти - CORS ограничен локальными origin-адресами - Попытки входа ограничиваются rate limit -- Runtime-настройки хранятся в `%APPDATA%\PC Remote` +- Runtime-настройки хранятся в `%APPDATA%\PC Remote` на Windows и `~/Library/Application Support/PC Remote` на macOS По умолчанию используется HTTP, потому что приложение предназначено для локальной сети. Для доступа извне используйте аутентифицированный VPN или другой защищённый транспорт вместо прямого проброса портов. Сообщение об уязвимостях: [SECURITY.md](SECURITY.md). -## Windows-сборки +## Сборки и релизы -Проект поддерживает два формата релиза: +Сборки для Windows: -- **Installer** — обычная установка для текущего пользователя, ярлык в меню «Пуск» и опциональный ярлык на рабочем столе. +- **Installer** — установка для текущего пользователя с ярлыком в меню «Пуск». - **Portable ZIP** — распаковать и запустить без установки. -Релизы по тегам `v*` собираются автоматически через GitHub Actions: запускаются тесты, PyInstaller-сборка, Inno Setup installer, portable ZIP и SHA-256 checksums. +Для macOS собирается universal-приложение для Apple Silicon и Intel в DMG и ZIP. Пакет подписан только локальной подписью и пока не заверен Apple; при первом открытии система может запросить разрешение. + +Релизы по тегам `v*` автоматически собирают обе платформы через GitHub Actions, запускают Rust-тесты, создают установщики и архивы и публикуют SHA-256 checksums. ## Запуск из исходников Требования: -- Windows 10 или Windows 11 -- Python 3.13+ +- Windows 10/11 или macOS 12+ +- Rust stable - Компьютер и телефон в одной локальной сети -```powershell -py -3.13 -m venv .venv -& .venv\Scripts\Activate.ps1 -python -m pip install -r requirements.txt -python main.py +```sh +cargo run ``` ## Сборка из исходников -```powershell -py -3.13 -m venv .venv -& .venv\Scripts\Activate.ps1 -python -m pip install -r requirements.txt pyinstaller tzdata -python build_release.py +```sh +cargo build --release ``` Portable-результат: ```text -dist\PC Remote\PC Remote.exe -``` - -Для чистой first-run сборки без сохранённых локальных настроек: - -```powershell -python build_release.py --reset-settings +target\release\pc-remote.exe ``` -Для создания установщика после portable-сборки скомпилируйте [`installer/PC-Remote.iss`](installer/PC-Remote.iss) через Inno Setup 6. +Установщик Windows собирается из [`installer/PC-Remote.iss`](installer/PC-Remote.iss) через Inno Setup 6. ## Тесты ```powershell -.venv\Scripts\python.exe -m pytest +cargo test --locked ``` Тесты также запускаются через GitHub Actions. @@ -169,16 +158,12 @@ python build_release.py --reset-settings ```text . -|- main.py точка входа FastAPI и запуск приложения -|- auth.py PIN, pairing и сессионные токены -|- gui.py приложение в трее и окна настройки -|- apps.py поиск и запуск приложений -|- audio.py интеграция с аудио Windows +|- src/ Rust API, трей, авторизация и управление системой |- web/ web-интерфейс для телефона -|- tests/ автоматические тесты +|- Cargo.toml зависимости и профиль сборки Rust +|- Cargo.lock зафиксированные зависимости релиза |- installer/ описание Windows-установщика |- docs/ документация и материалы для публикации -|- build_release.py сборка portable-версии ``` ## Документация diff --git a/SECURITY.md b/SECURITY.md index 7937fc8..9ce848a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,17 +39,19 @@ PC Remote is designed for trusted local-network use. The default transport is pl system. Executable extension checks and `shell=False` are not an allowlist. Do not run PC Remote elevated; do not give untrusted people the PIN/QR token. - PIN hashes are salted PBKDF2; tokens are random, short-lived and memory-only. - A four-digit PIN still has low entropy. `/login` has a per-peer attempt budget; - legacy action PINs and PIN changes share a separate global attempt budget. - A malicious peer can temporarily exhaust that legacy budget; valid session - tokens are not blocked by it. This is not a distributed-attack defence. + A four-digit PIN still has low entropy. All PIN checks, including `/login`, + protected actions and PIN changes, share a per-peer attempt limit and a + process-wide failure budget. Checks are serialized so concurrent requests + cannot pass a limit that has already been reached. A malicious peer can + temporarily exhaust the shared budget; valid session tokens are not blocked + by PIN limits. This is not a distributed-attack defence. - API requests carrying a browser Origin must match the explicit local-origin list. CORS alone is not authorization. Requests without Origin remain supported for the local tray/native clients and must satisfy endpoint authorization. Pairing-token issuance is loopback-only; an unauthenticated restart is accepted only from loopback, while an authenticated remote restart remains supported. - Local malware is outside this trust boundary. Uvicorn forwarded-client headers - are disabled deliberately. + Peer limits use the socket's remote address. Local malware is outside this + trust boundary. - Settings and logs are in user-writable AppData. They are not protected from another process running as the same Windows user. Diagnostics emits categories, never settings contents or raw log/error messages. @@ -63,10 +65,11 @@ The current distribution has no guaranteed Authenticode signing trust chain. Installer user scope is intentional: no service or elevated firewall provisioning is required. Do not import old elevated installer branches wholesale. -`build_release.py` preserves settings by default. `--reset-settings` and clean -restart explicitly delete settings; they are not upgrade/migration mechanisms. -Keep backups before intentional resets. An actual clean-install/upgrade/uninstall -claim requires the disposable-Windows smoke matrix, not just Python tests. +The Rust 2.0 build stores settings in the current user's application-data folder +and keeps legacy settings in place during migration. Its explicit clean-start +action clears the saved PIN; it is not an upgrade or migration mechanism. Keep +backups before intentional resets. Installer upgrade and uninstall behavior +should be verified on disposable Windows before making release claims. ## Review evidence diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..c13f74d --- /dev/null +++ b/build.rs @@ -0,0 +1,5 @@ +fn main() { + println!("cargo:rerun-if-changed=web/index.html"); + println!("cargo:rerun-if-changed=web/icons/icon-192.png"); + println!("cargo:rerun-if-env-changed=PC_REMOTE_VERSION"); +} diff --git a/docs/testing.md b/docs/testing.md index 9cc3ed9..3680bd3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2,80 +2,24 @@ **English** · [Русский](testing_RU.md) -## Overview +The Rust test suite runs on Windows, Apple Silicon macOS, and Intel macOS in GitHub Actions. It uses temporary directories for settings tests and does not require a live audio device or a real phone. -The automated test suite lives in the `tests/` directory and is configured -through `pytest.ini`. Tests are designed to run locally and in GitHub Actions -without requiring real audio devices, GUI interaction, or external services. +## Run locally -## Setup +Install stable Rust, then run these commands from the repository root: -```powershell -py -3.13 -m venv .venv -& .venv\Scripts\Activate.ps1 -python -m pip install -r requirements.txt +```sh +cargo fmt --all -- --check +cargo test --locked +cargo clippy --locked --all-targets -- -D warnings ``` -## Run The Full Suite +The PIN protection tests exercise both directions across `/login`, protected API routes, and valid session tokens. Persisted-settings tests use a temporary directory. -```powershell -python -m pytest -``` - -## Run A Specific File - -```powershell -python -m pytest tests/test_auth.py -v -python -m pytest tests/test_main.py -v -python -m pytest tests/test_audio.py -v -``` - -## Run A Specific Test +## Build the desktop app -```powershell -python -m pytest tests/test_auth.py::TestPasswordHash::test_password_hash_creation -v +```sh +cargo build --release --locked ``` -## Run With Coverage - -```powershell -python -m pip install pytest-cov -python -m pytest --cov=. --cov-report=html -``` - -## Test Layout - -- `tests/test_auth.py`: password hashing, tokens, setup, and auth lifecycle -- `tests/test_main.py`: API startup, health checks, restart flow, app endpoints, and caching -- `tests/test_audio.py`: audio helpers, device selection, volume control, and failure handling -- `tests/test_gui.py`: tray fallbacks, pairing helpers, and Tk safety checks -- `tests/test_apps.py`: app discovery, quick launch, pinned apps, and window actions -- `tests/test_net_utils.py`: local IP selection and public host resolution - -## Writing New Tests - -Use small, focused tests that isolate platform-specific behavior with mocks. -The existing suite follows a straightforward `pytest` + `unittest.mock` style. - -```python -from unittest.mock import patch - - -class TestNewFeature: - def test_example(self): - with patch("module.function"): - assert True -``` - -## CI Notes - -- The GitHub Actions workflow runs on `windows-latest` -- The CI entry point is `python -m pytest` -- New features should ship with tests or a short justification for missing coverage - -## Troubleshooting - -- Run commands from the repository root so imports resolve normally -- If a dependency is missing, reinstall from `requirements.txt` -- Audio and GUI tests rely on mocks, so local hardware state should not matter -- If packaging tests are added later, keep them separate from the fast default suite +Release artifacts are assembled in GitHub Actions: a Windows installer and portable ZIP, plus a universal macOS app for Apple Silicon and Intel. diff --git a/docs/testing_RU.md b/docs/testing_RU.md index cf79fa7..8ffa5d8 100644 --- a/docs/testing_RU.md +++ b/docs/testing_RU.md @@ -2,77 +2,24 @@ [English](testing.md) · **Русский** -## Обзор +Набор Rust-тестов запускается в GitHub Actions на Windows, Apple Silicon и Intel Mac. Тесты настроек используют временные каталоги; настоящее аудиоустройство и телефон не нужны. -Автоматические тесты находятся в каталоге `tests/` и настраиваются через `pytest.ini`. Они рассчитаны на локальный запуск и GitHub Actions без реальных аудиоустройств, ручного взаимодействия с GUI и внешних сервисов. +## Локальный запуск -## Подготовка окружения +Установите stable Rust и запустите из корня репозитория: -```powershell -py -3.13 -m venv .venv -& .venv\Scripts\Activate.ps1 -python -m pip install -r requirements.txt +```sh +cargo fmt --all -- --check +cargo test --locked +cargo clippy --locked --all-targets -- -D warnings ``` -## Запуск всех тестов +Тесты защиты PIN проверяют оба направления между `/login`, защищёнными API-маршрутами и действующими токенами. Тесты сохранения настроек используют временный каталог. -```powershell -python -m pytest -``` - -## Запуск отдельного файла - -```powershell -python -m pytest tests/test_auth.py -v -python -m pytest tests/test_main.py -v -python -m pytest tests/test_audio.py -v -``` - -## Запуск отдельного теста +## Сборка приложения -```powershell -python -m pytest tests/test_auth.py::TestPasswordHash::test_password_hash_creation -v +```sh +cargo build --release --locked ``` -## Покрытие кода - -```powershell -python -m pip install pytest-cov -python -m pytest --cov=. --cov-report=html -``` - -## Структура тестов - -- `tests/test_auth.py` — хеширование PIN, токены, первичная настройка и жизненный цикл аутентификации -- `tests/test_main.py` — запуск API, health-check, перезапуск, endpoints приложений и кэширование -- `tests/test_audio.py` — работа с аудио, выбор устройства, громкость и обработка ошибок -- `tests/test_gui.py` — fallback-логика трея, pairing helpers и безопасная работа Tk -- `tests/test_apps.py` — поиск приложений, быстрый запуск, закреплённые приложения и действия с окнами -- `tests/test_net_utils.py` — выбор локального IP и определение доступных LAN-адресов - -## Добавление новых тестов - -Предпочтительны небольшие изолированные тесты. Windows-зависимое поведение лучше закрывать моками, чтобы результат не зависел от конкретного компьютера. - -```python -from unittest.mock import patch - - -class TestNewFeature: - def test_example(self): - with patch("module.function"): - assert True -``` - -## CI - -- GitHub Actions запускается на `windows-latest` -- основная команда CI: `python -m pytest` -- новые функции желательно сопровождать тестами - -## Если тесты не запускаются - -- запускайте команды из корня репозитория -- при ошибках импорта заново установите зависимости из `requirements.txt` -- тесты аудио и GUI используют моки, поэтому наличие конкретного оборудования не требуется -- будущие тесты упаковки лучше держать отдельно от быстрого основного набора +GitHub Actions собирает установщик и portable ZIP для Windows, а также универсальное приложение macOS для Apple Silicon и Intel. diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..6e48b81 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,912 @@ +use std::{ + io::Read, + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::{Arc, atomic::Ordering}, + thread, + time::Duration, +}; + +use qrcode::{QrCode, render::svg}; +use serde_json::{Value, json}; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; + +use crate::{ + config, platform, + state::{AppItem, AppState}, +}; + +const INDEX_HTML: &[u8] = include_bytes!("../web/index.html"); +const FAVICON: &[u8] = include_bytes!("../web/favicon.ico"); +const ICON_192: &[u8] = include_bytes!("../web/icons/icon-192.png"); + +#[derive(Clone, Debug)] +pub struct ApiError { + pub status: u16, + pub message: String, + pub retry_after: Option, +} + +impl ApiError { + fn new(status: u16, message: impl Into) -> Self { + Self { + status, + message: message.into(), + retry_after: None, + } + } + fn limited(seconds: u64) -> Self { + Self { + status: 429, + message: "Too many login attempts. Try again later.".into(), + retry_after: Some(seconds), + } + } +} + +fn bad(message: impl Into) -> ApiError { + ApiError::new(400, message) +} +fn forbidden(message: impl Into) -> ApiError { + ApiError::new(403, message) +} + +fn text<'a>(body: &'a Value, key: &str) -> Option<&'a str> { + body.get(key).and_then(Value::as_str) +} + +fn request_pin(body: &Value, key: &str) -> Result { + let value = text(body, key).unwrap_or("").trim(); + if value.len() != config::PIN_LENGTH || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(bad("PIN must contain exactly four digits")); + } + Ok(value.to_owned()) +} + +fn local_peer(peer: IpAddr) -> bool { + AppState::is_loopback(peer) +} + +fn authenticate(state: &AppState, body: &Value, peer: IpAddr) -> Result<(), ApiError> { + if state.settings_error.load(Ordering::Relaxed) { + return Err(ApiError::new( + 500, + "settings.json is damaged; repair it before continuing", + )); + } + if !state.auth_ready() { + return Err(ApiError::new(409, "PIN setup required")); + } + if let Some(token) = text(body, "token") { + if state.verify_token(token) { + return Ok(()); + } + } + if text(body, "password").is_some() { + let pin = request_pin(body, "password")?; + return verify_pin_for_peer(state, &pin, peer).map_err(|error| { + if error.status == 403 { + ApiError::new(403, "Invalid PIN or expired session") + } else { + error + } + }); + } + if text(body, "token").is_some() { + return Err(forbidden("Invalid PIN or expired session")); + } + Err(forbidden("Active session required")) +} + +fn verify_pin_for_peer(state: &AppState, pin: &str, peer: IpAddr) -> Result<(), ApiError> { + let peer = peer.to_string(); + match state.verify_pin_for_peer(pin, &peer) { + Ok(true) => Ok(()), + Ok(false) => Err(forbidden("Invalid PIN")), + Err(seconds) => Err(ApiError::limited(seconds)), + } +} + +fn token_string(state: &AppState, body: &Value) -> Result { + let value = text(body, "token").unwrap_or_default(); + if value.len() < 32 || value.len() > 256 { + return Err(bad("Invalid token")); + } + if state.verify_token(value) { + return Ok(value.to_owned()); + } + Err(forbidden("Invalid QR token")) +} + +fn app_item(body: &Value) -> Result { + let path = text(body, "path").unwrap_or_default().trim(); + let name = text(body, "name").unwrap_or_default().trim(); + let args = text(body, "args").map(str::to_owned); + let aumid = text(body, "aumid").map(str::to_owned); + if path.is_empty() || path.len() > 4096 { + return Err(bad("Application path is required")); + } + if name.len() > 4096 || args.as_ref().is_some_and(|value| value.len() > 4096) { + return Err(bad("Application field is too long")); + } + Ok(AppItem { + path: path.to_owned(), + name: if name.is_empty() { + std::path::Path::new(path) + .file_stem() + .and_then(|v| v.to_str()) + .unwrap_or("Application") + .to_owned() + } else { + name.to_owned() + }, + args, + aumid, + is_running: false, + }) +} + +fn read_body(request: &mut Request) -> Result { + let mut bytes = Vec::new(); + request + .as_reader() + .take(65_537) + .read_to_end(&mut bytes) + .map_err(|_| ApiError::new(400, "Could not read request body"))?; + if bytes.len() > 65_536 { + return Err(ApiError::new(413, "Request body is too large")); + } + let body = if bytes.is_empty() { + json!({}) + } else { + serde_json::from_slice(&bytes) + .map_err(|_| ApiError::new(400, "Invalid JSON request body"))? + }; + if !body.is_object() { + return Err(ApiError::new(400, "Request body must be a JSON object")); + } + Ok(body) +} + +fn set_pair_token(state: &AppState, token: &str) -> bool { + state.pair_touch(token) +} + +fn route_api( + method: &Method, + path: &str, + body: &Value, + peer: IpAddr, + state: &Arc, +) -> Result { + match (method, path) { + (Method::Get, "/" | "/health") => Ok( + json!({"status":"healthy","version":config::version(),"auth_ready":state.auth_ready()}), + ), + (Method::Get, "/auth_state") => Ok(json!({"requires_password_setup":!state.auth_ready()})), + (Method::Post, "/pair") => { + if !local_peer(peer) { + return Err(forbidden("Local only")); + } + let token = state.issue_token(true); + state.log("QR pairing token issued"); + Ok( + json!({"token":token,"expires_in":config::PAIR_TTL_SECS,"requires_password_setup":!state.auth_ready()}), + ) + } + (Method::Post, "/pair_touch") => { + let token = token_string(state, body)?; + if !set_pair_token(state, &token) { + return Err(forbidden("Invalid QR token")); + } + Ok(json!({"status":"ok"})) + } + (Method::Post, "/pair_complete") => { + let token = token_string(state, body)?; + if !state.pair_complete(&token) { + return Err(forbidden("Invalid QR token")); + } + Ok(json!({"status":"ok"})) + } + (Method::Post, "/pair_status") => { + if !local_peer(peer) { + return Err(forbidden("Local only")); + } + let token = token_string(state, body)?; + let (opened, completed) = state.pair_status(&token); + Ok(json!({"opened":opened,"completed":completed})) + } + (Method::Post, "/login") => { + if !state.auth_ready() { + return Err(ApiError::new(409, "PIN setup is not complete yet")); + } + let pin = request_pin(body, "password")?; + if !state + .verify_pin_for_peer(&pin, &peer.to_string()) + .map_err(ApiError::limited)? + { + state.log(&format!("Login failed: invalid PIN | IP: {peer}")); + return Err(forbidden("Invalid PIN")); + } + state.log("Login: ok"); + Ok(json!({"token":state.issue_token(false),"expires_in":config::SESSION_TTL_SECS})) + } + (Method::Post, "/setup_password") => { + if state.auth_ready() { + return Err(ApiError::new(400, "PIN setup is already complete")); + } + if state.settings_error.load(Ordering::Relaxed) { + return Err(ApiError::new(500, "settings.json needs repair")); + } + let pair_token = token_string(state, body)?; + let pin = request_pin(body, "new_password")?; + state + .set_pin(&pin) + .map_err(|_| ApiError::new(500, "Could not save PIN settings"))?; + state.pair_complete(&pair_token); + let token = state.issue_token(false); + state.log("PIN setup completed"); + Ok(json!({"status":"ok","token":token,"expires_in":config::SESSION_TTL_SECS})) + } + (Method::Post, "/connect" | "/disconnect") => { + authenticate(state, body, peer)?; + let connected = path == "/connect"; + state.phone_connected.store(connected, Ordering::Relaxed); + state.log(if connected { + "Phone connected" + } else { + "Phone disconnected" + }); + Ok(json!({"status":if connected {"connected"} else {"disconnected"}})) + } + (Method::Post, "/logout") => { + authenticate(state, body, peer)?; + if let Some(token) = text(body, "token") { + state.revoke_token(token); + } + state.phone_connected.store(false, Ordering::Relaxed); + Ok(json!({"status":"bye"})) + } + (Method::Post, "/status") => { + authenticate(state, body, peer)?; + Ok( + json!({"volume":platform::volume(state),"muted":state.muted.load(Ordering::Relaxed)}), + ) + } + (Method::Post, "/stats" | "/info") => { + authenticate(state, body, peer)?; + let info = platform::system_info(state); + if path == "/info" { + Ok(info) + } else { + Ok( + json!({"cpu_percent":info["cpu_percent"],"ram_percent":info["ram_percent"],"ram_used_mb":info["ram_used_mb"],"ram_total_mb":info["ram_total_mb"],"uptime_sec":info["uptime_sec"]}), + ) + } + } + (Method::Post, "/volume") => { + authenticate(state, body, peer)?; + let current = platform::volume(state); + let updated = if let Some(value) = body.get("value").and_then(Value::as_f64) { + platform::set_volume(state, value as f32) + .map_err(|_| ApiError::new(500, "Audio operation failed"))? + } else if let Some(action) = text(body, "action") { + let delta = match action { + "up" => 0.05, + "down" => -0.05, + _ => return Err(bad("Unknown volume action")), + }; + platform::set_volume(state, current + delta) + .map_err(|_| ApiError::new(500, "Audio operation failed"))? + } else { + return Err(bad("Provide either value or action")); + }; + state.log(&format!("Volume changed: {:.0}%", updated * 100.0)); + Ok(json!({"volume":updated,"muted":state.muted.load(Ordering::Relaxed)})) + } + (Method::Post, "/mute") => { + authenticate(state, body, peer)?; + let muted = platform::toggle_mute(state) + .map_err(|_| ApiError::new(500, "Audio operation failed"))?; + state.log(if muted { "Mute on" } else { "Mute off" }); + Ok(json!({"muted":muted})) + } + (Method::Post, "/media") => { + authenticate(state, body, peer)?; + platform::send_media(text(body, "action").unwrap_or_default()).map_err(|error| { + if error == "Unknown action" { + bad(error) + } else { + ApiError::new(500, "Media operation failed") + } + })?; + Ok(json!({"status":"ok"})) + } + (Method::Post, "/audio/devices") => { + authenticate(state, body, peer)?; + let device = platform::default_output_device(); + Ok( + json!({"devices":[{"id":"default","name":device["name"],"is_default":true}],"active_output_device":device}), + ) + } + (Method::Post, "/audio/device") => { + authenticate(state, body, peer)?; + if text(body, "device_id") != Some("default") { + return Err(bad("Unknown audio device")); + } + Ok( + json!({"status":"ok","active_output_device":platform::default_output_device(),"devices":[{"id":"default","name":platform::default_output_device()["name"],"is_default":true}]}), + ) + } + (Method::Post, "/apps/recent") => { + authenticate(state, body, peer)?; + Ok(platform::list_apps(state)) + } + (Method::Post, "/apps/open") => { + authenticate(state, body, peer)?; + let item = if text(body, "path").unwrap_or_default().is_empty() { + state + .pinned() + .into_iter() + .next() + .or_else(|| platform::scan_shortcuts().into_iter().next()) + .ok_or_else(|| bad("No recent applications found"))? + } else { + app_item(body)? + }; + platform::launch(&item) + .map_err(|_| ApiError::new(500, "Could not open the application"))?; + state.log(&format!("Application launch requested: {}", item.name)); + let mut response = platform::list_apps(state); + if let Some(object) = response.as_object_mut() { + object.insert("status".into(), json!("ok")); + object.insert( + "launched_app".into(), + serde_json::to_value(item).unwrap_or(Value::Null), + ); + } + Ok(response) + } + (Method::Post, "/apps/window") => { + authenticate(state, body, peer)?; + let item = app_item(body)?; + platform::window_action(&item, text(body, "action").unwrap_or_default()) + .map_err(bad)?; + let mut response = platform::list_apps(state); + if let Some(object) = response.as_object_mut() { + object.insert("status".into(), json!("ok")); + object.insert("action".into(), json!(text(body, "action"))); + object.insert( + "app".into(), + serde_json::to_value(item).unwrap_or(Value::Null), + ); + } + Ok(response) + } + (Method::Post, "/apps/pin") => { + authenticate(state, body, peer)?; + let item = app_item(body)?; + let pinned = state + .pin_app(item.clone()) + .map_err(|_| ApiError::new(500, "Could not save the pinned application"))?; + state.log(&format!("Application pinned: {}", item.name)); + Ok(json!({"status":"ok","pinned_app":item,"pinned_apps":pinned})) + } + (Method::Post, "/apps/unpin") => { + authenticate(state, body, peer)?; + let item = app_item(body)?; + let (removed, pinned) = state + .unpin_app(&item.path, item.args.as_deref(), item.aumid.as_deref()) + .map_err(|_| ApiError::new(500, "Could not update pinned applications"))?; + Ok(json!({"status":"ok","removed":removed,"pinned_apps":pinned})) + } + (Method::Post, "/logs") => { + authenticate(state, body, peer)?; + let limit = body + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(200) + .clamp(1, 1000) as usize; + let since = body.get("since").and_then(Value::as_u64); + let (entries, next_since, reset) = state.logs(since, limit); + Ok( + json!({"logs":entries.into_iter().map(|entry|entry.message).collect::>(),"next_since":next_since,"reset":reset}), + ) + } + (Method::Post, "/shutdown" | "/reboot") => { + authenticate(state, body, peer)?; + platform::power_action(path == "/reboot") + .map_err(|_| ApiError::new(500, "Power operation failed"))?; + state.phone_connected.store(false, Ordering::Relaxed); + Ok(json!({"status":if path == "/reboot" {"rebooting"} else {"bye"}})) + } + (Method::Post, "/change_password") => { + if !state.auth_ready() { + return Err(ApiError::new(409, "Complete first-run setup via QR first")); + } + let current = request_pin(body, "current_password")?; + verify_pin_for_peer(state, ¤t, peer).map_err(|error| { + if error.status == 403 { + forbidden("Invalid current PIN") + } else { + error + } + })?; + if let Some(token) = text(body, "token") { + if !state.verify_token(token) { + return Err(forbidden("Invalid session")); + } + } else if !local_peer(peer) { + return Err(forbidden("Active session required")); + } + let next = request_pin(body, "new_password")?; + state + .change_pin(&next) + .map_err(|_| ApiError::new(500, "Could not save the new PIN"))?; + state.phone_connected.store(false, Ordering::Relaxed); + state.log("PIN changed"); + Ok(json!({"status":"ok"})) + } + (Method::Post, "/app/restart") => { + let credentials_present = body.get("token").is_some_and(|value| !value.is_null()) + || body.get("password").is_some_and(|value| !value.is_null()); + if !local_peer(peer) || credentials_present { + authenticate(state, body, peer)?; + } + let clean = body + .get("clean_start") + .and_then(Value::as_bool) + .unwrap_or(false); + if clean { + state + .clear_pin() + .map_err(|_| ApiError::new(500, "Could not reset saved settings"))?; + } + state.restart_requested.store(true, Ordering::Relaxed); + state.log(if clean { + "Clean restart requested" + } else { + "Restart requested" + }); + thread::spawn(|| { + thread::sleep(Duration::from_millis(500)); + if let Ok(executable) = std::env::current_exe() { + if std::process::Command::new(executable).spawn().is_ok() { + std::process::exit(0); + } + } + }); + Ok(json!({"status":"restarting","clean_start":clean})) + } + _ => Err(ApiError::new(404, "Not found")), + } +} + +fn allowed_origin(origin: Option<&str>) -> bool { + let Some(origin) = origin else { + return true; + }; + if origin == "null" { + return false; + } + for port in [ + config::port("PC_REMOTE_WEB_PORT", config::WEB_PORT_DEFAULT), + config::port("PC_REMOTE_API_PORT", config::API_PORT_DEFAULT), + ] { + if [ + format!("http://localhost:{port}"), + format!("http://127.0.0.1:{port}"), + format!("http://[::1]:{port}"), + format!("http://{}:{port}", platform::local_ip()), + ] + .iter() + .any(|allowed| origin == allowed) + { + return true; + } + } + false +} + +fn runtime_config() -> Value { + json!({"appVersion":env!("CARGO_PKG_VERSION"),"apiPort":config::port("PC_REMOTE_API_PORT",config::API_PORT_DEFAULT),"webPort":config::port("PC_REMOTE_WEB_PORT",config::WEB_PORT_DEFAULT),"pinLength":config::PIN_LENGTH,"pollIntervalMs":4000,"offlineRetryMinMs":4000,"offlineRetryMaxMs":30000,"pairPollIntervalMs":700}) +} + +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn qr_page(state: &AppState) -> Result, ApiError> { + let token = state.issue_token(true); + let host = platform::local_ip(); + let uri = format!( + "http://{host}:{}/?token={token}", + config::port("PC_REMOTE_WEB_PORT", config::WEB_PORT_DEFAULT) + ); + let svg = QrCode::new(uri.as_bytes()) + .map_err(|_| ApiError::new(500, "Could not create pairing QR"))? + .render::() + .min_dimensions(264, 264) + .build(); + let instructions = if state.auth_ready() { + "Scan with a phone on the same Wi-Fi or LAN. The QR code signs the phone in once." + } else { + "First start: scan the code, then choose a four-digit PIN on your phone." + }; + Ok(format!("PC Remote pairing

PC Remote

{svg}

{}

Open the phone control panel

Pairing address: {}

",instructions,config::port("PC_REMOTE_WEB_PORT",config::WEB_PORT_DEFAULT),html_escape(&uri)).into_bytes()) +} + +fn respond(request: Request, state: &Arc, web_server: bool) { + let peer = request + .remote_addr() + .map(SocketAddr::ip) + .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + let origin = request + .headers() + .iter() + .find(|header| header.field.equiv("Origin")) + .map(|header| header.value.as_str().to_owned()); + let origin_ok = allowed_origin(origin.as_deref()); + if !origin_ok { + let _ = json_response( + request, + ApiError::new(403, "Origin not allowed"), + origin.as_deref(), + ); + return; + } + if request.method() == &Method::Options { + let mut response = Response::empty(StatusCode(204)); + if let Some(value) = origin { + response = response + .with_header(Header::from_bytes("Access-Control-Allow-Origin", value).unwrap()); + } + response = response.with_header( + Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS").unwrap(), + ); + response = response.with_header( + Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap(), + ); + response = + response.with_header(Header::from_bytes("Access-Control-Max-Age", "600").unwrap()); + let _ = request.respond(response); + return; + } + let method = request.method().clone(); + let request_path = request.url().split('?').next().unwrap_or("/").to_owned(); + if web_server && method == Method::Get { + if request_path == "/runtime-config.js" { + let config = format!( + "window.__PC_REMOTE_CONFIG__ = Object.freeze({});", + runtime_config() + ) + .into_bytes(); + let _ = static_response( + request, + &config, + "application/javascript; charset=utf-8", + origin.as_deref(), + ); + } else if request_path == "/_tray/qr" { + if !local_peer(peer) { + let _ = json_response(request, forbidden("Local only"), origin.as_deref()); + return; + } + match qr_page(state) { + Ok(page) => { + state.log("QR pairing page opened"); + let _ = static_response( + request, + &page, + "text/html; charset=utf-8", + origin.as_deref(), + ); + } + Err(error) => { + let _ = json_response(request, error, origin.as_deref()); + } + } + } else if request_path == "/" || request_path == "/index.html" { + let _ = static_response( + request, + INDEX_HTML, + "text/html; charset=utf-8", + origin.as_deref(), + ); + } else if request_path == "/favicon.ico" { + let _ = static_response(request, FAVICON, "image/x-icon", origin.as_deref()); + } else if request_path == "/icons/icon-192.png" || request_path == "/web/icons/icon-192.png" + { + let _ = static_response(request, ICON_192, "image/png", origin.as_deref()); + } else { + let _ = json_response(request, ApiError::new(404, "Not found"), origin.as_deref()); + } + return; + } + if web_server + && method != Method::Get + && !request_path.starts_with("/health") + && !request_path.starts_with("/auth_state") + { + let _ = json_response(request, ApiError::new(404, "Not found"), origin.as_deref()); + return; + } + let mut request = request; + let body = match read_body(&mut request) { + Ok(value) => value, + Err(error) => { + let _ = json_response(request, error, origin.as_deref()); + return; + } + }; + match route_api(&method, &request_path, &body, peer, state) { + Ok(value) => { + let _ = json_response_value(request, StatusCode(200), value, origin.as_deref(), None); + } + Err(error) => { + let _ = json_response(request, error, origin.as_deref()); + } + } +} + +fn cors( + response: Response>>, + origin: Option<&str>, +) -> Response>> { + let mut response = response; + if let Some(value) = origin { + if let Ok(header) = Header::from_bytes("Access-Control-Allow-Origin", value) { + response = response.with_header(header); + } + } + if let Ok(header) = Header::from_bytes("Vary", "Origin") { + response = response.with_header(header); + } + response +} + +fn json_response( + request: Request, + error: ApiError, + origin: Option<&str>, +) -> Result<(), std::io::Error> { + let status = StatusCode(error.status); + let mut response = Response::from_data( + serde_json::to_vec( + &json!({"detail":error.message,"retry_after_seconds":error.retry_after}), + ) + .unwrap_or_default(), + ) + .with_status_code(status); + response + .add_header(Header::from_bytes("Content-Type", "application/json; charset=utf-8").unwrap()); + if let Some(seconds) = error.retry_after { + response.add_header(Header::from_bytes("Retry-After", seconds.to_string()).unwrap()); + } + request.respond(cors(response, origin)).map(|_| ()) +} + +fn json_response_value( + request: Request, + status: StatusCode, + value: Value, + origin: Option<&str>, + retry_after: Option, +) -> Result<(), std::io::Error> { + let mut response = Response::from_data(serde_json::to_vec(&value).unwrap_or_default()) + .with_status_code(status); + response + .add_header(Header::from_bytes("Content-Type", "application/json; charset=utf-8").unwrap()); + if let Some(seconds) = retry_after { + response.add_header(Header::from_bytes("Retry-After", seconds.to_string()).unwrap()); + } + request.respond(cors(response, origin)).map(|_| ()) +} + +fn static_response( + request: Request, + data: &[u8], + mime: &str, + origin: Option<&str>, +) -> Result<(), std::io::Error> { + let response = Response::from_data(data.to_vec()) + .with_status_code(StatusCode(200)) + .with_header(Header::from_bytes("Content-Type", mime).unwrap()) + .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap()); + request.respond(cors(response, origin)).map(|_| ()) +} + +fn serve(server: Server, state: Arc, web: bool, workers: usize) { + let (sender, receiver) = crossbeam_channel::bounded::(32); + for index in 0..workers { + let jobs = receiver.clone(); + let state = Arc::clone(&state); + thread::Builder::new() + .name(format!("http-worker-{index}")) + .spawn(move || { + while let Ok(request) = jobs.recv() { + respond(request, &state, web); + } + }) + .expect("could not start HTTP worker"); + } + loop { + match server.recv() { + Ok(request) => { + if sender.send(request).is_err() { + break; + } + } + Err(_) => thread::sleep(Duration::from_millis(20)), + } + } +} + +pub fn start_servers(state: Arc, api_port: u16, web_port: u16) -> Result<(), String> { + if api_port == web_port { + return Err("The web and API ports must be different".into()); + } + let api = Server::http((Ipv4Addr::UNSPECIFIED, api_port)) + .map_err(|error| format!("API could not start on port {api_port}: {error}"))?; + let web = Server::http((Ipv4Addr::UNSPECIFIED, web_port)) + .map_err(|error| format!("Web controller could not start on port {web_port}: {error}"))?; + let api_state = Arc::clone(&state); + thread::Builder::new() + .name("api-http-listener".into()) + .spawn(move || serve(api, api_state, false, 4)) + .map_err(|e| e.to_string())?; + let web_state = Arc::clone(&state); + thread::Builder::new() + .name("web-http-listener".into()) + .spawn(move || serve(web, web_state, true, 2)) + .map_err(|e| e.to_string())?; + state.log(&format!( + "Web controller available at http://{}:{web_port}", + platform::local_ip() + )); + state.log(&format!("API listening on port {api_port}")); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[test] + fn browser_origin_is_limited_to_local_origins() { + assert!(allowed_origin(None)); + assert!(allowed_origin(Some("http://localhost:8080"))); + assert!(!allowed_origin(Some("null"))); + assert!(!allowed_origin(Some("https://untrusted.invalid"))); + } + + #[test] + fn pairing_page_cannot_issue_a_qr_token_to_another_lan_device() { + assert!(local_peer(IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(local_peer(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST))); + assert!(!local_peer(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)))); + } + + #[test] + fn rate_limited_pin_cannot_bypass_through_action_endpoint() { + let temp = tempfile::tempdir().unwrap(); + let state = Arc::new(AppState::new_in(temp.path().to_path_buf())); + state.set_pin("1234").unwrap(); + let remote = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5)); + for attempt in 0..config::LOGIN_LIMIT { + let result = route_api( + &Method::Post, + "/login", + &json!({"password":"0000"}), + remote, + &state, + ); + if attempt + 1 < config::LOGIN_LIMIT { + assert_eq!(result.unwrap_err().status, 403); + } else { + assert_eq!(result.unwrap_err().status, 429); + } + } + let error = route_api( + &Method::Post, + "/status", + &json!({"password":"1234"}), + remote, + &state, + ) + .unwrap_err(); + assert_eq!(error.status, 429); + let token = state.issue_token(false); + assert!( + route_api( + &Method::Post, + "/status", + &json!({"token":token}), + remote, + &state + ) + .is_ok() + ); + } + + #[test] + fn action_pin_failures_also_block_login_and_password_changes_for_that_peer() { + let temp = tempfile::tempdir().unwrap(); + let state = Arc::new(AppState::new_in(temp.path().to_path_buf())); + state.set_pin("1234").unwrap(); + let remote = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5)); + for _ in 0..config::LOGIN_LIMIT { + let result = route_api( + &Method::Post, + "/status", + &json!({"password":"0000"}), + remote, + &state, + ); + assert!(result.is_err()); + } + let login = route_api( + &Method::Post, + "/login", + &json!({"password":"1234"}), + remote, + &state, + ) + .unwrap_err(); + assert_eq!(login.status, 429); + let session = state.issue_token(false); + let change = route_api( + &Method::Post, + "/change_password", + &json!({"current_password":"1234","new_password":"5678","token":session}), + remote, + &state, + ) + .unwrap_err(); + assert_eq!(change.status, 429); + state.reset_login_failures("legacy-pin-actions"); + let other_peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 6)); + assert!( + route_api( + &Method::Post, + "/status", + &json!({"password":"1234"}), + other_peer, + &state, + ) + .is_ok() + ); + } + + #[test] + fn pairing_token_continues_to_authenticate_existing_phone_client() { + let temp = tempfile::tempdir().unwrap(); + let state = Arc::new(AppState::new_in(temp.path().to_path_buf())); + let token = state.issue_token(true); + let peer = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5)); + let response = route_api( + &Method::Post, + "/setup_password", + &json!({"token":token,"new_password":"0456"}), + peer, + &state, + ) + .unwrap(); + assert_eq!(response["status"], "ok"); + assert!(state.auth_ready()); + let response = route_api( + &Method::Post, + "/connect", + &json!({"token":token}), + peer, + &state, + ) + .unwrap(); + assert_eq!(response["status"], "connected"); + assert!(state.phone_connected.load(Ordering::Relaxed)); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..b7ba563 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,47 @@ +use std::{env, path::PathBuf}; + +pub const APP_NAME: &str = "PC Remote"; +pub const APP_VERSION: &str = match option_env!("PC_REMOTE_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; +pub const PIN_LENGTH: usize = 4; +pub const API_PORT_DEFAULT: u16 = 8000; +pub const WEB_PORT_DEFAULT: u16 = 8080; +pub const SESSION_TTL_SECS: u64 = 3600; +pub const PAIR_TTL_SECS: u64 = SESSION_TTL_SECS; +pub const PBKDF2_ITERATIONS: u32 = 200_000; +pub const LOGIN_LIMIT: usize = 5; +pub const LOGIN_WINDOW_SECS: u64 = 60; +pub const LOGIN_BLOCK_SECS: u64 = 900; + +pub fn port(name: &str, default: u16) -> u16 { + env::var(name) + .ok() + .and_then(|raw| raw.parse().ok()) + .filter(|p: &u16| *p != 0) + .unwrap_or(default) +} + +pub fn version() -> &'static str { + APP_VERSION +} + +pub fn data_dir() -> PathBuf { + #[cfg(target_os = "windows")] + if let Some(base) = env::var_os("APPDATA") { + return PathBuf::from(base).join(APP_NAME); + } + #[cfg(target_os = "macos")] + if let Some(home) = env::var_os("HOME") { + return PathBuf::from(home) + .join("Library/Application Support") + .join(APP_NAME); + } + if let Some(base) = env::var_os("XDG_CONFIG_HOME") { + return PathBuf::from(base).join(APP_NAME); + } + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(".pc-remote") +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9a27781 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,88 @@ +#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] + +mod api; +mod config; +mod platform; +mod state; +mod tray; + +#[cfg(target_os = "macos")] +use std::process::Command; +use std::sync::Arc; + +fn handle_cli() -> bool { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("--install-autostart") => platform::set_autostart(true) + .map(|_| println!("Autostart enabled")) + .unwrap_or_else(|error| eprintln!("{error}")), + Some("--remove-autostart") => platform::set_autostart(false) + .map(|_| println!("Autostart disabled")) + .unwrap_or_else(|error| eprintln!("{error}")), + Some("--autostart-status") => { + println!( + "{}", + if platform::autostart_enabled() { + "enabled" + } else { + "disabled" + } + ); + return true; + } + _ => return false, + } + true +} + +fn show_start_error(message: &str) { + eprintln!("{message}"); + #[cfg(target_os = "windows")] + { + use std::os::windows::ffi::OsStrExt; + let title: Vec = std::ffi::OsStr::new("PC Remote could not start") + .encode_wide() + .chain(Some(0)) + .collect(); + let text: Vec = std::ffi::OsStr::new(message) + .encode_wide() + .chain(Some(0)) + .collect(); + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::MessageBoxW( + std::ptr::null_mut(), + text.as_ptr(), + title.as_ptr(), + 0x10, + ); + } + } + #[cfg(target_os = "macos")] + { + let _ = Command::new("osascript") + .args([ + "-e", + &format!( + "display alert \"PC Remote could not start\" message \"{}\"", + message.replace('"', "'").replace('\n', " ") + ), + ]) + .spawn(); + } +} + +fn main() { + if handle_cli() { + return; + } + let state = Arc::new(state::AppState::new()); + let api_port = config::port("PC_REMOTE_API_PORT", config::API_PORT_DEFAULT); + let web_port = config::port("PC_REMOTE_WEB_PORT", config::WEB_PORT_DEFAULT); + if let Err(error) = api::start_servers(Arc::clone(&state), api_port, web_port) { + show_start_error(&error); + return; + } + if let Err(error) = tray::run(Arc::clone(&state)) { + show_start_error(&error); + } +} diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..a62afb7 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,603 @@ +use std::{ + env, fs, + net::{Ipv4Addr, UdpSocket}, + path::{Path, PathBuf}, + process::Command, + thread, + time::Duration, +}; + +use serde_json::{Value, json}; +use sysinfo::System; + +use crate::state::{AppItem, AppState}; + +pub fn local_ip() -> Ipv4Addr { + UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) + .and_then(|socket| { + socket.connect((Ipv4Addr::new(8, 8, 8, 8), 80))?; + match socket.local_addr()?.ip() { + std::net::IpAddr::V4(ip) if !ip.is_loopback() => Ok(ip), + _ => Err(std::io::Error::other("no active LAN route")), + } + }) + .unwrap_or(Ipv4Addr::LOCALHOST) +} + +pub fn default_output_device() -> Value { + #[cfg(target_os = "macos")] + let name = "System default audio output"; + #[cfg(target_os = "windows")] + let name = "System default audio output"; + json!({ "id": "default", "name": name }) +} + +pub fn volume(state: &AppState) -> f32 { + #[cfg(target_os = "windows")] + unsafe { + let mut levels = 0u32; + if windows_sys::Win32::Media::Audio::waveOutGetVolume(std::ptr::null_mut(), &mut levels) + == 0 + { + let left = (levels & 0xFFFF) as f32 / 65535.0; + let right = (levels >> 16) as f32 / 65535.0; + if let Ok(mut current) = state.current_volume.lock() { + *current = (left + right) / 2.0; + } + } + } + #[cfg(target_os = "macos")] + if let Ok(output) = Command::new("osascript") + .args(["-e", "output volume of (get volume settings)"]) + .output() + { + if output.status.success() { + if let Ok(raw) = String::from_utf8(output.stdout) { + if let Ok(value) = raw.trim().parse::() { + if let Ok(mut current) = state.current_volume.lock() { + *current = value.clamp(0.0, 100.0) / 100.0; + } + } + } + } + } + state + .current_volume + .lock() + .map(|volume| *volume) + .unwrap_or(0.5) +} + +pub fn set_volume(state: &AppState, value: f32) -> Result { + let value = value.clamp(0.0, 1.0); + #[cfg(target_os = "windows")] + { + let word = (value * 65535.0).round() as u32; + let levels = word | (word << 16); + let result = unsafe { + windows_sys::Win32::Media::Audio::waveOutSetVolume(std::ptr::null_mut(), levels) + }; + if result != 0 { + return Err(format!("Windows audio operation failed ({result})")); + } + } + #[cfg(target_os = "macos")] + { + let percent = (value * 100.0).round().to_string(); + let output = Command::new("osascript") + .args(["-e", &format!("set volume output volume {percent}")]) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err("macOS could not change the output volume".into()); + } + } + if let Ok(mut current) = state.current_volume.lock() { + *current = value; + } + Ok(value) +} + +pub fn set_mute(state: &AppState, muted: bool) -> Result { + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{VK_VOLUME_MUTE, keybd_event}; + let current = state.muted.load(std::sync::atomic::Ordering::Relaxed); + if current != muted { + unsafe { + keybd_event(VK_VOLUME_MUTE as u8, 0, 0, 0); + } + } + } + #[cfg(target_os = "macos")] + { + let script = format!( + "set volume output muted {}", + if muted { "true" } else { "false" } + ); + let output = Command::new("osascript") + .args(["-e", script.as_str()]) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err("macOS could not change mute state".into()); + } + } + state + .muted + .store(muted, std::sync::atomic::Ordering::Relaxed); + Ok(muted) +} + +pub fn toggle_mute(state: &AppState) -> Result { + let next = !state.muted.load(std::sync::atomic::Ordering::Relaxed); + set_mute(state, next) +} + +pub fn send_media(action: &str) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + VK_MEDIA_NEXT_TRACK, VK_MEDIA_PLAY_PAUSE, VK_MEDIA_PREV_TRACK, VK_MEDIA_STOP, + keybd_event, + }; + let key = match action { + "play_pause" => VK_MEDIA_PLAY_PAUSE as u8, + "next" => VK_MEDIA_NEXT_TRACK as u8, + "prev" => VK_MEDIA_PREV_TRACK as u8, + "stop" => VK_MEDIA_STOP as u8, + _ => return Err("Unknown action".into()), + }; + unsafe { + keybd_event(key, 0, 0, 0); + } + } + #[cfg(target_os = "macos")] + { + let script = match action { + "play_pause" => "tell application \"Music\" to playpause", + "next" => "tell application \"Music\" to next track", + "prev" => "tell application \"Music\" to previous track", + "stop" => "tell application \"Music\" to stop", + _ => return Err("Unknown action".into()), + }; + let result = Command::new("osascript") + .args(["-e", script]) + .output() + .map_err(|error| error.to_string())?; + if !result.status.success() { + return Err("macOS media control was denied or unavailable".into()); + } + } + Ok(()) +} + +fn app_name(path: &Path) -> String { + path.file_stem() + .or_else(|| path.file_name()) + .and_then(|name| name.to_str()) + .unwrap_or("Application") + .to_owned() +} + +#[cfg(target_os = "windows")] +fn recent_apps() -> Vec { + let mut dirs = Vec::new(); + if let Some(appdata) = env::var_os("APPDATA") { + dirs.push(PathBuf::from(appdata).join("Microsoft/Windows/Recent")); + } + if let Some(appdata) = env::var_os("APPDATA") { + dirs.push(PathBuf::from(appdata).join("Microsoft/Windows/Start Menu/Programs")); + } + if let Some(programdata) = env::var_os("ProgramData") { + dirs.push(PathBuf::from(programdata).join("Microsoft/Windows/Start Menu/Programs")); + } + let mut found = Vec::new(); + for dir in dirs { + let Ok(entries) = fs::read_dir(dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("lnk")) + { + let modified = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .unwrap_or(std::time::UNIX_EPOCH); + found.push(( + modified, + AppItem { + name: app_name(&path), + path: path.to_string_lossy().into_owned(), + args: None, + aumid: None, + is_running: false, + }, + )); + } + } + } + found.sort_by_key(|item| std::cmp::Reverse(item.0)); + let mut unique = std::collections::HashSet::new(); + found + .into_iter() + .filter(|(_, item)| unique.insert(item.path.to_lowercase())) + .map(|(_, item)| item) + .take(30) + .collect() +} + +#[cfg(target_os = "macos")] +fn recent_apps() -> Vec { + let mut found = Vec::new(); + for directory in [ + PathBuf::from("/Applications"), + PathBuf::from("/System/Applications"), + ] { + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|extension| extension == "app") { + found.push(AppItem { + name: app_name(&path), + path: path.to_string_lossy().into_owned(), + args: None, + aumid: None, + is_running: false, + }); + } + } + } + found.sort_by_cached_key(|item| item.name.to_lowercase()); + found.truncate(30); + found +} + +pub fn list_apps(state: &AppState) -> Value { + let mut apps = recent_apps(); + let pinned = state.pinned(); + let mut seen: std::collections::HashSet = + pinned.iter().map(|app| app.path.to_lowercase()).collect(); + apps.retain(|app| seen.insert(app.path.to_lowercase())); + apps.truncate(30); + json!({ "apps": apps, "pinned_apps": pinned }) +} + +fn split_arguments(input: Option<&str>) -> Vec { + let Some(raw) = input else { return Vec::new() }; + let mut parts = Vec::new(); + let mut value = String::new(); + let mut quoted = false; + for char in raw.chars() { + match char { + '"' => quoted = !quoted, + c if c.is_whitespace() && !quoted => { + if !value.is_empty() { + parts.push(std::mem::take(&mut value)); + } + } + c => value.push(c), + } + } + if !value.is_empty() { + parts.push(value); + } + parts +} + +pub fn launch(item: &AppItem) -> Result<(), String> { + let path = PathBuf::from(&item.path); + if path.as_os_str().is_empty() || item.path.chars().any(char::is_control) { + return Err("Invalid application path".into()); + } + #[cfg(target_os = "windows")] + { + use std::os::windows::ffi::OsStrExt; + if item.aumid.as_ref().is_some_and(|id| !id.is_empty()) { + let target = format!( + "shell:AppsFolder\\{}", + item.aumid.as_deref().unwrap_or_default() + ); + let wide: Vec = std::ffi::OsStr::new(&target) + .encode_wide() + .chain(Some(0)) + .collect(); + let result = unsafe { + windows_sys::Win32::UI::Shell::ShellExecuteW( + std::ptr::null_mut(), + std::ptr::null(), + wide.as_ptr(), + std::ptr::null(), + std::ptr::null(), + windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL, + ) + }; + if result as isize <= 32 { + return Err("Windows could not open the application".into()); + } + return Ok(()); + } + let extension = path.extension().unwrap_or_default().to_string_lossy(); + if extension.eq_ignore_ascii_case("lnk") || extension.eq_ignore_ascii_case("url") { + use std::os::windows::ffi::OsStrExt; + let target: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let result = unsafe { + windows_sys::Win32::UI::Shell::ShellExecuteW( + std::ptr::null_mut(), + std::ptr::null(), + target.as_ptr(), + std::ptr::null(), + std::ptr::null(), + windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL, + ) + }; + if result as isize <= 32 { + return Err("Windows could not open the shortcut".into()); + } + return Ok(()); + } + Command::new(&path) + .args(split_arguments(item.args.as_deref())) + .spawn() + .map_err(|error| error.to_string())?; + } + #[cfg(target_os = "macos")] + { + let mut command = Command::new("open"); + command + .arg("-a") + .arg(&path) + .args(split_arguments(item.args.as_deref())); + command.spawn().map_err(|error| error.to_string())?; + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn applescript_quote(value: &str) -> String { + value + .chars() + .flat_map(|ch| match ch { + '\\' => "\\\\".chars().collect::>(), + '"' => "\\\"".chars().collect(), + '\n' | '\r' => " ".chars().collect(), + c => vec![c], + }) + .collect() +} + +pub fn window_action(item: &AppItem, action: &str) -> Result<(), String> { + if action != "minimize" && action != "close" { + return Err("Unsupported window action".into()); + } + #[cfg(target_os = "macos")] + { + let app = applescript_quote(&item.name); + let verb = if action == "close" { + "close" + } else { + "set miniaturized of front window to true" + }; + let script = if action == "close" { + format!( + "tell application \"System Events\" to tell process \"{app}\" to {verb} front window" + ) + } else { + format!("tell application \"System Events\" to tell process \"{app}\" to {verb}") + }; + let result = Command::new("osascript") + .args(["-e", &script]) + .output() + .map_err(|error| error.to_string())?; + if !result.status.success() { + return Err( + "Application window not found or Accessibility access is unavailable".into(), + ); + } + return Ok(()); + } + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Foundation::{HWND, LPARAM}; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetWindowThreadProcessId, IsWindowVisible, SC_MINIMIZE, SendMessageW, + WM_CLOSE, WM_SYSCOMMAND, + }; + use windows_sys::core::BOOL; + let mut system = System::new(); + system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + struct Search<'a> { + system: &'a System, + name: String, + minimize: bool, + matched: bool, + } + unsafe extern "system" fn find(window: HWND, data: LPARAM) -> BOOL { + let search = unsafe { &mut *(data as *mut Search<'_>) }; + if unsafe { IsWindowVisible(window) } == 0 { + return 1; + } + let mut pid = 0; + unsafe { + GetWindowThreadProcessId(window, &mut pid); + } + if let Some(process) = search.system.process(sysinfo::Pid::from_u32(pid)) { + let actual = process.name().to_string_lossy(); + let expected = format!("{}.exe", search.name); + if actual.eq_ignore_ascii_case(&expected) + || actual.eq_ignore_ascii_case(&search.name) + { + let (message, wparam) = if !search.minimize { + (WM_CLOSE, 0) + } else { + (WM_SYSCOMMAND, SC_MINIMIZE as usize) + }; + unsafe { + SendMessageW(window, message, wparam, 0); + } + search.matched = true; + return 0; + } + } + 1 + } + let mut search = Search { + system: &system, + name: item.name.clone(), + minimize: action == "minimize", + matched: false, + }; + unsafe { + EnumWindows(Some(find), &mut search as *mut Search as LPARAM); + } + if !search.matched { + return Err("Application window not found".into()); + } + return Ok(()); + } + #[allow(unreachable_code)] + Err("Window control is unavailable".into()) +} + +pub fn system_info(state: &AppState) -> Value { + let mut system = System::new(); + system.refresh_memory(); + system.refresh_cpu_usage(); + thread::sleep(Duration::from_millis(80)); + system.refresh_cpu_usage(); + let total = system.total_memory(); + let used = system.used_memory(); + let ip = local_ip(); + let interfaces = if ip.is_loopback() { + vec![] + } else { + vec![ + json!({"name":"Network","ip":ip.to_string(),"speed_mbps":0,"kind":"other","is_virtualbox_host_only":false,"is_preferred_candidate":true}), + ] + }; + let uptime = System::uptime(); + let network = json!({"hostname": System::host_name().unwrap_or_else(|| "PC Remote".into()), "current_ip":ip.to_string(), "primary_interface":interfaces.first(), "interfaces":interfaces}); + let audio = json!({ "active_output_device": default_output_device() }); + let battery = battery_info(); + json!({ + "cpu_percent": system.global_cpu_usage(), + "ram_percent": if total > 0 { used as f32 * 100.0 / total as f32 } else { 0.0 }, + "ram_used_mb": used / 1024, + "ram_total_mb": total / 1024, + "uptime_sec": uptime, + "battery": battery, + "network": network, + "audio": audio, + "volume": volume(state), + "muted": state.muted.load(std::sync::atomic::Ordering::Relaxed), + }) +} + +#[cfg(target_os = "windows")] +fn battery_info() -> Value { + use windows_sys::Win32::System::Power::{GetSystemPowerStatus, SYSTEM_POWER_STATUS}; + let mut status = SYSTEM_POWER_STATUS::default(); + if unsafe { GetSystemPowerStatus(&mut status) } == 0 + || status.BatteryFlag == 128 + || status.BatteryLifePercent == 255 + { + return json!({"present":false,"percent":null,"power_plugged":null,"secs_left":null}); + } + json!({"present":true,"percent":status.BatteryLifePercent,"power_plugged":status.ACLineStatus == 1,"secs_left":if status.BatteryLifeTime == u32::MAX { Value::Null } else { json!(status.BatteryLifeTime) }}) +} + +#[cfg(target_os = "macos")] +fn battery_info() -> Value { + if let Ok(output) = Command::new("pmset").args(["-g", "batt"]).output() { + if output.status.success() { + let text = String::from_utf8_lossy(&output.stdout); + if let Some(percentage) = text + .split('%') + .next_back() + .and_then(|_| text.split('%').next()) + .and_then(|value| value.trim().rsplit(' ').next()) + .and_then(|value| value.parse::().ok()) + { + return json!({"present":true,"percent":percentage,"power_plugged":text.contains("AC Power"),"secs_left":null}); + } + } + } + json!({"present":false,"percent":null,"power_plugged":null,"secs_left":null}) +} + +pub fn power_action(reboot: bool) -> Result<(), String> { + #[cfg(target_os = "windows")] + let result = if reboot { + Command::new("shutdown").args(["/r", "/t", "0"]).spawn() + } else { + Command::new("shutdown").args(["/s", "/t", "0"]).spawn() + }; + #[cfg(target_os = "macos")] + let result = Command::new("osascript") + .args([ + "-e", + if reboot { + "tell application \"System Events\" to restart" + } else { + "tell application \"System Events\" to shut down" + }, + ]) + .spawn(); + result.map(|_| ()).map_err(|error| error.to_string()) +} + +#[cfg(target_os = "windows")] +pub fn autostart_path() -> Option { + env::var_os("APPDATA") + .map(PathBuf::from) + .map(|path| path.join("Microsoft/Windows/Start Menu/Programs/Startup/PC Remote.cmd")) +} + +#[cfg(target_os = "macos")] +pub fn autostart_path() -> Option { + env::var_os("HOME") + .map(PathBuf::from) + .map(|path| path.join("Library/LaunchAgents/com.mimaxon1.PCRemote.plist")) +} + +pub fn autostart_enabled() -> bool { + autostart_path().is_some_and(|path| path.exists()) +} + +pub fn set_autostart(enabled: bool) -> Result<(), String> { + let path = autostart_path().ok_or("Could not locate the current user's startup folder")?; + if enabled { + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let content = if cfg!(target_os = "windows") { + format!( + "@echo off\r\nstart \"\" /b \"{}\"\r\n", + executable.display() + ) + } else { + let executable = executable + .to_string_lossy() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """); + format!( + "\n\nLabelcom.mimaxon1.PCRemoteProgramArguments{executable}RunAtLoad\n" + ) + }; + fs::create_dir_all(path.parent().ok_or("Startup folder is missing")?) + .map_err(|error| error.to_string())?; + fs::write(path, content).map_err(|error| error.to_string())?; + } else if path.exists() { + fs::remove_file(path).map_err(|error| error.to_string())?; + } + Ok(()) +} + +pub fn scan_shortcuts() -> Vec { + recent_apps() +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..7319107 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,728 @@ +use std::{ + collections::{HashMap, VecDeque}, + env, fs, + io::Write, + net::IpAddr, + path::PathBuf, + sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use hmac::Hmac; +use pbkdf2::pbkdf2; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use sha2::Sha256; + +use crate::config; + +type HmacSha256 = Hmac; + +#[derive(Clone, Serialize, Deserialize)] +pub struct AppItem { + pub path: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aumid: Option, + #[serde(default)] + pub is_running: bool, +} + +#[derive(Clone)] +struct Session { + expires_at: Instant, + pair: bool, + opened: bool, + completed: bool, +} + +#[derive(Clone, Debug)] +struct AttemptState { + timestamps: Vec, + blocked_until: Option, +} + +#[derive(Clone, Serialize)] +pub struct LogEntry { + pub id: u64, + pub time: u64, + pub message: String, +} + +pub struct AppState { + pub settings_path: PathBuf, + pub settings_error: AtomicBool, + settings: Mutex, + sessions: Mutex>, + attempts: Mutex>, + pin_verification: Mutex<()>, + logs: Mutex>, + next_log_id: Mutex, + pub phone_connected: AtomicBool, + pub restart_requested: AtomicBool, + pub current_volume: Mutex, + pub muted: AtomicBool, + pinned_path: PathBuf, +} + +fn read_object(path: &std::path::Path) -> Result { + let text = fs::read_to_string(path).map_err(|error| error.to_string())?; + let value: Value = + serde_json::from_str(&text).map_err(|error| format!("invalid JSON: {error}"))?; + if !value.is_object() { + return Err("settings file must contain a JSON object".into()); + } + Ok(value) +} + +fn legacy_settings_paths() -> Vec { + let mut paths = Vec::new(); + #[cfg(target_os = "windows")] + if let Some(appdata) = env::var_os("APPDATA") { + paths.push(PathBuf::from(appdata).join("PC-Android/settings.json")); + } + #[cfg(target_os = "macos")] + if let Some(home) = env::var_os("HOME") { + paths + .push(PathBuf::from(home).join("Library/Application Support/PC-Android/settings.json")); + } + if let Ok(executable) = env::current_exe() { + if let Some(directory) = executable.parent() { + paths.push(directory.join("settings.json")); + } + } + paths +} + +fn write_json_atomic(path: &std::path::Path, value: &Value) -> Result<(), String> { + fs::create_dir_all(path.parent().ok_or("settings directory is missing")?) + .map_err(|e| e.to_string())?; + let mut temp = + tempfile::NamedTempFile::new_in(path.parent().ok_or("settings directory is missing")?) + .map_err(|e| e.to_string())?; + temp.write_all( + serde_json::to_vec_pretty(value) + .map_err(|e| e.to_string())? + .as_slice(), + ) + .map_err(|e| e.to_string())?; + temp.as_file().sync_all().map_err(|e| e.to_string())?; + temp.persist(path).map_err(|e| e.error.to_string())?; + Ok(()) +} + +fn load_settings(path: &std::path::Path) -> (Value, bool) { + load_settings_from(path, &legacy_settings_paths()) +} + +fn load_settings_from(path: &std::path::Path, legacy_paths: &[PathBuf]) -> (Value, bool) { + if path.exists() { + return match read_object(path) { + Ok(value) => (value, false), + Err(error) => { + eprintln!( + "Settings could not be read; preserving the file and requiring manual repair: {error}" + ); + (Value::Object(Map::new()), true) + } + }; + } + for source in legacy_paths { + if source == path || !source.exists() { + continue; + } + let Ok(value) = read_object(source) else { + continue; + }; + // Copy the complete document so the prior PIN and network preferences survive. + // Keep the source as a rollback copy until a later explicit user cleanup. + return match write_json_atomic(path, &value) { + Ok(()) => (value, false), + Err(error) => { + eprintln!( + "Could not migrate settings from {}; leaving both files untouched: {error}", + source.display() + ); + (Value::Object(Map::new()), true) + } + }; + } + (Value::Object(Map::new()), false) +} + +impl AppState { + pub fn new() -> Self { + Self::new_in(config::data_dir()) + } + + pub fn new_in(directory: PathBuf) -> Self { + let settings_path = directory.join("settings.json"); + let pinned_path = directory.join("pinned_apps.json"); + let (settings, settings_error) = load_settings(&settings_path); + let state = Self { + settings_path, + settings_error: AtomicBool::new(settings_error), + settings: Mutex::new(settings), + sessions: Mutex::new(HashMap::new()), + attempts: Mutex::new(HashMap::new()), + pin_verification: Mutex::new(()), + logs: Mutex::new(VecDeque::with_capacity(200)), + next_log_id: Mutex::new(1), + phone_connected: AtomicBool::new(false), + restart_requested: AtomicBool::new(false), + current_volume: Mutex::new(0.5), + muted: AtomicBool::new(false), + pinned_path, + }; + state.log("PC Remote 2.0 started"); + state + } + + pub fn auth_ready(&self) -> bool { + if self.settings_error.load(Ordering::Relaxed) { + return false; + } + self.settings + .lock() + .ok() + .and_then(|settings| { + let password = settings.get("password")?; + if password.get("is_default").and_then(Value::as_bool) == Some(true) + || password.get("is_set").and_then(Value::as_bool) == Some(false) + || password.get("algorithm").and_then(Value::as_str) + != Some("pbkdf2_hmac_sha256") + || password + .get("iterations") + .and_then(Value::as_u64) + .is_none_or(|count| count == 0 || count > 2_000_000) + { + return None; + } + let salt = password + .get("salt")? + .as_str() + .and_then(|encoded| STANDARD.decode(encoded).ok())?; + let hash = password + .get("hash")? + .as_str() + .and_then(|encoded| STANDARD.decode(encoded).ok())?; + (salt.len() >= 8 && hash.len() == 32).then_some(()) + }) + .is_some() + } + + pub fn settings_snapshot(&self) -> Value { + self.settings + .lock() + .map(|s| s.clone()) + .unwrap_or_else(|_| Value::Object(Map::new())) + } + + pub fn pin_hash_matches(&self, pin: &str) -> bool { + if self.settings_error.load(Ordering::Relaxed) { + return false; + } + let data = self.settings_snapshot(); + let Some(block) = data.get("password") else { + return false; + }; + if block.get("is_set").and_then(Value::as_bool) == Some(false) { + return false; + } + if block.get("algorithm").and_then(Value::as_str) != Some("pbkdf2_hmac_sha256") { + return false; + } + let Some(iterations) = block + .get("iterations") + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + else { + return false; + }; + if iterations == 0 || iterations > 2_000_000 { + return false; + } + let Some(salt) = block + .get("salt") + .and_then(Value::as_str) + .and_then(|s| STANDARD.decode(s).ok()) + else { + return false; + }; + let Some(expected) = block + .get("hash") + .and_then(Value::as_str) + .and_then(|s| STANDARD.decode(s).ok()) + else { + return false; + }; + if salt.len() < 8 || expected.len() != 32 { + return false; + } + let mut actual = [0u8; 32]; + if pbkdf2::(pin.as_bytes(), &salt, iterations, &mut actual).is_err() { + return false; + } + use std::sync::atomic::compiler_fence; + let equal = actual + .iter() + .zip(expected.iter()) + .fold(0u8, |different, (a, b)| different | (*a ^ *b)) + == 0; + compiler_fence(Ordering::SeqCst); + equal + } + + pub fn set_pin(&self, pin: &str) -> Result<(), String> { + if self.settings_error.load(Ordering::Relaxed) { + return Err("settings.json needs repair".into()); + } + let mut salt = [0u8; 16]; + rand::rng().fill_bytes(&mut salt); + let mut hash = [0u8; 32]; + pbkdf2::(pin.as_bytes(), &salt, config::PBKDF2_ITERATIONS, &mut hash) + .map_err(|e| e.to_string())?; + let mut data = self.settings.lock().map_err(|_| "settings lock poisoned")?; + let mut replacement = data.clone(); + if !replacement.is_object() { + replacement = Value::Object(Map::new()); + } + let object = replacement.as_object_mut().ok_or("settings need repair")?; + object.insert("version".into(), json!(3)); + object.insert( + "password".into(), + json!({ + "is_set": true, + "algorithm": "pbkdf2_hmac_sha256", + "iterations": config::PBKDF2_ITERATIONS, + "salt": STANDARD.encode(salt), + "hash": STANDARD.encode(hash), + }), + ); + write_json_atomic(&self.settings_path, &replacement)?; + *data = replacement; + Ok(()) + } + + pub fn change_pin(&self, pin: &str) -> Result<(), String> { + self.set_pin(pin)?; + if let Ok(mut sessions) = self.sessions.lock() { + sessions.clear(); + } + Ok(()) + } + + pub fn clear_pin(&self) -> Result<(), String> { + if self.settings_error.load(Ordering::Relaxed) { + return Err("settings.json needs repair".into()); + } + let mut data = self.settings.lock().map_err(|_| "settings lock poisoned")?; + let mut replacement = data.clone(); + if let Some(object) = replacement.as_object_mut() { + object.insert("version".into(), json!(3)); + object.insert("password".into(), json!({ "is_set": false })); + } + write_json_atomic(&self.settings_path, &replacement)?; + *data = replacement; + if let Ok(mut sessions) = self.sessions.lock() { + sessions.clear(); + } + Ok(()) + } + + pub fn issue_token(&self, pair: bool) -> String { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + if let Ok(mut sessions) = self.sessions.lock() { + let ttl = if pair { + config::PAIR_TTL_SECS + } else { + config::SESSION_TTL_SECS + }; + sessions.insert( + token.clone(), + Session { + expires_at: Instant::now() + Duration::from_secs(ttl), + pair, + opened: false, + completed: false, + }, + ); + } + token + } + + pub fn verify_token(&self, token: &str) -> bool { + let Ok(mut sessions) = self.sessions.lock() else { + return false; + }; + let Some(session) = sessions.get(token) else { + return false; + }; + if session.expires_at <= Instant::now() { + sessions.remove(token); + return false; + } + true + } + + pub fn pair_touch(&self, token: &str) -> bool { + if let Ok(mut sessions) = self.sessions.lock() { + if let Some(item) = sessions.get_mut(token) { + if item.pair && item.expires_at > Instant::now() { + item.opened = true; + return true; + } + } + } + false + } + + pub fn pair_complete(&self, token: &str) -> bool { + if let Ok(mut sessions) = self.sessions.lock() { + if let Some(item) = sessions.get_mut(token) { + if item.pair && item.expires_at > Instant::now() { + item.completed = true; + return true; + } + } + } + false + } + + pub fn pair_status(&self, token: &str) -> (bool, bool) { + self.sessions + .lock() + .ok() + .and_then(|s| { + s.get(token) + .filter(|p| p.pair && p.expires_at > Instant::now()) + .map(|p| (p.opened, p.completed)) + }) + .unwrap_or((false, false)) + } + + pub fn revoke_token(&self, token: &str) { + if let Ok(mut sessions) = self.sessions.lock() { + sessions.remove(token); + } + } + + pub fn login_blocked_for(&self, peer: &str) -> Option { + let mut all = self.attempts.lock().ok()?; + let item = all.get_mut(peer)?; + match item.blocked_until { + Some(until) if until > Instant::now() => Some( + until + .saturating_duration_since(Instant::now()) + .as_secs() + .max(1), + ), + Some(_) => { + item.blocked_until = None; + item.timestamps.clear(); + None + } + None => None, + } + } + + pub fn login_failed(&self, peer: &str) -> Option { + let now = Instant::now(); + let mut all = self.attempts.lock().ok()?; + let item = all.entry(peer.to_owned()).or_insert(AttemptState { + timestamps: vec![], + blocked_until: None, + }); + item.timestamps + .retain(|ts| now.duration_since(*ts) < Duration::from_secs(config::LOGIN_WINDOW_SECS)); + item.timestamps.push(now); + if item.timestamps.len() >= config::LOGIN_LIMIT { + let until = now + Duration::from_secs(config::LOGIN_BLOCK_SECS); + item.blocked_until = Some(until); + return Some(config::LOGIN_BLOCK_SECS); + } + None + } + + pub fn reset_login_failures(&self, peer: &str) { + if let Ok(mut all) = self.attempts.lock() { + all.remove(peer); + } + } + + fn verify_legacy_pin(&self, pin: &str) -> Result { + if let Some(secs) = self.login_blocked_for("legacy-pin-actions") { + return Err(secs); + } + if self.pin_hash_matches(pin) { + self.reset_login_failures("legacy-pin-actions"); + Ok(true) + } else { + Err(self.login_failed("legacy-pin-actions").unwrap_or(0)) + } + } + + pub fn verify_pin_for_peer(&self, pin: &str, peer: &str) -> Result { + let _verification = self + .pin_verification + .lock() + .expect("PIN verification lock poisoned"); + if let Some(seconds) = self.login_blocked_for(peer) { + return Err(seconds); + } + match self.verify_legacy_pin(pin) { + Ok(true) => { + self.reset_login_failures(peer); + Ok(true) + } + Err(seconds) if seconds > 0 => { + let peer_block = self.login_failed(peer); + Err(peer_block.unwrap_or(seconds)) + } + Err(_) => match self.login_failed(peer) { + Some(seconds) => Err(seconds), + None => Ok(false), + }, + Ok(false) => Ok(false), + } + } + + pub fn logs(&self, since: Option, limit: usize) -> (Vec, u64, bool) { + let Ok(entries) = self.logs.lock() else { + return (vec![], 0, false); + }; + let cursor = entries.back().map(|row| row.id).unwrap_or(0); + let reset = since.is_some_and(|mark| { + entries + .front() + .is_some_and(|front| mark.saturating_add(1) < front.id) + }); + let rows = entries + .iter() + .filter(|row| reset || since.is_none_or(|mark| row.id > mark)) + .take(limit.min(1000)) + .cloned() + .collect(); + (rows, cursor, reset) + } + + pub fn log(&self, message: &str) { + let Ok(mut next) = self.next_log_id.lock() else { + return; + }; + let entry = LogEntry { + id: *next, + time: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + message: message.chars().take(1024).collect(), + }; + *next += 1; + if let Ok(mut rows) = self.logs.lock() { + if rows.len() >= 500 { + rows.pop_front(); + } + rows.push_back(entry); + } + } + + pub fn pinned(&self) -> Vec { + fs::read_to_string(&self.pinned_path) + .ok() + .and_then(|text| serde_json::from_str::>(&text).ok()) + .unwrap_or_default() + } + + pub fn pin_app(&self, item: AppItem) -> Result, String> { + let mut items = self.pinned(); + items.retain(|existing| { + existing.path != item.path || existing.args != item.args || existing.aumid != item.aumid + }); + items.insert(0, item); + items.truncate(100); + write_json_atomic( + &self.pinned_path, + &serde_json::to_value(&items).map_err(|e| e.to_string())?, + )?; + Ok(items) + } + + pub fn unpin_app( + &self, + path: &str, + args: Option<&str>, + aumid: Option<&str>, + ) -> Result<(bool, Vec), String> { + let mut items = self.pinned(); + let previous = items.len(); + items.retain(|existing| { + !(existing.path.eq_ignore_ascii_case(path) + && existing.args.as_deref() == args + && existing.aumid.as_deref() == aumid) + }); + if items.len() != previous { + write_json_atomic( + &self.pinned_path, + &serde_json::to_value(&items).map_err(|e| e.to_string())?, + )?; + } + Ok((previous != items.len(), items)) + } + + pub fn is_loopback(ip: IpAddr) -> bool { + ip.is_loopback() + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn preserves_legacy_pin_fields_on_change() { + let temp = tempfile::tempdir().unwrap(); + let state = AppState::new_in(temp.path().to_path_buf()); + state.settings_error.store(false, Ordering::Relaxed); + let mut settings = state.settings.lock().unwrap(); + *settings = json!({"network":{"preferred_ip":"192.168.1.10"},"keep":"value"}); + drop(settings); + state.set_pin("0712").unwrap(); + assert!(state.pin_hash_matches("0712")); + assert!(!state.pin_hash_matches("1234")); + let document = state.settings_snapshot(); + assert_eq!(document["network"]["preferred_ip"], "192.168.1.10"); + assert_eq!(document["keep"], "value"); + } + + #[test] + fn copies_valid_legacy_settings_without_removing_the_source() { + let temp = tempfile::tempdir().unwrap(); + let legacy = temp.path().join("legacy-settings.json"); + let current = temp.path().join("current/settings.json"); + let contents = json!({"version":3,"password":{"is_set":false},"network":{"preferred_interface":"Wi-Fi","preferred_ip":"192.168.1.20"}}); + fs::write(&legacy, serde_json::to_vec(&contents).unwrap()).unwrap(); + + let (loaded, damaged) = load_settings_from(¤t, std::slice::from_ref(&legacy)); + + assert!(!damaged); + assert_eq!(loaded, contents); + assert_eq!(read_object(¤t).unwrap(), contents); + assert_eq!(read_object(&legacy).unwrap(), contents); + } + + #[test] + fn damaged_current_settings_never_delete_or_replace_the_legacy_copy() { + let temp = tempfile::tempdir().unwrap(); + let legacy = temp.path().join("legacy-settings.json"); + let current = temp.path().join("current/settings.json"); + let source = json!({"version":3,"network":{"preferred_ip":"192.168.1.20"}}); + fs::write(&legacy, serde_json::to_vec(&source).unwrap()).unwrap(); + fs::create_dir_all(current.parent().unwrap()).unwrap(); + fs::write(¤t, b"{").unwrap(); + + let (loaded, damaged) = load_settings_from(¤t, std::slice::from_ref(&legacy)); + + assert!(damaged); + assert_eq!(loaded, json!({})); + assert_eq!(fs::read(¤t).unwrap(), b"{"); + assert_eq!(read_object(&legacy).unwrap(), source); + } + + #[test] + fn existing_current_settings_keep_precedence_without_deleting_legacy_data() { + let temp = tempfile::tempdir().unwrap(); + let legacy = temp.path().join("legacy-settings.json"); + let current = temp.path().join("current/settings.json"); + let old = json!({"network":{"preferred_ip":"192.168.1.10"}}); + let new = json!({"network":{"preferred_ip":"192.168.1.20"}}); + fs::write(&legacy, serde_json::to_vec(&old).unwrap()).unwrap(); + write_json_atomic(¤t, &new).unwrap(); + + let (loaded, damaged) = load_settings_from(¤t, std::slice::from_ref(&legacy)); + + assert!(!damaged); + assert_eq!(loaded, new); + assert_eq!(read_object(&legacy).unwrap(), old); + } + + #[test] + fn pairing_token_remains_a_usable_session_for_its_full_ttl() { + let temp = tempfile::tempdir().unwrap(); + let state = AppState::new_in(temp.path().to_path_buf()); + let token = state.issue_token(true); + assert!(state.verify_token(&token)); + assert_eq!(config::PAIR_TTL_SECS, config::SESSION_TTL_SECS); + assert!(state.pair_touch(&token)); + assert_eq!(state.pair_status(&token), (true, false)); + } + + #[test] + fn failed_pin_checks_share_a_global_budget() { + let temp = tempfile::tempdir().unwrap(); + let state = AppState::new_in(temp.path().to_path_buf()); + for attempt in 0..config::LOGIN_LIMIT - 1 { + assert!( + !state + .verify_pin_for_peer("wrong", &format!("192.0.2.{}", attempt + 1)) + .unwrap() + ); + } + assert!( + state + .verify_pin_for_peer("correct-but-now-blocked", "192.0.2.5") + .unwrap_err() + > 0 + ); + assert!( + state + .verify_pin_for_peer("correct-but-now-blocked", "192.0.2.6") + .unwrap_err() + > 0 + ); + } + + #[test] + fn concurrent_pin_failures_cannot_overrun_peer_limit() { + let temp = tempfile::tempdir().unwrap(); + let state = std::sync::Arc::new(AppState::new_in(temp.path().to_path_buf())); + let workers = (0..8) + .map(|_| { + let state = std::sync::Arc::clone(&state); + std::thread::spawn(move || state.verify_pin_for_peer("wrong", "192.0.2.2")) + }) + .collect::>(); + let results = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>(); + assert_eq!( + results + .iter() + .filter(|result| **result == Ok(false)) + .count(), + config::LOGIN_LIMIT - 1 + ); + assert_eq!( + results + .iter() + .filter(|result| result.as_ref().is_err()) + .count(), + 8 - (config::LOGIN_LIMIT - 1) + ); + assert!( + state + .verify_pin_for_peer("correct", "192.0.2.2") + .unwrap_err() + > 0 + ); + } +} diff --git a/src/tray.rs b/src/tray.rs new file mode 100644 index 0000000..24f74ff --- /dev/null +++ b/src/tray.rs @@ -0,0 +1,125 @@ +use std::sync::{Arc, atomic::Ordering}; + +use tao::{ + event::Event, + event_loop::{ControlFlow, EventLoopBuilder}, +}; +use tray_icon::{ + Icon, TrayIconBuilder, + menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}, +}; + +use crate::{config, platform, state::AppState}; + +#[derive(Clone)] +enum UiAction { + Pair, + OpenController, + ToggleAutostart, + Quit, +} + +fn pairing_page() -> String { + format!( + "http://127.0.0.1:{}/_tray/qr", + config::port("PC_REMOTE_WEB_PORT", config::WEB_PORT_DEFAULT) + ) +} + +fn open_pairing_page() { + let _ = open::that(pairing_page()); +} + +fn open_controller() { + let _ = open::that(format!( + "http://127.0.0.1:{}", + config::port("PC_REMOTE_WEB_PORT", config::WEB_PORT_DEFAULT) + )); +} + +fn menu() -> Result> { + let menu = Menu::new(); + let pair = MenuItem::with_id("show-pairing-qr", "Show phone pairing QR code", true, None); + let open = MenuItem::with_id("open-controller", "Open PC Remote", true, None); + let startup = MenuItem::with_id( + "toggle-autostart", + if platform::autostart_enabled() { + "Disable start at login" + } else { + "Start at login" + }, + true, + None, + ); + let quit = MenuItem::with_id("quit", "Exit PC Remote", true, None); + menu.append(&pair)?; + menu.append(&open)?; + menu.append(&PredefinedMenuItem::separator())?; + menu.append(&startup)?; + menu.append(&PredefinedMenuItem::separator())?; + menu.append(&quit)?; + Ok(menu) +} + +fn icon() -> Result { + let decoded = image::load_from_memory(include_bytes!("../web/icons/icon-192.png")) + .map_err(|error| error.to_string())? + .to_rgba8(); + let (width, height) = decoded.dimensions(); + Icon::from_rgba(decoded.into_raw(), width, height).map_err(|error| error.to_string()) +} + +pub fn run(state: Arc) -> Result<(), String> { + let event_loop = EventLoopBuilder::::with_user_event().build(); + let tray_menu = menu().map_err(|error| error.to_string())?; + let _icon = TrayIconBuilder::new() + .with_tooltip(if state.auth_ready() { + "PC Remote 2.0" + } else { + "PC Remote: scan QR to finish setup" + }) + .with_icon(icon()?) + .with_menu(Box::new(tray_menu)) + .build() + .map_err(|error| format!("Could not create the system-tray icon: {error}"))?; + + let proxy = event_loop.create_proxy(); + MenuEvent::set_event_handler(Some(move |event: MenuEvent| { + let action = match event.id.as_ref() { + "show-pairing-qr" => Some(UiAction::Pair), + "open-controller" => Some(UiAction::OpenController), + "toggle-autostart" => Some(UiAction::ToggleAutostart), + "quit" => Some(UiAction::Quit), + _ => None, + }; + if let Some(action) = action { + let _ = proxy.send_event(action); + } + })); + + let restart = Arc::clone(&state); + event_loop.run(move |event, _target, control_flow| { + *control_flow = ControlFlow::Wait; + if let Event::UserEvent(action) = event { + match action { + UiAction::Pair => open_pairing_page(), + UiAction::OpenController => open_controller(), + UiAction::ToggleAutostart => { + let enabled = !platform::autostart_enabled(); + match platform::set_autostart(enabled) { + Ok(()) => restart.log(if enabled { + "Autostart enabled" + } else { + "Autostart disabled" + }), + Err(error) => restart.log(&format!("Autostart update failed: {error}")), + } + } + UiAction::Quit => *control_flow = ControlFlow::Exit, + } + } + if restart.restart_requested.load(Ordering::Relaxed) { + *control_flow = ControlFlow::Exit; + } + }) +}