From d550bbd806acd6812b7748b77dac9d0f4270aa02 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sun, 30 Aug 2026 21:51:26 +0200 Subject: [PATCH 01/13] quality gates --- .github/workflows/quality.yml | 63 ++ .github/workflows/tauri-release.yml | 143 ++- README.md | 9 +- docs/quality-gates.md | 70 ++ eslint.config.js | 40 + package-lock.json | 907 +++++++++++++++++- package.json | 11 +- scripts/run-js-tests.mjs | 31 + src-tauri/capabilities/typing-invaders.json | 9 + src-tauri/src/main.rs | 255 +++++ src-tauri/tests/golden_fixtures.rs | 31 + src/main.js | 27 +- src/quality_diagnostics.js | 20 + src/secondary_window_ready.js | 60 ++ src/self-test.js | 9 +- src/settings.js | 10 +- src/typing_invaders/game.js | 17 +- tests/app_config.test.mjs | 8 +- tests/compatibility/fixture_contract.test.mjs | 14 + tests/compatibility/release_workflow.test.mjs | 17 + tests/fixture_helpers.mjs | 10 + tests/fixtures/README.md | 16 + tests/fixtures/ble/read-only-session.json | 7 + tests/fixtures/ble/writable-session.json | 7 + tests/fixtures/configs/baseline.json | 5 + tests/fixtures/configs/external.json | 5 + tests/fixtures/configs/malformed.json | 6 + tests/fixtures/future/README.md | 11 + tests/fixtures/layouts/corne-connected.json | 53 + tests/fixtures/layouts/external-minimal.json | 6 + tests/fixtures/layouts/qwerty-minimal.json | 13 + tests/golden_fixtures.test.mjs | 52 + tests/input_source_sync_config.test.mjs | 25 +- tests/privacy/diagnostic_redaction.test.mjs | 21 + tests/secondary_window_capabilities.test.mjs | 22 + tests/secondary_window_ready.test.mjs | 47 + 36 files changed, 1913 insertions(+), 144 deletions(-) create mode 100644 .github/workflows/quality.yml create mode 100644 docs/quality-gates.md create mode 100644 eslint.config.js create mode 100644 scripts/run-js-tests.mjs create mode 100644 src-tauri/capabilities/typing-invaders.json create mode 100644 src-tauri/tests/golden_fixtures.rs create mode 100644 src/quality_diagnostics.js create mode 100644 src/secondary_window_ready.js create mode 100644 tests/compatibility/fixture_contract.test.mjs create mode 100644 tests/compatibility/release_workflow.test.mjs create mode 100644 tests/fixture_helpers.mjs create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/ble/read-only-session.json create mode 100644 tests/fixtures/ble/writable-session.json create mode 100644 tests/fixtures/configs/baseline.json create mode 100644 tests/fixtures/configs/external.json create mode 100644 tests/fixtures/configs/malformed.json create mode 100644 tests/fixtures/future/README.md create mode 100644 tests/fixtures/layouts/corne-connected.json create mode 100644 tests/fixtures/layouts/external-minimal.json create mode 100644 tests/fixtures/layouts/qwerty-minimal.json create mode 100644 tests/golden_fixtures.test.mjs create mode 100644 tests/privacy/diagnostic_redaction.test.mjs create mode 100644 tests/secondary_window_capabilities.test.mjs create mode 100644 tests/secondary_window_ready.test.mjs diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..b2165b8 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,63 @@ +name: Source Quality + +on: + pull_request: + push: + branches: ["master"] + workflow_call: + +permissions: + contents: read + +jobs: + desktop-source: + name: Desktop source (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux + runner: ubuntu-latest + - platform: windows + runner: windows-latest + - platform: macos + runner: macos-15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Install Linux dependencies + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev + - name: Install JavaScript dependencies + run: npm ci + - name: JavaScript lint and unit tests + run: npm run check:js + - name: Rust formatting + run: cargo fmt --manifest-path src-tauri/Cargo.toml -- --check + - name: Rust compile and unit tests + run: cargo test --manifest-path src-tauri/Cargo.toml --locked + + website-production-build: + name: Website production build + runs-on: ubuntu-latest + defaults: + run: + working-directory: website + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: website/package-lock.json + - run: npm ci + - run: npm run build diff --git a/.github/workflows/tauri-release.yml b/.github/workflows/tauri-release.yml index 1a3237d..57d21cb 100644 --- a/.github/workflows/tauri-release.yml +++ b/.github/workflows/tauri-release.yml @@ -5,68 +5,45 @@ on: branches: ["master"] tags: ["v[0-9]*.[0-9]*.[0-9]*"] paths-ignore: - - 'website/**' + - "website/**" permissions: contents: write -env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - jobs: + quality: + uses: ./.github/workflows/quality.yml + prepare: runs-on: ubuntu-latest outputs: is_release: ${{ steps.context.outputs.is_release }} - release_id: ${{ steps.create_release.outputs.id }} steps: - - name: Checkout repository - uses: actions/checkout@v4 - + - uses: actions/checkout@v4 - name: Determine release context id: context + shell: bash run: | if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then echo "is_release=true" >> "$GITHUB_OUTPUT" else echo "is_release=false" >> "$GITHUB_OUTPUT" fi - - - name: Verify tag matches versions + - name: Verify tag matches all source versions if: steps.context.outputs.is_release == 'true' + shell: bash run: | - TAG="${GITHUB_REF#refs/tags/}" - EXPECTED="${TAG#v}" + EXPECTED="${GITHUB_REF_NAME#v}" PKG_VERSION=$(jq -r '.version' package.json) TAURI_VERSION=$(jq -r '.version' src-tauri/tauri.conf.json) - if [[ "$PKG_VERSION" != "$EXPECTED" ]]; then - echo "package.json version ($PKG_VERSION) does not match tag ($EXPECTED)" >&2 - exit 1 - fi - if [[ "$TAURI_VERSION" != "$EXPECTED" ]]; then - echo "tauri.conf.json version ($TAURI_VERSION) does not match tag ($EXPECTED)" >&2 - exit 1 - fi - - - name: Create GitHub release - id: create_release - if: steps.context.outputs.is_release == 'true' - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref_name }} - release_name: ${{ github.ref_name }} - draft: false - prerelease: false - body: | - Automated Keyboard Helper release. - - macOS downloads labelled `apple-silicon` and `intel` are ad-hoc-signed, unnotarized preview builds. Apple has not verified them; follow the macOS installation guidance in the README if Gatekeeper blocks the first launch. + CARGO_VERSION=$(sed -n '/^\[package\]/,/^\[/s/^version = "\([^"]*\)"/\1/p' src-tauri/Cargo.toml) + test "$PKG_VERSION" = "$EXPECTED" + test "$TAURI_VERSION" = "$EXPECTED" + test "$CARGO_VERSION" = "$EXPECTED" build: - name: Build (${{ matrix.artifact_label }}) - needs: prepare + name: Package (${{ matrix.artifact_label }}) + needs: [quality, prepare] runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -94,78 +71,86 @@ jobs: expected_arch: x86_64 tauri_args: --target x86_64-apple-darwin artifact_path: dist/macos/macos-intel/** - env: CI: true - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20" cache: npm - - - name: Set up Rust toolchain - uses: dtolnay/rust-toolchain@stable - + - uses: dtolnay/rust-toolchain@stable - name: Add explicit macOS Rust target if: matrix.platform == 'macos' run: rustup target add ${{ matrix.rust_target }} - - name: Install Linux dependencies if: matrix.platform == 'linux' run: | sudo apt-get update - sudo apt-get install -y \ - libgtk-3-dev \ - libwebkit2gtk-4.1-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev - - - name: Install JS dependencies - run: npm ci - - - name: Build non-macOS Tauri app (attach to release) - if: matrix.platform != 'macos' && needs.prepare.outputs.is_release == 'true' + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev + - run: npm ci + - name: Build non-macOS package + if: matrix.platform != 'macos' uses: tauri-apps/tauri-action@v0 - with: - releaseId: ${{ needs.prepare.outputs.release_id }} - - - name: Build non-macOS Tauri app (no release) - if: matrix.platform != 'macos' && needs.prepare.outputs.is_release != 'true' - uses: tauri-apps/tauri-action@v0 - - name: Build ad-hoc-signed macOS preview if: matrix.platform == 'macos' uses: tauri-apps/tauri-action@v0 env: - # CI preview only. A future Developer ID job must supply its own identity. APPLE_SIGNING_IDENTITY: "-" with: args: ${{ matrix.tauri_args }} - - name: Verify and stage macOS preview if: matrix.platform == 'macos' + shell: bash run: >- bash .github/scripts/verify-macos-ad-hoc.sh "${{ matrix.rust_target }}" "${{ matrix.expected_arch }}" "${{ matrix.artifact_label }}" - - - name: Attach verified macOS preview to release - if: matrix.platform == 'macos' && needs.prepare.outputs.is_release == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Exercise secondary windows + if: matrix.platform == 'windows' + continue-on-error: ${{ vars.WINDOWS_SMOKE_REQUIRED != 'true' }} + timeout-minutes: 3 + shell: pwsh run: | - assets=("dist/macos/${{ matrix.artifact_label }}"/*) - gh release upload "$GITHUB_REF_NAME" "${assets[@]}" --clobber - - - name: Upload build artifacts + $env:KEYBOARD_HELPER_SMOKE_REPORT = "$PWD\secondary-window-smoke.json" + $process = Start-Process -FilePath "src-tauri\target\release\keyboard-app.exe" -ArgumentList "--quality-smoke-secondary-windows" -Wait -PassThru + if ($process.ExitCode -ne 0) { exit $process.ExitCode } + $report = Get-Content $env:KEYBOARD_HELPER_SMOKE_REPORT | ConvertFrom-Json + if (-not $report.passed) { throw "Secondary-window smoke did not pass" } + - name: Retain Windows smoke diagnostics + if: matrix.platform == 'windows' && always() + uses: actions/upload-artifact@v4 + with: + name: windows-secondary-window-smoke + path: secondary-window-smoke.json + if-no-files-found: warn + - name: Upload package artifacts uses: actions/upload-artifact@v4 with: name: tauri-release-${{ matrix.artifact_label }} path: ${{ matrix.artifact_path }} if-no-files-found: error + + publish: + name: Publish GitHub release + if: needs.prepare.outputs.is_release == 'true' + needs: [quality, prepare, build] + runs-on: ubuntu-latest + steps: + - name: Download verified platform artifacts + uses: actions/download-artifact@v4 + with: + pattern: tauri-release-* + path: release-assets + merge-multiple: true + - name: Create completed release only after all gates + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + mapfile -d '' assets < <(find release-assets -type f -print0) + test "${#assets[@]}" -gt 0 + gh release create "$GITHUB_REF_NAME" "${assets[@]}" \ + --title "$GITHUB_REF_NAME" \ + --notes "Automated Keyboard Helper release. macOS downloads are ad-hoc-signed, unnotarized preview builds." diff --git a/README.md b/README.md index f21fd37..f3577c5 100644 --- a/README.md +++ b/README.md @@ -105,13 +105,18 @@ The report exists only until the self-test window closes. It verifies the config Releases are cut from `master` with semantic version tags. +The required source matrix, website production build, platform packages, and +Windows secondary-window smoke all finish before CI creates a GitHub release. +See [Product quality gates](docs/quality-gates.md) for local commands, platform +constraints, retained artifacts, and failure recovery. + - Version source: keep `package.json`, `src-tauri/tauri.conf.json`, and `src-tauri/Cargo.toml` on the same semver (e.g., `0.2.0`). Update all three before tagging so the JS package, Tauri config, and Rust crate stay aligned. -- Trigger: create an annotated tag `vMAJOR.MINOR.PATCH` on `master` and push it; CI will build macOS/Windows/Linux bundles and publish a GitHub release with the assets attached. macOS release downloads are explicitly labelled `macos-apple-silicon` or `macos-intel`, include SHA-256 files, and remain unnotarized previews. The job should fail if the tag does not match the version in both files. +- Trigger: create an annotated tag `vMAJOR.MINOR.PATCH` on `master` and push it; CI will build macOS/Windows/Linux bundles and publish a GitHub release with the assets attached. macOS release downloads are explicitly labelled `macos-apple-silicon` or `macos-intel`, include SHA-256 files, and remain unnotarized previews. The job fails if the tag does not match all three version sources. - Steps: 1. Bump the version in `package.json`, `src-tauri/tauri.conf.json`, and `src-tauri/Cargo.toml`, commit, and merge to `master`. 2. Draft release notes (highlights, fixes, platform notes). Keep them short and paste them into the GitHub release description after CI creates it. 3. Tag the merge commit (`git tag -a v0.2.0 -m "Release v0.2.0"`) and push the tag (`git push origin v0.2.0`). - 4. Watch the release workflow in GitHub Actions; when it finishes, open the generated GitHub release for `v0.2.0`, paste the release notes into the description, and publish/save. + 4. Watch the release workflow in GitHub Actions. A failed prerequisite creates no normal release; use its retained artifacts to diagnose the failure and rerun the complete tagged workflow. After success, open the generated release for `v0.2.0` and replace the automated notes with the prepared notes. - Non-tag pushes to `master` still run the build and upload architecture-specific artifacts to the workflow run but do not create a GitHub release entry or stable release downloads. ## App configuration & external layouts diff --git a/docs/quality-gates.md b/docs/quality-gates.md new file mode 100644 index 0000000..3caf67e --- /dev/null +++ b/docs/quality-gates.md @@ -0,0 +1,70 @@ +# Product quality gates + +## Local checks + +Run the same fast checks used by pull requests from the application repository: + +```bash +npm ci +npm run check:js +npm run test:privacy +npm run test:compatibility +cargo fmt --manifest-path src-tauri/Cargo.toml -- --check +cargo test --manifest-path src-tauri/Cargo.toml --locked +npm --prefix website ci +npm --prefix website run build +``` + +`quality.yml` repeats JavaScript and Rust source checks on Ubuntu, Windows, and +macOS 15. Linux needs the Tauri GTK/WebKit/AppIndicator development packages. +Native window behavior, WebView2, platform packaging, macOS architecture, and +signing cannot be established by a different host: those checks remain on their +own runners. + +The Windows package job starts the release executable with +`--quality-smoke-secondary-windows`. This test-only startup switch is inert in a +normal launch. It opens Settings, Shift-Space Invaders, and Keyboard Self-test +through their production commands and checks page readiness, visibility, +single-instance reuse, minimized-window restoration, focus, and clean close. +The structured JSON report is retained as the +`windows-secondary-window-smoke` workflow artifact. + +During stabilization the smoke step is non-blocking. After repeated green runs, +set the repository variable `WINDOWS_SMOKE_REQUIRED=true`; the same matrix step +then becomes a release-blocking prerequisite without changing its test path. + +## Fixtures and extension suites + +Reviewed shared contracts live under `tests/fixtures`. A layout, config, BLE, +or other versioned contract change must add a compatibility test that proves +backward compatibility, a migration, or an intentional explicit rejection. +Register it under `tests/compatibility` so `npm run test:compatibility` owns it. + +Analytics and MCP changes must add privacy assertions under `tests/privacy` for +every applicable boundary: disabled mode, consent, raw typed text, raw key-log +retention, approval, approval expiration, and diagnostic/export redaction. +Retained artifacts must pass through the diagnostic redaction contract and must +not contain typed content, credentials, tokens, or private configuration values. + +## Repository boundaries + +The planning repository owns OpenSpec and documentation checks. Its workflow +runs strict OpenSpec validation for planning changes. The `keyboard_helper` +repository owns application, website, package, Windows smoke, privacy, and +compatibility checks. These are repository-local gates; neither repository +claims to block the other's release until an explicit shared status mechanism +is approved and configured. + +## Release safety and recovery + +A tag first passes the source matrix, production website build, version check, +all four platform package jobs, the Windows smoke once promoted to required, +and macOS architecture and ad-hoc-signature verification. Only the final +`publish` job can create a normal GitHub release. A failed required prerequisite +therefore leaves workflow artifacts for diagnosis but no completed release. + +Workflow artifacts use the repository's configured GitHub Actions retention. +After a failure, download the relevant build or smoke artifact, fix the source +or CI issue, delete and recreate the tag only if the tagged commit must change, +then re-run the full tagged workflow. Do not manually create a normal release +from a partially successful run. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..3771984 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,40 @@ +import js from "@eslint/js"; +import globals from "globals"; + +export default [ + { + ignores: [ + "node_modules/**", + "src-tauri/target/**", + "website/dist/**", + "website/node_modules/**", + ], + }, + js.configs.recommended, + { + files: ["src/**/*.js"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + }, + }, + rules: { + "no-unused-vars": ["error", { argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }], + }, + }, + { + files: ["tests/**/*.mjs", "scripts/**/*.mjs", "eslint.config.js"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.node, + }, + }, + rules: { + "no-unused-vars": ["error", { argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }], + }, + }, +]; diff --git a/package-lock.json b/package-lock.json index da8d5fd..8b39340 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,204 @@ "@tauri-apps/api": "^2.9.0" }, "devDependencies": { - "@tauri-apps/cli": "^2.0.0" + "@eslint/js": "^10.0.1", + "@tauri-apps/cli": "^2.0.0", + "eslint": "^10.9.1", + "globals": "^17.11.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@tauri-apps/api": { @@ -240,6 +437,714 @@ "engines": { "node": ">= 10" } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", + "dev": true, + "license": "MIT", + "peer": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 937bb4c..83ebb46 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,18 @@ "private": true, "type": "module", "scripts": { - "test": "node --test tests/*.test.mjs", + "lint": "eslint src tests scripts", + "test": "node scripts/run-js-tests.mjs", + "check:js": "npm run lint && npm test", + "test:privacy": "node scripts/run-js-tests.mjs tests/privacy", + "test:compatibility": "node scripts/run-js-tests.mjs tests/compatibility", "tauri": "tauri" }, "devDependencies": { - "@tauri-apps/cli": "^2.0.0" + "@eslint/js": "^10.0.1", + "@tauri-apps/cli": "^2.0.0", + "eslint": "^10.9.1", + "globals": "^17.11.0" }, "dependencies": { "@tauri-apps/api": "^2.9.0" diff --git a/scripts/run-js-tests.mjs b/scripts/run-js-tests.mjs new file mode 100644 index 0000000..8ddc1da --- /dev/null +++ b/scripts/run-js-tests.mjs @@ -0,0 +1,31 @@ +import { readdirSync, statSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; + +function collectTests(path) { + const resolved = resolve(path); + if (!statSync(resolved).isDirectory()) return [resolved]; + return readdirSync(resolved, { withFileTypes: true }) + .flatMap((entry) => { + const child = resolve(resolved, entry.name); + if (entry.isDirectory()) return collectTests(child); + return entry.name.endsWith(".test.mjs") ? [child] : []; + }) + .sort(); +} + +const requestedPaths = process.argv.slice(2); +const testFiles = (requestedPaths.length ? requestedPaths : ["tests"]) + .flatMap(collectTests); + +if (testFiles.length === 0) { + console.error("No JavaScript test files matched the requested paths."); + process.exit(1); +} + +const result = spawnSync(process.execPath, ["--test", ...testFiles], { + stdio: "inherit", +}); + +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/src-tauri/capabilities/typing-invaders.json b/src-tauri/capabilities/typing-invaders.json new file mode 100644 index 0000000..72dabfc --- /dev/null +++ b/src-tauri/capabilities/typing-invaders.json @@ -0,0 +1,9 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "typing-invaders", + "description": "Capability for the Shift-Space Invaders window", + "windows": ["typing-invaders"], + "permissions": [ + "core:default" + ] +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2f4faca..1ee2efe 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; mod ble_layer_sync; mod config_store; #[cfg(target_os = "macos")] @@ -48,6 +49,49 @@ struct OverlayGeometryState { snapshot: Mutex>, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct SecondaryWindowReadyPayload { + label: String, + state: String, + stage: String, + error: Option, +} + +struct SecondaryWindowReadinessState { + sender: tokio::sync::broadcast::Sender, +} + +impl Default for SecondaryWindowReadinessState { + fn default() -> Self { + let (sender, _) = tokio::sync::broadcast::channel(16); + Self { sender } + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SecondaryWindowSmokeResult { + label: String, + ready: bool, + visible: bool, + reused: bool, + restored: bool, + focused: bool, + closed: bool, + stage: String, + error: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct QualitySmokeReport { + schema_version: u8, + platform: String, + passed: bool, + windows: Vec, +} + impl OverlayGeometryState { fn capture_if_empty(&self, snapshot: OverlayWindowSnapshot) -> Result<(), String> { let mut current = self.snapshot.lock().map_err(|error| error.to_string())?; @@ -671,6 +715,213 @@ fn open_keyboard_self_test_window( } } +#[tauri::command] +fn secondary_window_ready( + state: State, + payload: SecondaryWindowReadyPayload, +) { + let _ = state.sender.send(payload); +} + +#[tauri::command] +fn quality_smoke_requested() -> bool { + std::env::args().any(|argument| argument == "--quality-smoke-secondary-windows") +} + +async fn wait_for_secondary_window( + receiver: &mut tokio::sync::broadcast::Receiver, + label: &str, +) -> Result<(), SecondaryWindowReadyPayload> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(SecondaryWindowReadyPayload { + label: label.to_string(), + state: "failed".to_string(), + stage: "timeout".to_string(), + error: Some(format!("{label} readiness timed out")), + }); + } + let payload = tokio::time::timeout(remaining, receiver.recv()) + .await + .map_err(|_| SecondaryWindowReadyPayload { + label: label.to_string(), + state: "failed".to_string(), + stage: "timeout".to_string(), + error: Some(format!("{label} readiness timed out")), + })? + .map_err(|error| SecondaryWindowReadyPayload { + label: label.to_string(), + state: "failed".to_string(), + stage: "readiness".to_string(), + error: Some(format!("{label} readiness channel failed: {error}")), + })?; + if payload.label != label { + continue; + } + return if payload.state == "ready" { + Ok(()) + } else { + Err(payload) + }; + } +} + +async fn smoke_secondary_window( + app_handle: &tauri::AppHandle, + receiver: &mut tokio::sync::broadcast::Receiver, + label: &str, +) -> SecondaryWindowSmokeResult { + let mut result = SecondaryWindowSmokeResult { + label: label.to_string(), + ready: false, + visible: false, + reused: false, + restored: false, + focused: false, + closed: false, + stage: "open".to_string(), + error: None, + }; + let opened = match label { + SETTINGS_WINDOW_LABEL => open_settings_window(app_handle), + TYPING_INVADERS_WINDOW_LABEL => open_typing_invaders_window(app_handle), + KEYBOARD_SELF_TEST_WINDOW_LABEL => open_keyboard_self_test_window(app_handle, "qwerty"), + _ => Err(format!("unknown secondary window label: {label}")), + }; + if let Err(error) = opened { + result.error = Some(error); + return result; + } + + result.stage = "readiness".to_string(); + if let Err(failure) = wait_for_secondary_window(receiver, label).await { + result.stage = failure.stage; + result.error = Some( + failure + .error + .unwrap_or_else(|| format!("{label} readiness failed")), + ); + if let Some(window) = app_handle.get_webview_window(label) { + let _ = window.destroy(); + } + return result; + } + result.ready = true; + let Some(window) = app_handle.get_webview_window(label) else { + result.error = Some(format!("{label} disappeared after readiness")); + return result; + }; + result.stage = "visible".to_string(); + result.visible = window.is_visible().unwrap_or(false); + if !result.visible { + result.error = Some(format!("{label} is not visible")); + let _ = window.destroy(); + return result; + } + + result.stage = "reuse".to_string(); + let reused = match label { + SETTINGS_WINDOW_LABEL => open_settings_window(app_handle), + TYPING_INVADERS_WINDOW_LABEL => open_typing_invaders_window(app_handle), + KEYBOARD_SELF_TEST_WINDOW_LABEL => open_keyboard_self_test_window(app_handle, "qwerty"), + _ => unreachable!(), + }; + result.reused = reused.is_ok() && app_handle.get_webview_window(label).is_some(); + if !result.reused { + result.error = Some( + reused + .err() + .unwrap_or_else(|| format!("{label} was not reused")), + ); + let _ = window.destroy(); + return result; + } + + result.stage = "restore".to_string(); + if let Err(error) = window.minimize() { + result.error = Some(format!("failed to minimize {label}: {error}")); + let _ = window.destroy(); + return result; + } + let restored = match label { + SETTINGS_WINDOW_LABEL => open_settings_window(app_handle), + TYPING_INVADERS_WINDOW_LABEL => open_typing_invaders_window(app_handle), + KEYBOARD_SELF_TEST_WINDOW_LABEL => open_keyboard_self_test_window(app_handle, "qwerty"), + _ => unreachable!(), + }; + for _ in 0..20 { + result.restored = restored.is_ok() && !window.is_minimized().unwrap_or(true); + result.focused = window.is_focused().unwrap_or(false); + if result.restored && result.focused { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + if !result.restored || !result.focused { + result.stage = "focus".to_string(); + result.error = Some(format!("{label} did not restore and focus")); + } + if result.error.is_none() { + result.stage = "close".to_string(); + } + if let Err(error) = window.destroy() { + result.error = Some(format!("failed to close {label}: {error}")); + return result; + } + for _ in 0..20 { + if app_handle.get_webview_window(label).is_none() { + result.closed = true; + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + if !result.closed { + result.error = Some(format!("{label} did not close cleanly")); + } else if result.error.is_none() { + result.stage = "complete".to_string(); + } + result +} + +#[tauri::command] +async fn run_secondary_window_smoke( + app_handle: tauri::AppHandle, +) -> Result { + let sender = app_handle + .state::() + .sender + .clone(); + let mut receiver = sender.subscribe(); + let mut windows = Vec::new(); + for label in [ + SETTINGS_WINDOW_LABEL, + TYPING_INVADERS_WINDOW_LABEL, + KEYBOARD_SELF_TEST_WINDOW_LABEL, + ] { + windows.push(smoke_secondary_window(&app_handle, &mut receiver, label).await); + } + let passed = windows.iter().all(|result| result.error.is_none()); + let report = QualitySmokeReport { + schema_version: 1, + platform: std::env::consts::OS.to_string(), + passed, + windows, + }; + if let Ok(path) = std::env::var("KEYBOARD_HELPER_SMOKE_REPORT") { + if let Ok(json) = serde_json::to_string_pretty(&report) { + let _ = std::fs::write(path, json); + } + } + let exit_handle = app_handle.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(250)).await; + exit_handle.exit(if passed { 0 } else { 1 }); + }); + Ok(report) +} + #[tauri::command] fn start_keyboard_listener(app_handle: tauri::AppHandle, state: State) { // Если уже запущен — второй раз не стартуем @@ -984,6 +1235,7 @@ fn main() { .manage(BleLayerSyncTauriState::default()) .manage(MacosInputSourceTauriState::default()) .manage(OverlayGeometryState::default()) + .manage(SecondaryWindowReadinessState::default()) .setup(|app| { build_tray(app.handle())?; #[cfg(target_os = "macos")] @@ -1020,6 +1272,9 @@ fn main() { open_typing_invaders, open_settings, open_keyboard_self_test, + secondary_window_ready, + quality_smoke_requested, + run_secondary_window_smoke, read_config_state, save_config, read_layout_file, diff --git a/src-tauri/tests/golden_fixtures.rs b/src-tauri/tests/golden_fixtures.rs new file mode 100644 index 0000000..d1e0489 --- /dev/null +++ b/src-tauri/tests/golden_fixtures.rs @@ -0,0 +1,31 @@ +use serde_json::Value; + +fn fixture(source: &str) -> Value { + serde_json::from_str(source).expect("golden fixture must contain valid JSON") +} + +#[test] +fn layout_fixtures_keep_required_contract_fields() { + for source in [ + include_str!("../../tests/fixtures/layouts/qwerty-minimal.json"), + include_str!("../../tests/fixtures/layouts/corne-connected.json"), + include_str!("../../tests/fixtures/layouts/external-minimal.json"), + ] { + let value = fixture(source); + assert!(value["name"].is_string()); + assert!(value["keyPositions"].is_array()); + assert!(value["keyLayers"].is_object() || value["keyLayers"].is_array()); + } +} + +#[test] +fn ble_session_fixtures_cover_read_only_and_writable_states() { + let read_only = fixture(include_str!( + "../../tests/fixtures/ble/read-only-session.json" + )); + let writable = fixture(include_str!( + "../../tests/fixtures/ble/writable-session.json" + )); + assert_eq!(read_only["writable"], false); + assert_eq!(writable["writable"], true); +} diff --git a/src/main.js b/src/main.js index 18c0945..153d581 100644 --- a/src/main.js +++ b/src/main.js @@ -274,7 +274,7 @@ async function selectLanguage(inputSourceId) { } } -function getAllowedLayoutKeys(config) { +function getAllowedLayoutKeys(_config) { const availableKeys = Object.keys(layoutDefinitions); return availableKeys; } @@ -539,28 +539,6 @@ function applyLayer(index) { renderLayerIndicator(); } -function shiftCorne() { - applyLayer(1); -} - -function normalCorne() { - applyLayer(0); -} - -function setDactylDefault() { - applyLayer(0); -} - -function setDactylLower() { - console.log("Setting Dactyl lower layer"); - applyLayer(1); -} - -function setDactylMagic() { - console.log("Setting Dactyl lower layer"); - applyLayer(2); -} - function parseToggleHotkey(str) { if (!str) return null; const parts = str.split("+").map((p) => p.trim()); @@ -903,4 +881,7 @@ window.addEventListener("DOMContentLoaded", async () => { if (layoutLoadErrors.length) { showLayoutError(`${layoutLoadErrors[0]} A built-in fallback is active.`); } + if (tauri?.core?.invoke && await tauri.core.invoke("quality_smoke_requested")) { + await tauri.core.invoke("run_secondary_window_smoke"); + } }); diff --git a/src/quality_diagnostics.js b/src/quality_diagnostics.js new file mode 100644 index 0000000..d861620 --- /dev/null +++ b/src/quality_diagnostics.js @@ -0,0 +1,20 @@ +const REDACTED = "[REDACTED]"; +const SENSITIVE_KEY = /(authorization|cookie|credential|password|secret|token|typed.?text|raw.?text|raw.?key|config(?:uration)?(?:value)?)/i; + +function sanitizeString(value) { + return value + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]") + .replace(/\b[A-Za-z]:\\Users\\[^\\\s]+\\[^\s]*/g, "[PRIVATE_PATH]") + .replace(/\/(?:home|Users)\/[^/\s]+\/[^\s]*/g, "[PRIVATE_PATH]"); +} + +export function redactDiagnostic(value) { + if (Array.isArray(value)) return value.map(redactDiagnostic); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [ + key, + SENSITIVE_KEY.test(key) ? REDACTED : redactDiagnostic(entry), + ])); + } + return typeof value === "string" ? sanitizeString(value) : value; +} diff --git a/src/secondary_window_ready.js b/src/secondary_window_ready.js new file mode 100644 index 0000000..69bce3c --- /dev/null +++ b/src/secondary_window_ready.js @@ -0,0 +1,60 @@ +export const SECONDARY_WINDOWS = Object.freeze({ + settings: Object.freeze({ label: "settings", page: "settings.html", capability: "settings" }), + typingInvaders: Object.freeze({ label: "typing-invaders", page: "game.html", capability: "typing-invaders" }), + selfTest: Object.freeze({ label: "keyboard-self-test", page: "self-test.html", capability: "self-test" }), +}); + +function errorMessage(error) { + const message = typeof error === "string" ? error : error?.message; + return String(message || "initialization failed").slice(0, 240); +} + +export async function initializeSecondaryWindow({ + invoke, + label, + initialize, + failureStage = "initialize", +}) { + try { + await initialize(); + await invoke?.("secondary_window_ready", { + payload: { label, state: "ready", stage: "initialized" }, + }); + } catch (error) { + try { + await invoke?.("secondary_window_ready", { + payload: { label, state: "failed", stage: failureStage, error: errorMessage(error) }, + }); + } catch { + // Preserve the initialization error when readiness reporting is unavailable. + } + throw error; + } +} + +export function createReadinessGate({ label, timeoutMs = 10_000, setTimer = setTimeout, clearTimer = clearTimeout }) { + let settled = false; + let resolveResult; + const result = new Promise((resolve) => { resolveResult = resolve; }); + const timer = setTimer(() => { + if (settled) return; + settled = true; + resolveResult({ ok: false, label, stage: "timeout", error: "readiness timeout" }); + }, timeoutMs); + + return { + result, + accept(payload) { + if (settled || payload?.label !== label) return false; + settled = true; + clearTimer(timer); + resolveResult({ + ok: payload.state === "ready", + label, + stage: payload.stage, + error: payload.error, + }); + return true; + }, + }; +} diff --git a/src/self-test.js b/src/self-test.js index ffd70f2..e335083 100644 --- a/src/self-test.js +++ b/src/self-test.js @@ -1,6 +1,7 @@ import { loadLayoutCatalog, normalizeLayerData } from "./layout_catalog.js"; import { buildTestPlan, createSelfTestController } from "./self_test/controller.js"; import { createOverlayPresentationPayload } from "./self_test/overlay_presentation.js"; +import { initializeSecondaryWindow, SECONDARY_WINDOWS } from "./secondary_window_ready.js"; const elements = Object.fromEntries([ "setupView", "activeView", "resultsView", "layoutSelect", "layerSelect", "catalogErrors", "testability", @@ -150,8 +151,14 @@ async function initialize() { } catch (error) { elements.catalogErrors.textContent = `Could not load keyboard layouts: ${error?.message ?? error}`; elements.testability.textContent = "Guided testing is unavailable."; + throw error; } } window.addEventListener("beforeunload", () => controller.dispose()); -initialize(); +initializeSecondaryWindow({ + invoke: tauri?.core?.invoke, + label: SECONDARY_WINDOWS.selfTest.label, + failureStage: "asset-loading", + initialize, +}).catch((error) => console.error("Self-test initialization failed:", error)); diff --git a/src/settings.js b/src/settings.js index 5e672b1..017fa5f 100644 --- a/src/settings.js +++ b/src/settings.js @@ -9,6 +9,7 @@ import { persistSettingsDraft, } from "./settings_actions.js"; import { parseExternalLayout } from "./app_config.js"; +import { initializeSecondaryWindow, SECONDARY_WINDOWS } from "./secondary_window_ready.js"; const elements = { form: document.getElementById("settingsForm"), @@ -275,7 +276,7 @@ async function save(event) { async function initialize() { if (!tauri?.core?.invoke) { setStatus("Settings are available in the desktop application only.", "error"); - return; + throw new Error("Tauri API is unavailable"); } try { const result = await tauri.core.invoke("read_config_state"); @@ -290,6 +291,7 @@ async function initialize() { render(); } catch (error) { setStatus(displayError(error, "Could not load settings."), "error"); + throw error; } } @@ -321,4 +323,8 @@ windowHandle?.onCloseRequested?.(async (event) => { if (await confirmDiscard()) await closeSettings(); }); -initialize(); +initializeSecondaryWindow({ + invoke: tauri?.core?.invoke, + label: SECONDARY_WINDOWS.settings.label, + initialize, +}).catch((error) => console.error("Settings initialization failed:", error)); diff --git a/src/typing_invaders/game.js b/src/typing_invaders/game.js index 9e05c7a..026dfd1 100644 --- a/src/typing_invaders/game.js +++ b/src/typing_invaders/game.js @@ -1,10 +1,17 @@ import { createTypingInvadersController } from "./controller.js"; import { createTypingInvadersGame } from "./model.js"; import { createTypingInvadersView } from "./view.js"; +import { initializeSecondaryWindow, SECONDARY_WINDOWS } from "../secondary_window_ready.js"; -window.addEventListener("DOMContentLoaded", () => { - const game = createTypingInvadersGame(); - const view = createTypingInvadersView(document); - const controller = createTypingInvadersController({ game, view }); - controller.mount(); +window.addEventListener("DOMContentLoaded", async () => { + await initializeSecondaryWindow({ + invoke: window.__TAURI__?.core?.invoke, + label: SECONDARY_WINDOWS.typingInvaders.label, + initialize: async () => { + const game = createTypingInvadersGame(); + const view = createTypingInvadersView(document); + const controller = createTypingInvadersController({ game, view }); + controller.mount(); + }, + }).catch((error) => console.error("Shift-Space Invaders initialization failed:", error)); }); diff --git a/tests/app_config.test.mjs b/tests/app_config.test.mjs index 4c0b92d..55d64d6 100644 --- a/tests/app_config.test.mjs +++ b/tests/app_config.test.mjs @@ -14,13 +14,9 @@ import { serializeConfig, validateConfigDraft, } from "../src/app_config.js"; +import { readJsonFixture } from "./fixture_helpers.mjs"; -const validLayout = { - name: "My Board", - keySize: { w: 50, h: 50, gap: 4 }, - keyPositions: [{ row: 0, col: 0 }], - keyLayers: { default: [["A", "KeyA"]] }, -}; +const validLayout = readJsonFixture("layouts/external-minimal.json"); test("defaults enable every built-in layout and select QWERTY", () => { const config = createDefaultConfig(); diff --git a/tests/compatibility/fixture_contract.test.mjs b/tests/compatibility/fixture_contract.test.mjs new file mode 100644 index 0000000..bd18768 --- /dev/null +++ b/tests/compatibility/fixture_contract.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +test("future contract fixture areas remain explicitly reserved", async () => { + const notes = await readFile(path.join(root, "tests/fixtures/future/README.md"), "utf8"); + for (const area of ["analytics", "lessons", "mobile", "BLE[- ]events"]) { + assert.match(notes, new RegExp(area, "i")); + } +}); diff --git a/tests/compatibility/release_workflow.test.mjs b/tests/compatibility/release_workflow.test.mjs new file mode 100644 index 0000000..3357a79 --- /dev/null +++ b/tests/compatibility/release_workflow.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +test("release publication is downstream of quality, version, and platform packages", async () => { + const workflow = await readFile(path.join(root, ".github/workflows/tauri-release.yml"), "utf8"); + const publish = workflow.slice(workflow.indexOf("\n publish:")); + const beforePublish = workflow.slice(0, workflow.indexOf("\n publish:")); + assert.match(publish, /needs: \[quality, prepare, build\]/); + assert.match(publish, /gh release create/); + assert.doesNotMatch(beforePublish, /gh release create|actions\/create-release/); + assert.match(workflow, /windows-secondary-window-smoke/); +}); diff --git a/tests/fixture_helpers.mjs b/tests/fixture_helpers.mjs new file mode 100644 index 0000000..6a3aecf --- /dev/null +++ b/tests/fixture_helpers.mjs @@ -0,0 +1,10 @@ +import { readFileSync } from "node:fs"; + +export function readFixture(relativePath) { + const url = new URL(`fixtures/${relativePath}`, import.meta.url); + return readFileSync(url, "utf8"); +} + +export function readJsonFixture(relativePath) { + return JSON.parse(readFixture(relativePath)); +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..c46120e --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,16 @@ +# Golden product fixtures + +These files are reviewed examples of supported Keyboard Helper metadata. Tests +should reuse them before inventing a private schema variant. + +- `layouts/` contains minimal layouts that preserve the same positional, + layered, combo/chord, BLE, and input-source shapes as production layouts. +- `configs/` contains startup, external-layout, and intentionally invalid draft + configurations. +- `ble/` contains transport-state examples; no physical BLE device is required. +- `future/` reserves versioned locations for approved analytics, lesson, mobile, + and BLE-event contracts. Files there are documentation until the owning + OpenSpec change defines their runtime schema. + +Changing a fixture is a contract change: update every consumer and keep the +invalid examples invalid for the documented reason. diff --git a/tests/fixtures/ble/read-only-session.json b/tests/fixtures/ble/read-only-session.json new file mode 100644 index 0000000..025948b --- /dev/null +++ b/tests/fixtures/ble/read-only-session.json @@ -0,0 +1,7 @@ +{ + "layout": "corne", + "state": "connected", + "writable": false, + "layer": 0, + "expectedControl": "disabled" +} diff --git a/tests/fixtures/ble/writable-session.json b/tests/fixtures/ble/writable-session.json new file mode 100644 index 0000000..1036ee8 --- /dev/null +++ b/tests/fixtures/ble/writable-session.json @@ -0,0 +1,7 @@ +{ + "layout": "corne", + "state": "connected", + "writable": true, + "layer": 2, + "expectedControl": "enabled" +} diff --git a/tests/fixtures/configs/baseline.json b/tests/fixtures/configs/baseline.json new file mode 100644 index 0000000..2a658fe --- /dev/null +++ b/tests/fixtures/configs/baseline.json @@ -0,0 +1,5 @@ +{ + "defaultLayout": "qwerty", + "toggleHotkey": "Shift+Meta+KeyK", + "layouts": { "qwerty": true, "corne": true } +} diff --git a/tests/fixtures/configs/external.json b/tests/fixtures/configs/external.json new file mode 100644 index 0000000..8549912 --- /dev/null +++ b/tests/fixtures/configs/external.json @@ -0,0 +1,5 @@ +{ + "defaultLayout": "external", + "toggleHotkey": null, + "layouts": { "external": "tests/fixtures/layouts/external-minimal.json" } +} diff --git a/tests/fixtures/configs/malformed.json b/tests/fixtures/configs/malformed.json new file mode 100644 index 0000000..b611159 --- /dev/null +++ b/tests/fixtures/configs/malformed.json @@ -0,0 +1,6 @@ +{ + "defaultLayout": "missing", + "toggleHotkey": "Shift", + "layouts": { "disabled": false }, + "expectedDiagnostic": "no enabled layouts and no valid shortcut" +} diff --git a/tests/fixtures/future/README.md b/tests/fixtures/future/README.md new file mode 100644 index 0000000..931753d --- /dev/null +++ b/tests/fixtures/future/README.md @@ -0,0 +1,11 @@ +# Reserved fixture contracts + +Add fixtures here only with the OpenSpec change that owns their behavior: + +- `analytics/` — aggregate-only analytics and privacy-mode examples. +- `lessons/` — versioned lesson targets and compatibility examples. +- `mobile/` — mobile layout metadata and BLE lifecycle examples. +- `ble-events/` — versioned capability and event frames. + +Each future fixture must state its schema version, expected compatibility or +rejection behavior, and prohibited sensitive fields where applicable. diff --git a/tests/fixtures/layouts/corne-connected.json b/tests/fixtures/layouts/corne-connected.json new file mode 100644 index 0000000..c08e6f8 --- /dev/null +++ b/tests/fixtures/layouts/corne-connected.json @@ -0,0 +1,53 @@ +{ + "name": "Golden Corne", + "keySize": { "w": 46, "h": 46, "gap": 4 }, + "keyPositions": [ + { "row": 0, "col": 0 }, + { "row": 0, "col": 1 }, + { "row": 1, "col": 0 }, + { "row": 1, "col": 1 }, + { "row": 2, "col": 0, "angle": 12 } + ], + "keyLayers": { + "de": [["q", "KeyQ"], ["w", "KeyW"], ["a", "KeyA"], ["s", "KeyS"], ["LANG", "F18"]], + "deShift": [["Q", "Shift+KeyQ"], ["W", "Shift+KeyW"], ["A", "Shift+KeyA"], ["S", "Shift+KeyS"], null], + "ru": [["й", "KeyQ"], ["ц", "KeyW"], ["ф", "KeyA"], ["ы", "KeyS"], ["LANG", "F18"]], + "ruShift": [["Й", "Shift+KeyQ"], ["Ц", "Shift+KeyW"], ["Ф", "Shift+KeyA"], ["Ы", "Shift+KeyS"], null], + "utility": [["BT", "F19"], null, null, null, null] + }, + "combos": [ + { + "id": "escape-combo", + "key1": { "row": 0, "col": 0 }, + "key2": { "row": 0, "col": 1 }, + "code": "Escape" + } + ], + "bleLayerSource": { + "deviceName": "Golden Corne", + "serviceUuid": "12345678-1234-5678-1234-56789abcdef0", + "characteristicUuid": "12345678-1234-5678-1234-56789abcdef1", + "format": "int32-le" + }, + "inputSourceSync": { + "macos": { + "sources": [ + { + "id": "de", + "label": "Deutsch", + "inputSourceId": "com.apple.keylayout.German", + "baseLayer": 0, + "layers": [0, 1] + }, + { + "id": "ru", + "label": "Русский", + "inputSourceId": "com.apple.keylayout.Russian", + "baseLayer": 2, + "layers": [2, 3] + } + ], + "neutralLayers": [4] + } + } +} diff --git a/tests/fixtures/layouts/external-minimal.json b/tests/fixtures/layouts/external-minimal.json new file mode 100644 index 0000000..a69a90f --- /dev/null +++ b/tests/fixtures/layouts/external-minimal.json @@ -0,0 +1,6 @@ +{ + "name": "My Board", + "keySize": { "w": 44, "h": 44, "gap": 3 }, + "keyPositions": [{ "row": 0, "col": 0 }], + "keyLayers": { "default": [["X", "KeyX"]] } +} diff --git a/tests/fixtures/layouts/qwerty-minimal.json b/tests/fixtures/layouts/qwerty-minimal.json new file mode 100644 index 0000000..0daa165 --- /dev/null +++ b/tests/fixtures/layouts/qwerty-minimal.json @@ -0,0 +1,13 @@ +{ + "name": "Golden QWERTY", + "keySize": { "w": 48, "h": 48, "gap": 4 }, + "keyPositions": [ + { "row": 0, "col": 0 }, + { "row": 0, "col": 1 }, + { "row": 0, "col": 2 } + ], + "keyLayers": { + "default": [["a", "KeyA"], ["b", "KeyB"], ["Shift", "ShiftLeft"]], + "shift": [["A", "Shift+KeyA"], ["B", "Shift+KeyB"], null] + } +} diff --git a/tests/golden_fixtures.test.mjs b/tests/golden_fixtures.test.mjs new file mode 100644 index 0000000..43e4afd --- /dev/null +++ b/tests/golden_fixtures.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + normalizeConfig, + parseExternalLayout, + validateConfigDraft, +} from "../src/app_config.js"; +import { normalizeBleLayerSource } from "../src/ble_layer_sync.js"; +import { normalizeInputSourceSync } from "../src/input_source_sync_config.js"; +import { readFixture, readJsonFixture } from "./fixture_helpers.mjs"; + +test("golden baseline and external configurations normalize consistently", () => { + const baseline = normalizeConfig(readJsonFixture("configs/baseline.json")); + assert.equal(baseline.defaultLayout, "qwerty"); + assert.equal(validateConfigDraft(baseline).valid, true); + + const external = normalizeConfig(readJsonFixture("configs/external.json")); + assert.equal(external.defaultLayout, "external"); + assert.equal(validateConfigDraft(external).valid, true); +}); + +test("golden malformed configuration remains invalid for the documented reasons", () => { + const malformed = readJsonFixture("configs/malformed.json"); + const result = validateConfigDraft(malformed); + assert.equal(result.valid, false); + assert.ok(result.errors.defaultLayout); + assert.ok(result.errors.layouts); +}); + +test("golden layout fixtures are accepted by the external layout parser", () => { + for (const fixture of [ + "layouts/qwerty-minimal.json", + "layouts/corne-connected.json", + "layouts/external-minimal.json", + ]) { + assert.equal(parseExternalLayout(readFixture(fixture)).valid, true, fixture); + } +}); + +test("golden Corne fixture exercises layer, combo, BLE, and input-source contracts", () => { + const corne = readJsonFixture("layouts/corne-connected.json"); + assert.equal(Object.keys(corne.keyLayers).length, 5); + assert.equal(corne.combos[0].id, "escape-combo"); + assert.equal(normalizeBleLayerSource(corne)?.format, "int32-le"); + assert.equal(normalizeInputSourceSync(corne, 5, { platform: "macos" }).error, null); +}); + +test("golden BLE states distinguish read-only and writable sessions", () => { + assert.equal(readJsonFixture("ble/read-only-session.json").writable, false); + assert.equal(readJsonFixture("ble/writable-session.json").writable, true); +}); diff --git a/tests/input_source_sync_config.test.mjs b/tests/input_source_sync_config.test.mjs index 324d034..d1dcd41 100644 --- a/tests/input_source_sync_config.test.mjs +++ b/tests/input_source_sync_config.test.mjs @@ -5,30 +5,9 @@ import { detectRuntimePlatform, normalizeInputSourceSync, } from "../src/input_source_sync_config.js"; +import { readJsonFixture } from "./fixture_helpers.mjs"; -const validDefinition = { - inputSourceSync: { - macos: { - sources: [ - { - id: "de", - label: "Deutsch", - inputSourceId: "com.apple.keylayout.German", - baseLayer: 0, - layers: [0, 1], - }, - { - id: "ru", - label: "Русский", - inputSourceId: "com.apple.keylayout.Russian", - baseLayer: 2, - layers: [2, 3], - }, - ], - neutralLayers: [4], - }, - }, -}; +const validDefinition = readJsonFixture("layouts/corne-connected.json"); test("detectRuntimePlatform recognizes supported desktop families", () => { assert.equal(detectRuntimePlatform({ platform: "MacIntel" }), "macos"); diff --git a/tests/privacy/diagnostic_redaction.test.mjs b/tests/privacy/diagnostic_redaction.test.mjs new file mode 100644 index 0000000..edc376b --- /dev/null +++ b/tests/privacy/diagnostic_redaction.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { redactDiagnostic } from "../../src/quality_diagnostics.js"; + +test("retained diagnostics omit private input, credentials, tokens, and config values", () => { + const diagnostic = redactDiagnostic({ + stage: "asset-loading", + typedText: "a private sentence", + rawKeyLog: ["KeyA", "KeyB"], + password: "hunter2", + nested: { token: "abc.123", config: { defaultLayout: "private-board" } }, + error: "Bearer abc.def failed for C:\\Users\\maxim\\private\\layout.json", + }); + const retained = JSON.stringify(diagnostic); + for (const secret of ["private sentence", "KeyA", "hunter2", "abc.123", "private-board", "maxim"]) { + assert.doesNotMatch(retained, new RegExp(secret.replace(".", "\\."))); + } + assert.match(retained, /asset-loading/); + assert.match(retained, /REDACTED/); +}); diff --git a/tests/secondary_window_capabilities.test.mjs b/tests/secondary_window_capabilities.test.mjs new file mode 100644 index 0000000..fdce2f8 --- /dev/null +++ b/tests/secondary_window_capabilities.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { SECONDARY_WINDOWS } from "../src/secondary_window_ready.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +test("every dynamic secondary window has a page, capability, and Rust label", async () => { + const rust = await readFile(path.join(root, "src-tauri/src/main.rs"), "utf8"); + for (const window of Object.values(SECONDARY_WINDOWS)) { + await readFile(path.join(root, "src", window.page), "utf8"); + const capability = JSON.parse(await readFile( + path.join(root, `src-tauri/capabilities/${window.capability}.json`), + "utf8", + )); + assert.ok(capability.windows.includes(window.label)); + assert.match(rust, new RegExp(`\\"${window.label}\\"`)); + } +}); diff --git a/tests/secondary_window_ready.test.mjs b/tests/secondary_window_ready.test.mjs new file mode 100644 index 0000000..04e3bd1 --- /dev/null +++ b/tests/secondary_window_ready.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createReadinessGate, initializeSecondaryWindow } from "../src/secondary_window_ready.js"; + +test("matching readiness resolves a gate and a duplicate is ignored", async () => { + const gate = createReadinessGate({ label: "settings" }); + assert.equal(gate.accept({ label: "settings", state: "ready", stage: "initialized" }), true); + assert.equal(gate.accept({ label: "settings", state: "ready", stage: "duplicate" }), false); + assert.deepEqual(await gate.result, { ok: true, label: "settings", stage: "initialized", error: undefined }); +}); + +test("stale readiness is ignored", async () => { + const gate = createReadinessGate({ label: "settings" }); + assert.equal(gate.accept({ label: "typing-invaders", state: "ready" }), false); + assert.equal(gate.accept({ label: "settings", state: "ready", stage: "initialized" }), true); + assert.equal((await gate.result).ok, true); +}); + +test("failed readiness reports a bounded error", async () => { + const calls = []; + const error = new Error("broken window"); + await assert.rejects(() => initializeSecondaryWindow({ + label: "settings", + invoke: async (command, args) => calls.push([command, args]), + initialize: async () => { throw error; }, + }), error); + assert.equal(calls[0][1].payload.state, "failed"); + assert.equal(calls[0][1].payload.error, "broken window"); +}); + +test("readiness timeout settles without hanging", async () => { + let callback; + const gate = createReadinessGate({ + label: "settings", + timeoutMs: 25, + setTimer: (fn) => { callback = fn; return 1; }, + clearTimer: () => {}, + }); + callback(); + assert.deepEqual(await gate.result, { + ok: false, + label: "settings", + stage: "timeout", + error: "readiness timeout", + }); +}); From b6370128695a23d52f016484f97677359de6a6d8 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Mon, 31 Aug 2026 03:44:03 +0200 Subject: [PATCH 02/13] remove vendored rdev --- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 3 +- src-tauri/vendor/rdev/.cargo-ok | 1 - src-tauri/vendor/rdev/.cargo_vcs_info.json | 6 - .../vendor/rdev/.github/workflows/rust.yml | 48 -- src-tauri/vendor/rdev/.gitignore | 2 - src-tauri/vendor/rdev/Cargo.toml | 126 ---- src-tauri/vendor/rdev/Cargo.toml.orig | 64 --- src-tauri/vendor/rdev/LICENSE | 21 - src-tauri/vendor/rdev/README.md | 8 - src-tauri/vendor/rdev/README.tpl | 8 - src-tauri/vendor/rdev/examples/channel.rs | 22 - src-tauri/vendor/rdev/examples/display.rs | 6 - src-tauri/vendor/rdev/examples/grab.rs | 19 - .../vendor/rdev/examples/keyboard_state.rs | 18 - src-tauri/vendor/rdev/examples/listen.rs | 12 - src-tauri/vendor/rdev/examples/serialize.rs | 18 - src-tauri/vendor/rdev/examples/simulate.rs | 28 - .../vendor/rdev/examples/tokio_channel.rs | 22 - src-tauri/vendor/rdev/rustfmt.toml | 1 - src-tauri/vendor/rdev/src/lib.rs | 410 ------------- src-tauri/vendor/rdev/src/linux/common.rs | 138 ----- src-tauri/vendor/rdev/src/linux/display.rs | 7 - src-tauri/vendor/rdev/src/linux/grab.rs | 541 ------------------ src-tauri/vendor/rdev/src/linux/keyboard.rs | 269 --------- src-tauri/vendor/rdev/src/linux/keycodes.rs | 161 ------ src-tauri/vendor/rdev/src/linux/listen.rs | 116 ---- src-tauri/vendor/rdev/src/linux/mod.rs | 18 - src-tauri/vendor/rdev/src/linux/simulate.rs | 99 ---- src-tauri/vendor/rdev/src/macos/common.rs | 148 ----- src-tauri/vendor/rdev/src/macos/display.rs | 7 - src-tauri/vendor/rdev/src/macos/grab.rs | 65 --- src-tauri/vendor/rdev/src/macos/keyboard.rs | 188 ------ src-tauri/vendor/rdev/src/macos/keycodes.rs | 291 ---------- src-tauri/vendor/rdev/src/macos/listen.rs | 65 --- src-tauri/vendor/rdev/src/macos/mod.rs | 15 - src-tauri/vendor/rdev/src/macos/simulate.rs | 97 ---- src-tauri/vendor/rdev/src/rdev.rs | 303 ---------- src-tauri/vendor/rdev/src/windows/common.rs | 127 ---- src-tauri/vendor/rdev/src/windows/display.rs | 17 - src-tauri/vendor/rdev/src/windows/grab.rs | 59 -- src-tauri/vendor/rdev/src/windows/keyboard.rs | 175 ------ src-tauri/vendor/rdev/src/windows/keycodes.rs | 164 ------ src-tauri/vendor/rdev/src/windows/listen.rs | 56 -- src-tauri/vendor/rdev/src/windows/mod.rs | 17 - src-tauri/vendor/rdev/src/windows/simulate.rs | 133 ----- src-tauri/vendor/rdev/tests/grab.rs | 72 --- .../vendor/rdev/tests/listen_and_simulate.rs | 74 --- 48 files changed, 3 insertions(+), 4264 deletions(-) delete mode 100644 src-tauri/vendor/rdev/.cargo-ok delete mode 100644 src-tauri/vendor/rdev/.cargo_vcs_info.json delete mode 100644 src-tauri/vendor/rdev/.github/workflows/rust.yml delete mode 100644 src-tauri/vendor/rdev/.gitignore delete mode 100644 src-tauri/vendor/rdev/Cargo.toml delete mode 100644 src-tauri/vendor/rdev/Cargo.toml.orig delete mode 100644 src-tauri/vendor/rdev/LICENSE delete mode 100644 src-tauri/vendor/rdev/README.md delete mode 100644 src-tauri/vendor/rdev/README.tpl delete mode 100644 src-tauri/vendor/rdev/examples/channel.rs delete mode 100644 src-tauri/vendor/rdev/examples/display.rs delete mode 100644 src-tauri/vendor/rdev/examples/grab.rs delete mode 100644 src-tauri/vendor/rdev/examples/keyboard_state.rs delete mode 100644 src-tauri/vendor/rdev/examples/listen.rs delete mode 100644 src-tauri/vendor/rdev/examples/serialize.rs delete mode 100644 src-tauri/vendor/rdev/examples/simulate.rs delete mode 100644 src-tauri/vendor/rdev/examples/tokio_channel.rs delete mode 100644 src-tauri/vendor/rdev/rustfmt.toml delete mode 100644 src-tauri/vendor/rdev/src/lib.rs delete mode 100644 src-tauri/vendor/rdev/src/linux/common.rs delete mode 100644 src-tauri/vendor/rdev/src/linux/display.rs delete mode 100644 src-tauri/vendor/rdev/src/linux/grab.rs delete mode 100644 src-tauri/vendor/rdev/src/linux/keyboard.rs delete mode 100644 src-tauri/vendor/rdev/src/linux/keycodes.rs delete mode 100644 src-tauri/vendor/rdev/src/linux/listen.rs delete mode 100644 src-tauri/vendor/rdev/src/linux/mod.rs delete mode 100644 src-tauri/vendor/rdev/src/linux/simulate.rs delete mode 100644 src-tauri/vendor/rdev/src/macos/common.rs delete mode 100644 src-tauri/vendor/rdev/src/macos/display.rs delete mode 100644 src-tauri/vendor/rdev/src/macos/grab.rs delete mode 100644 src-tauri/vendor/rdev/src/macos/keyboard.rs delete mode 100644 src-tauri/vendor/rdev/src/macos/keycodes.rs delete mode 100644 src-tauri/vendor/rdev/src/macos/listen.rs delete mode 100644 src-tauri/vendor/rdev/src/macos/mod.rs delete mode 100644 src-tauri/vendor/rdev/src/macos/simulate.rs delete mode 100644 src-tauri/vendor/rdev/src/rdev.rs delete mode 100644 src-tauri/vendor/rdev/src/windows/common.rs delete mode 100644 src-tauri/vendor/rdev/src/windows/display.rs delete mode 100644 src-tauri/vendor/rdev/src/windows/grab.rs delete mode 100644 src-tauri/vendor/rdev/src/windows/keyboard.rs delete mode 100644 src-tauri/vendor/rdev/src/windows/keycodes.rs delete mode 100644 src-tauri/vendor/rdev/src/windows/listen.rs delete mode 100644 src-tauri/vendor/rdev/src/windows/mod.rs delete mode 100644 src-tauri/vendor/rdev/src/windows/simulate.rs delete mode 100644 src-tauri/vendor/rdev/tests/grab.rs delete mode 100644 src-tauri/vendor/rdev/tests/listen_and_simulate.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0078185..358267d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3211,6 +3211,8 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "rdev" version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00552ca2dc2f93b84cd7b5581de49549411e4e41d89e1c691bcb93dc4be360c3" dependencies = [ "cocoa", "core-foundation 0.7.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index eb0f9a0..d137cb4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,8 +30,7 @@ serde_json = "1" tokio = { version = "1", features = ["full"] } uuid = { version = "1", features = ["v4"] } -# Vendored rdev with macOS key-name lookup disabled to avoid macOS 15 event tap crash -rdev = { path = "vendor/rdev" } +rdev = "0.5.3" [target.'cfg(target_vendor = "apple")'.dependencies] objc2 = "0.5.2" diff --git a/src-tauri/vendor/rdev/.cargo-ok b/src-tauri/vendor/rdev/.cargo-ok deleted file mode 100644 index 5f8b795..0000000 --- a/src-tauri/vendor/rdev/.cargo-ok +++ /dev/null @@ -1 +0,0 @@ -{"v":1} \ No newline at end of file diff --git a/src-tauri/vendor/rdev/.cargo_vcs_info.json b/src-tauri/vendor/rdev/.cargo_vcs_info.json deleted file mode 100644 index 8ce4450..0000000 --- a/src-tauri/vendor/rdev/.cargo_vcs_info.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "git": { - "sha1": "0e2a1c8bb0c2b58f31ed3105c3a800695f7497f9" - }, - "path_in_vcs": "" -} \ No newline at end of file diff --git a/src-tauri/vendor/rdev/.github/workflows/rust.yml b/src-tauri/vendor/rdev/.github/workflows/rust.yml deleted file mode 100644 index ec1b994..0000000 --- a/src-tauri/vendor/rdev/.github/workflows/rust.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: build - -on: [push, pull_request] - -jobs: - build: - - runs-on: ${{matrix.os}} - env: - DISPLAY: ':99' - strategy: - fail-fast: false - matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - include: - - os: ubuntu-latest - headless: Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & - - os: ubuntu-latest - dependencies: sudo apt-get install libxtst-dev libevdev-dev --assume-yes - - os: macos-latest - # TODO: We can't test this on github, we can't set accessibility yet. - test: cargo test --verbose --all-features -- --skip test_listen_and_simulate --skip test_grab - - os: ubuntu-latest - # TODO unstable_grab feature is not supported on Linux. - test: cargo test --verbose --features=serialize - - os: windows-latest - test: cargo test --verbose --all-features - - steps: - - uses: actions/checkout@v2 - - name: CargoFmt - run: rustup component add rustfmt - - name: Dependencies - run: ${{matrix.dependencies}} - - name: Setup headless environment - run: ${{matrix.headless}} - - name: Check formatting - run: | - rustup component add rustfmt - cargo fmt -- --check - - name: Build - run: cargo build --verbose - - name: Run tests - run: ${{matrix.test}} - - name: Linter - run: | - rustup component add clippy - cargo clippy --all-features --verbose -- -Dwarnings diff --git a/src-tauri/vendor/rdev/.gitignore b/src-tauri/vendor/rdev/.gitignore deleted file mode 100644 index 96ef6c0..0000000 --- a/src-tauri/vendor/rdev/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target -Cargo.lock diff --git a/src-tauri/vendor/rdev/Cargo.toml b/src-tauri/vendor/rdev/Cargo.toml deleted file mode 100644 index 4eb06b5..0000000 --- a/src-tauri/vendor/rdev/Cargo.toml +++ /dev/null @@ -1,126 +0,0 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies. -# -# If you are reading this file be aware that the original Cargo.toml -# will likely look very different (and much more reasonable). -# See Cargo.toml.orig for the original contents. - -[package] -edition = "2018" -name = "rdev" -version = "0.5.3" -authors = ["Nicolas Patry "] -description = "Listen and send keyboard and mouse events on Windows, Linux and MacOS." -homepage = "https://github.com/Narsil/rdev" -documentation = "https://docs.rs/rdev/" -readme = "README.md" -keywords = [ - "input", - "mouse", - "testing", - "keyboard", - "automation", -] -categories = [ - "development-tools::testing", - "api-bindings", - "hardware-support", -] -license = "MIT" -repository = "https://github.com/Narsil/rdev" - -[[example]] -name = "serialize" -required-features = ["serialize"] - -[[example]] -name = "grab" -required-features = ["unstable_grab"] - -[[example]] -name = "tokio_channel" -required-features = ["unstable_grab"] - -[[test]] -name = "grab" -path = "tests/grab.rs" -required-features = ["unstable_grab"] - -[dependencies.lazy_static] -version = "1.4" - -[dependencies.serde] -version = "1.0" -features = ["derive"] -optional = true - -[dev-dependencies.serde_json] -version = "1.0" - -[dev-dependencies.serial_test] -version = "0.4" - -[dev-dependencies.tokio] -version = "1.5" -features = [ - "sync", - "macros", - "rt-multi-thread", -] - -[features] -serialize = ["serde"] -unstable_grab = [ - "evdev-rs", - "epoll", - "inotify", -] - -[target."cfg(target_os = \"linux\")".dependencies.epoll] -version = "4.1.0" -optional = true - -[target."cfg(target_os = \"linux\")".dependencies.evdev-rs] -version = "0.4.0" -optional = true - -[target."cfg(target_os = \"linux\")".dependencies.inotify] -version = "0.8.2" -optional = true -default-features = false - -[target."cfg(target_os = \"linux\")".dependencies.libc] -version = "0.2" - -[target."cfg(target_os = \"linux\")".dependencies.x11] -version = "2.18" -features = [ - "xlib", - "xrecord", - "xinput", -] - -[target."cfg(target_os = \"macos\")".dependencies.cocoa] -version = "0.22" - -[target."cfg(target_os = \"macos\")".dependencies.core-foundation] -version = "0.7" - -[target."cfg(target_os = \"macos\")".dependencies.core-foundation-sys] -version = "0.7" - -[target."cfg(target_os = \"macos\")".dependencies.core-graphics] -version = "0.19.0" -features = ["highsierra"] - -[target."cfg(target_os = \"windows\")".dependencies.winapi] -version = "0.3" -features = [ - "winuser", - "errhandlingapi", - "processthreadsapi", -] diff --git a/src-tauri/vendor/rdev/Cargo.toml.orig b/src-tauri/vendor/rdev/Cargo.toml.orig deleted file mode 100644 index 6f37864..0000000 --- a/src-tauri/vendor/rdev/Cargo.toml.orig +++ /dev/null @@ -1,64 +0,0 @@ -[package] -name = "rdev" -version = "0.5.3" -authors = ["Nicolas Patry "] -edition = "2018" - -description = "Listen and send keyboard and mouse events on Windows, Linux and MacOS." -documentation = "https://docs.rs/rdev/" -homepage = "https://github.com/Narsil/rdev" -repository = "https://github.com/Narsil/rdev" -readme = "README.md" -keywords = ["input", "mouse", "testing", "keyboard", "automation"] -categories = ["development-tools::testing", "api-bindings", "hardware-support"] -license = "MIT" - -[dependencies] -serde = {version = "1.0", features = ["derive"], optional=true} -lazy_static = "1.4" - -[features] -serialize = ["serde"] -unstable_grab = ["evdev-rs", "epoll", "inotify"] - -[target.'cfg(target_os = "macos")'.dependencies] -cocoa = "0.22" -core-graphics = {version = "0.19.0", features = ["highsierra"]} -core-foundation = {version = "0.7"} -core-foundation-sys = {version = "0.7"} - - -[target.'cfg(target_os = "linux")'.dependencies] -libc = "0.2" -x11 = {version = "2.18", features = ["xlib", "xrecord", "xinput"]} -evdev-rs = {version = "0.4.0", optional=true} -epoll = {version = "4.1.0", optional=true} -inotify = {version = "0.8.2", default-features=false, optional=true} - -[target.'cfg(target_os = "windows")'.dependencies] -winapi = { version = "0.3", features = ["winuser", "errhandlingapi", "processthreadsapi"] } - -[dev-dependencies] -serde_json = "1.0" -# Some tests interact with the real OS. We can't hit the OS in parallel -# because that leads to unexpected behavior and flaky tests, so we need -# to run thoses tests in sequence instead. -serial_test = "0.4" -tokio = {version = "1.5", features=["sync", "macros", "rt-multi-thread"]} - -[[example]] -name = "serialize" -required-features = ["serialize"] - -[[example]] -name = "grab" -required-features = ["unstable_grab"] - -[[example]] -name = "tokio_channel" -required-features = ["unstable_grab"] - -[[test]] -name = "grab" -path = "tests/grab.rs" -required-features = ["unstable_grab"] diff --git a/src-tauri/vendor/rdev/LICENSE b/src-tauri/vendor/rdev/LICENSE deleted file mode 100644 index 5be1138..0000000 --- a/src-tauri/vendor/rdev/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020 Nicolas Patry - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src-tauri/vendor/rdev/README.md b/src-tauri/vendor/rdev/README.md deleted file mode 100644 index c228d90..0000000 --- a/src-tauri/vendor/rdev/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Vendored rdev (macOS fix) - -This copy of `rdev` is vendored to avoid a crash on macOS 15 when listening for global keyboard events. The upstream macOS implementation calls into `TIS*` APIs to derive localized key names, which triggers a dispatch queue assertion (`islGetInputSourceListWithAdditions`) when invoked off the main thread. Since this app only needs key codes, the macOS path was patched to skip key-name lookup. - -Key change: -- `src/macos/common.rs`: return `None` for `name` instead of calling `create_string_for_key` in the event tap callback. - -If upstream fixes this, we can remove this vendor directory and return to the crates.io dependency. diff --git a/src-tauri/vendor/rdev/README.tpl b/src-tauri/vendor/rdev/README.tpl deleted file mode 100644 index 9f9061e..0000000 --- a/src-tauri/vendor/rdev/README.tpl +++ /dev/null @@ -1,8 +0,0 @@ -![](https://github.com/Narsil/rdev/workflows/build/badge.svg) -[![Crate](https://img.shields.io/crates/v/rdev.svg)](https://crates.io/crates/rdev) -[![API](https://docs.rs/rdev/badge.svg)](https://docs.rs/rdev) - -# {{crate}} - -{{readme}} - diff --git a/src-tauri/vendor/rdev/examples/channel.rs b/src-tauri/vendor/rdev/examples/channel.rs deleted file mode 100644 index 5f7158a..0000000 --- a/src-tauri/vendor/rdev/examples/channel.rs +++ /dev/null @@ -1,22 +0,0 @@ -use rdev::listen; -use std::sync::mpsc::channel; -use std::thread; - -fn main() { - // spawn new thread because listen blocks - let (schan, rchan) = channel(); - let _listener = thread::spawn(move || { - listen(move |event| { - schan - .send(event) - .unwrap_or_else(|e| println!("Could not send event {:?}", e)); - }) - .expect("Could not listen"); - }); - - let mut events = Vec::new(); - for event in rchan.iter() { - println!("Received {:?}", event); - events.push(event); - } -} diff --git a/src-tauri/vendor/rdev/examples/display.rs b/src-tauri/vendor/rdev/examples/display.rs deleted file mode 100644 index b51848a..0000000 --- a/src-tauri/vendor/rdev/examples/display.rs +++ /dev/null @@ -1,6 +0,0 @@ -use rdev::display_size; -fn main() { - let (w, h) = display_size().unwrap(); - - println!("Your screen is {:?}x{:?}", w, h); -} diff --git a/src-tauri/vendor/rdev/examples/grab.rs b/src-tauri/vendor/rdev/examples/grab.rs deleted file mode 100644 index 6f7f04a..0000000 --- a/src-tauri/vendor/rdev/examples/grab.rs +++ /dev/null @@ -1,19 +0,0 @@ -use rdev::{grab, Event, EventType, Key}; - -fn main() { - // This will block. - if let Err(error) = grab(callback) { - println!("Error: {:?}", error) - } -} - -fn callback(event: Event) -> Option { - println!("My callback {:?}", event); - match event.event_type { - EventType::KeyPress(Key::Tab) => { - println!("Cancelling tab !"); - None - } - _ => Some(event), - } -} diff --git a/src-tauri/vendor/rdev/examples/keyboard_state.rs b/src-tauri/vendor/rdev/examples/keyboard_state.rs deleted file mode 100644 index 833eee9..0000000 --- a/src-tauri/vendor/rdev/examples/keyboard_state.rs +++ /dev/null @@ -1,18 +0,0 @@ -use rdev::{EventType, Key, Keyboard, KeyboardState}; - -fn main() { - let mut keyboard = Keyboard::new().unwrap(); - let char_s = keyboard.add(&EventType::KeyPress(Key::KeyS)).unwrap(); - assert_eq!(char_s, "s".to_string()); - println!("Pressing S gives: {:?}", char_s); - let n = keyboard.add(&EventType::KeyRelease(Key::KeyS)); - assert_eq!(n, None); - - keyboard.add(&EventType::KeyPress(Key::ShiftLeft)); - let char_s = keyboard.add(&EventType::KeyPress(Key::KeyS)).unwrap(); - println!("Pressing Shift+S gives: {:?}", char_s); - assert_eq!(char_s, "S".to_string()); - let n = keyboard.add(&EventType::KeyRelease(Key::KeyS)); - assert_eq!(n, None); - keyboard.add(&EventType::KeyRelease(Key::ShiftLeft)); -} diff --git a/src-tauri/vendor/rdev/examples/listen.rs b/src-tauri/vendor/rdev/examples/listen.rs deleted file mode 100644 index 3785294..0000000 --- a/src-tauri/vendor/rdev/examples/listen.rs +++ /dev/null @@ -1,12 +0,0 @@ -use rdev::{listen, Event}; - -fn main() { - // This will block. - if let Err(error) = listen(callback) { - println!("Error: {:?}", error) - } -} - -fn callback(event: Event) { - println!("My callback {:?}", event); -} diff --git a/src-tauri/vendor/rdev/examples/serialize.rs b/src-tauri/vendor/rdev/examples/serialize.rs deleted file mode 100644 index 800fceb..0000000 --- a/src-tauri/vendor/rdev/examples/serialize.rs +++ /dev/null @@ -1,18 +0,0 @@ -use rdev::{Event, EventType, Key}; -use std::time::SystemTime; - -fn main() { - let event = Event { - event_type: EventType::KeyPress(Key::KeyS), - time: SystemTime::now(), - name: Some(String::from("S")), - }; - - let serialized = serde_json::to_string(&event).unwrap(); - - let deserialized: Event = serde_json::from_str(&serialized).unwrap(); - - println!("Serialized event {:?}", serialized); - println!("Deserialized event {:?}", deserialized); - assert_eq!(event, deserialized); -} diff --git a/src-tauri/vendor/rdev/examples/simulate.rs b/src-tauri/vendor/rdev/examples/simulate.rs deleted file mode 100644 index 1f50f04..0000000 --- a/src-tauri/vendor/rdev/examples/simulate.rs +++ /dev/null @@ -1,28 +0,0 @@ -use rdev::{simulate, Button, EventType, Key, SimulateError}; -use std::{thread, time}; - -fn send(event_type: &EventType) { - let delay = time::Duration::from_millis(20); - match simulate(event_type) { - Ok(()) => (), - Err(SimulateError) => { - println!("We could not send {:?}", event_type); - } - } - // Let ths OS catchup (at least MacOS) - thread::sleep(delay); -} - -fn main() { - send(&EventType::KeyPress(Key::KeyS)); - send(&EventType::KeyRelease(Key::KeyS)); - - send(&EventType::MouseMove { x: 0.0, y: 0.0 }); - send(&EventType::MouseMove { x: 400.0, y: 400.0 }); - send(&EventType::ButtonPress(Button::Left)); - send(&EventType::ButtonRelease(Button::Right)); - send(&EventType::Wheel { - delta_x: 0, - delta_y: 1, - }); -} diff --git a/src-tauri/vendor/rdev/examples/tokio_channel.rs b/src-tauri/vendor/rdev/examples/tokio_channel.rs deleted file mode 100644 index 57b29cf..0000000 --- a/src-tauri/vendor/rdev/examples/tokio_channel.rs +++ /dev/null @@ -1,22 +0,0 @@ -use rdev::listen; -use std::thread; -use tokio::sync::mpsc; - -#[tokio::main] -async fn main() { - // spawn new thread because listen blocks - let (schan, mut rchan) = mpsc::unbounded_channel(); - let _listener = thread::spawn(move || { - listen(move |event| { - schan - .send(event) - .unwrap_or_else(|e| println!("Could not send event {:?}", e)); - }) - .expect("Could not listen"); - }); - - loop { - let event = rchan.recv().await; - println!("Received {:?}", event); - } -} diff --git a/src-tauri/vendor/rdev/rustfmt.toml b/src-tauri/vendor/rdev/rustfmt.toml deleted file mode 100644 index c51666e..0000000 --- a/src-tauri/vendor/rdev/rustfmt.toml +++ /dev/null @@ -1 +0,0 @@ -edition = "2018" \ No newline at end of file diff --git a/src-tauri/vendor/rdev/src/lib.rs b/src-tauri/vendor/rdev/src/lib.rs deleted file mode 100644 index 4aee56d..0000000 --- a/src-tauri/vendor/rdev/src/lib.rs +++ /dev/null @@ -1,410 +0,0 @@ -//! Simple library to listen and send events to keyboard and mouse on MacOS, Windows and Linux -//! (x11). -//! -//! You can also check out [Enigo](https://github.com/Enigo-rs/Enigo) which is another -//! crate which helped me write this one. -//! -//! This crate is so far a pet project for me to understand the rust ecosystem. -//! -//! # Listening to global events -//! -//! ```no_run -//! use rdev::{listen, Event}; -//! -//! // This will block. -//! if let Err(error) = listen(callback) { -//! println!("Error: {:?}", error) -//! } -//! -//! fn callback(event: Event) { -//! println!("My callback {:?}", event); -//! match event.name { -//! Some(string) => println!("User wrote {:?}", string), -//! None => (), -//! } -//! } -//! ``` -//! -//! ## OS Caveats: -//! When using the `listen` function, the following caveats apply: -//! -//! ## Mac OS -//! The process running the blocking `listen` function (loop) needs to be the parent process (no fork before). -//! The process needs to be granted access to the Accessibility API (ie. if you're running your process -//! inside Terminal.app, then Terminal.app needs to be added in -//! System Preferences > Security & Privacy > Privacy > Accessibility) -//! If the process is not granted access to the Accessibility API, MacOS will silently ignore rdev's -//! `listen` calleback and will not trigger it with events. No error will be generated. -//! -//! ## Linux -//! The `listen` function uses X11 APIs, and so will not work in Wayland or in the linux kernel virtual console -//! -//! # Sending some events -//! -//! ```no_run -//! use rdev::{simulate, Button, EventType, Key, SimulateError}; -//! use std::{thread, time}; -//! -//! fn send(event_type: &EventType) { -//! let delay = time::Duration::from_millis(20); -//! match simulate(event_type) { -//! Ok(()) => (), -//! Err(SimulateError) => { -//! println!("We could not send {:?}", event_type); -//! } -//! } -//! // Let ths OS catchup (at least MacOS) -//! thread::sleep(delay); -//! } -//! -//! send(&EventType::KeyPress(Key::KeyS)); -//! send(&EventType::KeyRelease(Key::KeyS)); -//! -//! send(&EventType::MouseMove { x: 0.0, y: 0.0 }); -//! send(&EventType::MouseMove { x: 400.0, y: 400.0 }); -//! send(&EventType::ButtonPress(Button::Left)); -//! send(&EventType::ButtonRelease(Button::Right)); -//! send(&EventType::Wheel { -//! delta_x: 0, -//! delta_y: 1, -//! }); -//! ``` -//! # Main structs -//! ## Event -//! -//! In order to detect what a user types, we need to plug to the OS level management -//! of keyboard state (modifiers like shift, ctrl, but also dead keys if they exist). -//! -//! `EventType` corresponds to a *physical* event, corresponding to QWERTY layout -//! `Event` corresponds to an actual event that was received and `Event.name` reflects -//! what key was interpreted by the OS at that time, it will respect the layout. -//! -//! ```no_run -//! # use crate::rdev::EventType; -//! # use std::time::SystemTime; -//! /// When events arrive from the system we can add some information -//! /// time is when the event was received. -//! #[derive(Debug)] -//! pub struct Event { -//! pub time: SystemTime, -//! pub name: Option, -//! pub event_type: EventType, -//! } -//! ``` -//! -//! Be careful, Event::name, might be None, but also String::from(""), and might contain -//! not displayable unicode characters. We send exactly what the OS sends us so do some sanity checking -//! before using it. -//! Caveat: Dead keys don't function yet on Linux -//! -//! ## EventType -//! -//! In order to manage different OS, the current EventType choices is a mix&match -//! to account for all possible events. -//! There is a safe mechanism to detect events no matter what, which are the -//! Unknown() variant of the enum which will contain some OS specific value. -//! Also not that not all keys are mapped to an OS code, so simulate might fail if you -//! try to send an unmapped key. Sending Unknown() variants will always work (the OS might -//! still reject it). -//! -//! ```no_run -//! # use crate::rdev::{Key, Button}; -//! /// In order to manage different OS, the current EventType choices is a mix&match -//! /// to account for all possible events. -//! #[derive(Debug)] -//! pub enum EventType { -//! /// The keys correspond to a standard qwerty layout, they don't correspond -//! /// To the actual letter a user would use, that requires some layout logic to be added. -//! KeyPress(Key), -//! KeyRelease(Key), -//! /// Some mouse will have more than 3 buttons, these are not defined, and different OS will -//! /// give different Unknown code. -//! ButtonPress(Button), -//! ButtonRelease(Button), -//! /// Values in pixels -//! MouseMove { -//! x: f64, -//! y: f64, -//! }, -//! /// Note: On Linux, there is no actual delta the actual values are ignored for delta_x -//! /// and we only look at the sign of delta_y to simulate wheelup or wheeldown. -//! Wheel { -//! delta_x: i64, -//! delta_y: i64, -//! }, -//! } -//! ``` -//! -//! -//! # Getting the main screen size -//! -//! ```no_run -//! use rdev::{display_size}; -//! -//! let (w, h) = display_size().unwrap(); -//! assert!(w > 0); -//! assert!(h > 0); -//! ``` -//! -//! # Keyboard state -//! -//! We can define a dummy Keyboard, that we will use to detect -//! what kind of EventType trigger some String. We get the currently used -//! layout for now ! -//! Caveat : This is layout dependent. If your app needs to support -//! layout switching don't use this ! -//! Caveat: On Linux, the dead keys mechanism is not implemented. -//! Caveat: Only shift and dead keys are implemented, Alt+unicode code on windows -//! won't work. -//! -//! ```no_run -//! use rdev::{Keyboard, EventType, Key, KeyboardState}; -//! -//! let mut keyboard = Keyboard::new().unwrap(); -//! let string = keyboard.add(&EventType::KeyPress(Key::KeyS)); -//! // string == Some("s") -//! ``` -//! -//! # Grabbing global events. (Requires `unstable_grab` feature) -//! -//! Installing this library with the `unstable_grab` feature adds the `grab` function -//! which hooks into the global input device event stream. -//! by suppling this function with a callback, you can intercept -//! all keyboard and mouse events before they are delivered to applications / window managers. -//! In the callback, returning None ignores the event and returning the event let's it pass. -//! There is no modification of the event possible here (yet). -//! -//! Note: the use of the word `unstable` here refers specifically to the fact that the `grab` API is unstable and subject to change -//! -//! ```no_run -//! #[cfg(feature = "unstable_grab")] -//! use rdev::{grab, Event, EventType, Key}; -//! -//! #[cfg(feature = "unstable_grab")] -//! let callback = |event: Event| -> Option { -//! if let EventType::KeyPress(Key::CapsLock) = event.event_type { -//! println!("Consuming and cancelling CapsLock"); -//! None // CapsLock is now effectively disabled -//! } -//! else { Some(event) } -//! }; -//! // This will block. -//! #[cfg(feature = "unstable_grab")] -//! if let Err(error) = grab(callback) { -//! println!("Error: {:?}", error) -//! } -//! ``` -//! -//! ## OS Caveats: -//! When using the `listen` and/or `grab` functions, the following caveats apply: -//! -//! ### Mac OS -//! The process running the blocking `grab` function (loop) needs to be the parent process (no fork before). -//! The process needs to be granted access to the Accessibility API (ie. if you're running your process -//! inside Terminal.app, then Terminal.app needs to be added in -//! System Preferences > Security & Privacy > Privacy > Accessibility) -//! If the process is not granted access to the Accessibility API, the `grab` call will fail with an -//! EventTapError (at least in MacOS 10.15, possibly other versions as well) -//! -//! ### Linux -//! The `grab` function use the `evdev` library to intercept events, so they will work with both X11 and Wayland -//! In order for this to work, the process runnign the `listen` or `grab` loop needs to either run as root (not recommended), -//! or run as a user who's a member of the `input` group (recommended) -//! Note: on some distros, the group name for evdev access is called `plugdev`, and on some systems, both groups can exist. -//! When in doubt, add your user to both groups if they exist. -//! -//! # Serialization -//! -//! Event data returned by the `listen` and `grab` functions can be serialized and de-serialized with -//! Serde if you install this library with the `serialize` feature. -mod rdev; -pub use crate::rdev::{ - Button, DisplayError, Event, EventType, GrabCallback, GrabError, Key, KeyboardState, - ListenError, SimulateError, -}; - -#[cfg(target_os = "macos")] -mod macos; -#[cfg(target_os = "macos")] -pub use crate::macos::Keyboard; -#[cfg(target_os = "macos")] -use crate::macos::{display_size as _display_size, listen as _listen, simulate as _simulate}; - -#[cfg(target_os = "linux")] -mod linux; -#[cfg(target_os = "linux")] -pub use crate::linux::Keyboard; -#[cfg(target_os = "linux")] -use crate::linux::{display_size as _display_size, listen as _listen, simulate as _simulate}; - -#[cfg(target_os = "windows")] -mod windows; -#[cfg(target_os = "windows")] -pub use crate::windows::Keyboard; -#[cfg(target_os = "windows")] -use crate::windows::{display_size as _display_size, listen as _listen, simulate as _simulate}; - -/// Listening to global events. Caveat: On MacOS, you require the listen -/// loop needs to be the primary app (no fork before) and need to have accessibility -/// settings enabled. -/// -/// ```no_run -/// use rdev::{listen, Event}; -/// -/// fn callback(event: Event) { -/// println!("My callback {:?}", event); -/// match event.name{ -/// Some(string) => println!("User wrote {:?}", string), -/// None => () -/// } -/// } -/// fn main(){ -/// // This will block. -/// if let Err(error) = listen(callback) { -/// println!("Error: {:?}", error) -/// } -/// } -/// ``` -pub fn listen(callback: T) -> Result<(), ListenError> -where - T: FnMut(Event) + 'static, -{ - _listen(callback) -} - -/// Sending some events -/// -/// ```no_run -/// use rdev::{simulate, Button, EventType, Key, SimulateError}; -/// use std::{thread, time}; -/// -/// fn send(event_type: &EventType) { -/// let delay = time::Duration::from_millis(20); -/// match simulate(event_type) { -/// Ok(()) => (), -/// Err(SimulateError) => { -/// println!("We could not send {:?}", event_type); -/// } -/// } -/// // Let ths OS catchup (at least MacOS) -/// thread::sleep(delay); -/// } -/// -/// fn my_shortcut() { -/// send(&EventType::KeyPress(Key::KeyS)); -/// send(&EventType::KeyRelease(Key::KeyS)); -/// -/// send(&EventType::MouseMove { x: 0.0, y: 0.0 }); -/// send(&EventType::MouseMove { x: 400.0, y: 400.0 }); -/// send(&EventType::ButtonPress(Button::Left)); -/// send(&EventType::ButtonRelease(Button::Right)); -/// send(&EventType::Wheel { -/// delta_x: 0, -/// delta_y: 1, -/// }); -/// } -/// ``` -pub fn simulate(event_type: &EventType) -> Result<(), SimulateError> { - _simulate(event_type) -} - -/// Returns the size in pixels of the main screen. -/// This is useful to use with x, y from MouseMove Event. -/// -/// ```no_run -/// use rdev::{display_size}; -/// -/// let (w, h) = display_size().unwrap(); -/// println!("My screen size : {:?}x{:?}", w, h); -/// ``` -pub fn display_size() -> Result<(u64, u64), DisplayError> { - _display_size() -} - -#[cfg(feature = "unstable_grab")] -#[cfg(target_os = "linux")] -pub use crate::linux::grab as _grab; -#[cfg(feature = "unstable_grab")] -#[cfg(target_os = "macos")] -pub use crate::macos::grab as _grab; -#[cfg(feature = "unstable_grab")] -#[cfg(target_os = "windows")] -pub use crate::windows::grab as _grab; -#[cfg(any(feature = "unstable_grab"))] -/// Grabbing global events. In the callback, returning None ignores the event -/// and returning the event let's it pass. There is no modification of the event -/// possible here. -/// Caveat: On MacOS, you require the grab -/// loop needs to be the primary app (no fork before) and need to have accessibility -/// settings enabled. -/// On Linux, you need rw access to evdev devices in /etc/input/ (usually group membership in `input` group is enough) -/// -/// ```no_run -/// use rdev::{grab, Event, EventType, Key}; -/// -/// fn callback(event: Event) -> Option { -/// println!("My callback {:?}", event); -/// match event.event_type{ -/// EventType::KeyPress(Key::Tab) => None, -/// _ => Some(event), -/// } -/// } -/// fn main(){ -/// // This will block. -/// if let Err(error) = grab(callback) { -/// println!("Error: {:?}", error) -/// } -/// } -/// ``` -#[cfg(any(feature = "unstable_grab"))] -pub fn grab(callback: T) -> Result<(), GrabError> -where - T: Fn(Event) -> Option + 'static, -{ - _grab(callback) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_keyboard_state() { - // S - let mut keyboard = Keyboard::new().unwrap(); - let char_s = keyboard.add(&EventType::KeyPress(Key::KeyS)).unwrap(); - assert_eq!( - char_s, - "s".to_string(), - "This test should pass only on Qwerty layout !" - ); - let n = keyboard.add(&EventType::KeyRelease(Key::KeyS)); - assert_eq!(n, None); - - // Shift + S - keyboard.add(&EventType::KeyPress(Key::ShiftLeft)); - let char_s = keyboard.add(&EventType::KeyPress(Key::KeyS)).unwrap(); - assert_eq!(char_s, "S".to_string()); - let n = keyboard.add(&EventType::KeyRelease(Key::KeyS)); - assert_eq!(n, None); - keyboard.add(&EventType::KeyRelease(Key::ShiftLeft)); - - // Reset - keyboard.add(&EventType::KeyPress(Key::ShiftLeft)); - keyboard.reset(); - let char_s = keyboard.add(&EventType::KeyPress(Key::KeyS)).unwrap(); - assert_eq!(char_s, "s".to_string()); - let n = keyboard.add(&EventType::KeyRelease(Key::KeyS)); - assert_eq!(n, None); - keyboard.add(&EventType::KeyRelease(Key::ShiftLeft)); - - // UsIntl layout required - // let n = keyboard.add(&EventType::KeyPress(Key::Quote)); - // assert_eq!(n, Some("".to_string())); - // let m = keyboard.add(&EventType::KeyRelease(Key::Quote)); - // assert_eq!(m, None); - // let e = keyboard.add(&EventType::KeyPress(Key::KeyE)).unwrap(); - // assert_eq!(e, "é".to_string()); - // keyboard.add(&EventType::KeyRelease(Key::KeyE)); - } -} diff --git a/src-tauri/vendor/rdev/src/linux/common.rs b/src-tauri/vendor/rdev/src/linux/common.rs deleted file mode 100644 index 94ecbb9..0000000 --- a/src-tauri/vendor/rdev/src/linux/common.rs +++ /dev/null @@ -1,138 +0,0 @@ -use crate::linux::keyboard::Keyboard; -use crate::linux::keycodes::key_from_code; -use crate::rdev::{Button, Event, EventType, KeyboardState}; -use std::convert::TryInto; -use std::os::raw::{c_int, c_uchar, c_uint}; -use std::ptr::null; -use std::time::SystemTime; -use x11::xlib; - -pub const TRUE: c_int = 1; -pub const FALSE: c_int = 0; - -// A global for the callbacks. -pub static mut KEYBOARD: Option = None; - -pub fn convert_event(code: c_uchar, type_: c_int, x: f64, y: f64) -> Option { - match type_ { - xlib::KeyPress => { - let key = key_from_code(code.into()); - Some(EventType::KeyPress(key)) - } - xlib::KeyRelease => { - let key = key_from_code(code.into()); - Some(EventType::KeyRelease(key)) - } - xlib::ButtonPress => match code { - 1 => Some(EventType::ButtonPress(Button::Left)), - 2 => Some(EventType::ButtonPress(Button::Middle)), - 3 => Some(EventType::ButtonPress(Button::Right)), - 4 => Some(EventType::Wheel { - delta_y: 1, - delta_x: 0, - }), - 5 => Some(EventType::Wheel { - delta_y: -1, - delta_x: 0, - }), - 6 => Some(EventType::Wheel { - delta_y: 0, - delta_x: -1, - }), - 7 => Some(EventType::Wheel { - delta_y: 0, - delta_x: 1, - }), - code => Some(EventType::ButtonPress(Button::Unknown(code))), - }, - xlib::ButtonRelease => match code { - 1 => Some(EventType::ButtonRelease(Button::Left)), - 2 => Some(EventType::ButtonRelease(Button::Middle)), - 3 => Some(EventType::ButtonRelease(Button::Right)), - 4 | 5 => None, - _ => Some(EventType::ButtonRelease(Button::Unknown(code))), - }, - xlib::MotionNotify => Some(EventType::MouseMove { x, y }), - _ => None, - } -} - -pub fn convert( - keyboard: &mut Option, - code: c_uint, - type_: c_int, - x: f64, - y: f64, -) -> Option { - let event_type = convert_event(code as c_uchar, type_, x, y)?; - let kb: &mut Keyboard = (*keyboard).as_mut()?; - let name = kb.add(&event_type); - Some(Event { - event_type, - time: SystemTime::now(), - name, - }) -} - -pub struct Display { - display: *mut xlib::Display, -} - -impl Display { - pub fn new() -> Option { - unsafe { - let display = xlib::XOpenDisplay(null()); - if display.is_null() { - return None; - } - Some(Display { display }) - } - } - - pub fn get_size(&self) -> Option<(u64, u64)> { - unsafe { - let screen_ptr = xlib::XDefaultScreenOfDisplay(self.display); - if screen_ptr.is_null() { - return None; - } - let screen = *screen_ptr; - Some(( - screen.width.try_into().ok()?, - screen.height.try_into().ok()?, - )) - } - } - - #[cfg(feature = "unstable_grab")] - pub fn get_mouse_pos(&self) -> Option<(u64, u64)> { - unsafe { - let root_window = xlib::XRootWindow(self.display, 0); - let mut root_x = 0; - let mut root_y = 0; - let mut x = 0; - let mut y = 0; - let mut root = 0; - let mut child = 0; - let mut mask = 0; - let _screen_ptr = xlib::XQueryPointer( - self.display, - root_window, - &mut root, - &mut child, - &mut root_x, - &mut root_y, - &mut x, - &mut y, - &mut mask, - ); - Some((root_x.try_into().ok()?, root_y.try_into().ok()?)) - } - } -} -impl Drop for Display { - fn drop(&mut self) { - unsafe { - xlib::XCloseDisplay(self.display); - } - } -} diff --git a/src-tauri/vendor/rdev/src/linux/display.rs b/src-tauri/vendor/rdev/src/linux/display.rs deleted file mode 100644 index d50e91f..0000000 --- a/src-tauri/vendor/rdev/src/linux/display.rs +++ /dev/null @@ -1,7 +0,0 @@ -use crate::linux::common::Display; -use crate::rdev::DisplayError; - -pub fn display_size() -> Result<(u64, u64), DisplayError> { - let display = Display::new().ok_or(DisplayError::NoDisplay)?; - display.get_size().ok_or(DisplayError::NoDisplay) -} diff --git a/src-tauri/vendor/rdev/src/linux/grab.rs b/src-tauri/vendor/rdev/src/linux/grab.rs deleted file mode 100644 index 43aa07e..0000000 --- a/src-tauri/vendor/rdev/src/linux/grab.rs +++ /dev/null @@ -1,541 +0,0 @@ -use crate::linux::common::Display; -use crate::linux::keyboard::Keyboard; -use crate::rdev::{Button, Event, EventType, GrabError, Key, KeyboardState}; -use epoll::ControlOptions::{EPOLL_CTL_ADD, EPOLL_CTL_DEL}; -use evdev_rs::{ - enums::{EventCode, EV_KEY, EV_REL}, - Device, InputEvent, UInputDevice, -}; -use inotify::{Inotify, WatchMask}; -use std::ffi::{OsStr, OsString}; -use std::fs::{read_dir, File}; -use std::io; -use std::os::unix::{ - ffi::OsStrExt, - fs::FileTypeExt, - io::{AsRawFd, IntoRawFd, RawFd}, -}; -use std::path::Path; -use std::time::SystemTime; - -// TODO The x, y coordinates are currently wrong !! Is there mouse acceleration -// to take into account ?? - -macro_rules! convert_keys { - ($($ev_key:ident, $rdev_key:ident),*) => { - //TODO: make const when rust lang issue #49146 is fixed - #[allow(unreachable_patterns)] - fn evdev_key_to_rdev_key(key: &EV_KEY) -> Option { - match key { - $( - EV_KEY::$ev_key => Some(Key::$rdev_key), - )* - _ => None, - } - } - - // //TODO: make const when rust lang issue #49146 is fixed - // fn rdev_key_to_evdev_key(key: &Key) -> Option { - // match key { - // $( - // Key::$rdev_key => Some(EV_KEY::$ev_key), - // )* - // _ => None - // } - // } - }; -} - -macro_rules! convert_buttons { - ($($ev_key:ident, $rdev_key:ident),*) => { - //TODO: make const when rust lang issue #49146 is fixed - fn evdev_key_to_rdev_button(key: &EV_KEY) -> Option @@ -49,7 +50,7 @@

Follow the highlighted key

Test complete

    -

    These results verify configured HID output only. Events may come from any attached keyboard and do not prove raw switch or matrix health.

    +

    Results identify whether the session used physical BLE telemetry or global HID fallback. Transport diagnostics remain visible above.

    diff --git a/src/self-test.js b/src/self-test.js index e335083..735fa33 100644 --- a/src/self-test.js +++ b/src/self-test.js @@ -2,18 +2,21 @@ import { loadLayoutCatalog, normalizeLayerData } from "./layout_catalog.js"; import { buildTestPlan, createSelfTestController } from "./self_test/controller.js"; import { createOverlayPresentationPayload } from "./self_test/overlay_presentation.js"; import { initializeSecondaryWindow, SECONDARY_WINDOWS } from "./secondary_window_ready.js"; +import { normalizeBleKeyboardFrame, normalizeSystemKeyEvent } from "./input_events.js"; const elements = Object.fromEntries([ "setupView", "activeView", "resultsView", "layoutSelect", "layerSelect", "catalogErrors", "testability", "startButton", "selectionLabel", "progressText", "progressBar", "instruction", "expectedCode", "receivedCode", "mismatchActions", "waitingActions", "retryButton", "problemButton", "skipButton", "stopButton", "resultCounts", "problemList", "retestButton", "anotherLayerButton", "closeButton", "closeResultsButton", "liveStatus", + "transportDiagnostics", ].map((id) => [id, document.getElementById(id)])); const tauri = window.__TAURI__; let catalog = null; let selectedLayoutKey = null; let selectedLayerIndex = 0; +let effectiveInputSource = "system"; async function readConfig() { if (!tauri?.core?.invoke) return null; @@ -27,6 +30,19 @@ function currentData() { return { definition, ...normalized }; } +function buildSelectedPlan() { + const { definition, layers, names } = currentData(); + if (!definition) return null; + return buildTestPlan({ + layoutKey: selectedLayoutKey, + definition, + layers, + layerNames: names, + layerIndex: selectedLayerIndex, + inputSource: effectiveInputSource, + }); +} + function renderSetup() { const { definition, layers, names } = currentData(); elements.layerSelect.innerHTML = ""; @@ -36,10 +52,13 @@ function renderSetup() { selectedLayerIndex = Math.min(selectedLayerIndex, Math.max(0, layers.length - 1)); elements.layerSelect.value = String(selectedLayerIndex); elements.layerSelect.disabled = !layers.length; - const plan = definition ? buildTestPlan({ layoutKey: selectedLayoutKey, definition, layers, layerNames: names, layerIndex: selectedLayerIndex }) : null; + const plan = buildSelectedPlan(); const testable = plan?.testableIndexes.length ?? 0; - const notTestable = plan?.entries.filter((entry) => !entry.descriptor.supported).length ?? 0; - elements.testability.textContent = plan ? `${testable} guided positions · ${notTestable} not testable from HID events` : "No compatible layout is available."; + const notTestable = plan?.entries.filter((entry) => entry.kind === "key" && !entry.testable).length ?? 0; + const combos = plan?.entries.filter((entry) => entry.kind === "combo").length ?? 0; + elements.testability.textContent = plan + ? `${testable} guided items${combos ? ` · ${combos} firmware combos` : ""} · source: ${effectiveInputSource === "ble" ? "BLE physical events" : "global HID"}${notTestable ? ` · ${notTestable} not testable` : ""}` + : "No compatible layout is available."; elements.startButton.disabled = testable === 0; elements.selectionLabel.textContent = definition ? `${definition.name} · ${names[selectedLayerIndex] ?? "Layer"}` : ""; } @@ -69,6 +88,10 @@ function renderResults(snapshot) { } function render(snapshot) { + const latestDiagnostic = snapshot.diagnostics?.at(-1); + elements.transportDiagnostics.textContent = latestDiagnostic + ? `BLE diagnostic: ${latestDiagnostic.code}${latestDiagnostic.message ? ` — ${latestDiagnostic.message}` : ""}` + : ""; const setup = snapshot.phase === "setup"; const complete = snapshot.phase === "complete"; elements.setupView.hidden = !setup; @@ -105,9 +128,8 @@ const controller = createSelfTestController({ }); function startSelectedPlan() { - const { definition, layers, names } = currentData(); - if (!definition) return; - controller.start(buildTestPlan({ layoutKey: selectedLayoutKey, definition, layers, layerNames: names, layerIndex: selectedLayerIndex })); + const plan = buildSelectedPlan(); + if (plan) controller.start(plan); } async function closeWindow() { @@ -145,7 +167,33 @@ async function initialize() { elements.catalogErrors.textContent = catalog.errors.join(" "); renderSetup(); if (tauri?.event?.listen) { - await tauri.event.listen("key_event", (event) => controller.handleKey(event.payload?.key, event.payload?.event_type)); + await tauri.event.listen("key_event", (event) => { + if (effectiveInputSource !== "system") return; + const input = normalizeSystemKeyEvent(event.payload); + if (input) controller.handleKey(input.code, input.action); + }); + await tauri.event.listen("ble_keyboard_event", (event) => { + if (effectiveInputSource !== "ble" || event.payload?.layout !== selectedLayoutKey) return; + const input = normalizeBleKeyboardFrame(event.payload?.frame); + if (input?.kind === "key") controller.handlePhysicalKey(input.position, input.action); + if (input?.kind === "combo") controller.handleCombo(input.comboId, input.positions, input.action); + if (input?.kind === "diagnostic") controller.reportDiagnostic("firmware-diagnostic", input); + }); + await tauri.event.listen("ble_keyboard_diagnostic", (event) => { + if (event.payload?.layout !== selectedLayoutKey) return; + controller.reportDiagnostic(event.payload?.code ?? "ble-diagnostic", { + message: event.payload?.message, + }); + }); + await tauri.event.listen("self-test-source-state", (event) => { + const next = event.payload?.effectiveSource === "ble" ? "ble" : "system"; + if (next === effectiveInputSource) return; + const previous = effectiveInputSource; + effectiveInputSource = next; + controller.handleSourceTransition(previous, next, event.payload?.reason); + if (controller.getSnapshot().phase === "setup") renderSetup(); + }); + await tauri.event.emitTo("overlay", "self-test-source-request", {}); } publishOverlay(controller.getSnapshot()); } catch (error) { diff --git a/src/self_test/controller.js b/src/self_test/controller.js index 68e75ea..5a3995a 100644 --- a/src/self_test/controller.js +++ b/src/self_test/controller.js @@ -9,27 +9,81 @@ function freezeEntry(entry) { }); } -export function buildTestPlan({ layoutKey, definition, layers, layerNames, layerIndex = 0, onlyIndexes = null }) { +function positionsForCombo(combo, keyPositions) { + if (Array.isArray(combo?.positions)) return combo.positions; + const coordinates = [combo?.key1, combo?.key2]; + if (coordinates.some((position) => !position)) return []; + return coordinates.map((position) => keyPositions.findIndex( + (candidate) => candidate.row === position.row && candidate.col === position.col, + )); +} + +function comboEntries(definition, startIndex, allowed) { + const entries = []; + for (const combo of (Array.isArray(definition.combos) ? definition.combos : [])) { + const positions = positionsForCombo(combo, definition.keyPositions); + const comboId = Number.isInteger(combo?.id) && combo.id > 0 ? combo.id : null; + if ((!comboId && positions.length === 0) + || positions.some((position) => !Number.isInteger(position) || position < 0 || position >= definition.keyPositions.length)) { + continue; + } + const index = startIndex + entries.length; + const sourceTestable = true; + entries.push(freezeEntry({ + index, + kind: "combo", + position: {}, + label: combo.code ?? (comboId ? `Combo ${comboId}` : `Combo ${positions.join("+")}`), + rawCode: combo.code ?? "", + descriptor: { supported: false, trigger: null, modifiers: [] }, + comboId, + positions: Object.freeze([...positions]), + sourceTestable, + testable: sourceTestable && (!allowed || allowed.has(index)), + excludedFromRetest: Boolean(allowed && !allowed.has(index)), + })); + } + return entries; +} + +export function buildTestPlan({ + layoutKey, + definition, + layers, + layerNames, + layerIndex = 0, + onlyIndexes = null, + inputSource = "system", +}) { const allowed = onlyIndexes ? new Set(onlyIndexes) : null; const entries = definition.keyPositions.map((position, index) => { const rawEntry = effectiveLayerEntry(layers, layerIndex, index); const normalized = normalizeKeyEntry(rawEntry); const descriptor = normalizeHidDescriptor(normalized.code); + const sourceTestable = inputSource === "ble" || descriptor.supported; return freezeEntry({ index, + kind: "key", position, - label: typeof normalized.label === "object" ? (normalized.label.text ?? "") : (normalized.label ?? ""), + label: normalized.label && typeof normalized.label === "object" + ? (normalized.label.text ?? "") + : (normalized.label ?? ""), rawCode: normalized.code ?? "", descriptor, - testable: descriptor.supported && (!allowed || allowed.has(index)), - excludedFromRetest: Boolean(allowed && !allowed.has(index) && descriptor.supported), + sourceTestable, + testable: sourceTestable && (!allowed || allowed.has(index)), + excludedFromRetest: Boolean(allowed && !allowed.has(index) && sourceTestable), }); }); + if (inputSource === "ble") { + entries.push(...comboEntries(definition, entries.length, allowed)); + } const testableIndexes = entries.filter((entry) => entry.testable).map((entry) => entry.index); return Object.freeze({ layoutKey, layoutName: definition.name ?? layoutKey, layerIndex, + inputSource, layerName: layerNames?.[layerIndex] ?? `Layer ${layerIndex + 1}`, keySize: Object.freeze({ ...definition.keySize }), entries: Object.freeze(entries), @@ -39,7 +93,7 @@ export function buildTestPlan({ layoutKey, definition, layers, layerNames, layer function initialResults(plan) { return new Map(plan.entries - .filter((entry) => !entry.descriptor.supported && !entry.excludedFromRetest) + .filter((entry) => !entry.testable && !entry.excludedFromRetest) .map((entry) => [entry.index, { status: "not-testable", expected: entry.rawCode }])); } @@ -50,10 +104,13 @@ export function createSelfTestController({ onChange = () => {} } = {}) { cursor: 0, results: new Map(), pressedCodes: new Set(), + pressedPositions: new Set(), activeChord: null, + activePhysicalPosition: null, pendingTransition: null, received: null, completionRevision: 0, + diagnostics: [], }; function currentEntry() { @@ -77,9 +134,11 @@ export function createSelfTestController({ onChange = () => {} } = {}) { received: state.received, activeModifiers, pressedCodes: [...state.pressedCodes], + pressedPositions: [...state.pressedPositions], chordActive: state.phase === "chord-active", waitingForRelease: state.phase === "waiting-clean", completionRevision: state.completionRevision, + diagnostics: state.diagnostics.map((diagnostic) => ({ ...diagnostic })), }; } @@ -98,7 +157,7 @@ export function createSelfTestController({ onChange = () => {} } = {}) { } function finishPendingTransition() { - if (state.phase !== "waiting-clean" || state.pressedCodes.size > 0) return false; + if (state.phase !== "waiting-clean" || state.pressedCodes.size > 0 || state.pressedPositions.size > 0) return false; const transition = state.pendingTransition; state.pendingTransition = null; if (transition === "advance") { @@ -113,6 +172,7 @@ export function createSelfTestController({ onChange = () => {} } = {}) { function waitForCleanBoundary(transition) { state.activeChord = null; + state.activePhysicalPosition = null; state.pendingTransition = transition; state.received = transition === "retry" ? null : state.received; state.phase = "waiting-clean"; @@ -139,10 +199,13 @@ export function createSelfTestController({ onChange = () => {} } = {}) { cursor: 0, results: initialResults(plan), pressedCodes: state.pressedCodes, + pressedPositions: state.pressedPositions, activeChord: null, + activePhysicalPosition: null, pendingTransition: "start", received: null, completionRevision: state.completionRevision, + diagnostics: state.diagnostics, }; if (!finishPendingTransition()) notify(); return true; @@ -228,6 +291,132 @@ export function createSelfTestController({ onChange = () => {} } = {}) { return false; } + function samePositions(left, right) { + const expected = [...left].sort((a, b) => a - b); + const received = [...right].sort((a, b) => a - b); + return left.length === right.length + && expected.every((position, index) => position === received[index]); + } + + function recordDiagnostic(code, detail = {}) { + state.diagnostics = [...state.diagnostics, { code, ...detail }].slice(-20); + } + + function reportDiagnostic(code, detail = {}) { + recordDiagnostic(code, detail); + notify(); + } + + function handleSourceTransition(previous, current, reason = null) { + if (previous === current) return false; + if (previous === "ble") { + state.pressedPositions.clear(); + state.activePhysicalPosition = null; + } + if (previous === "system") { + state.pressedCodes.clear(); + state.activeChord = null; + } + recordDiagnostic(current === "ble" ? "ble-source-active" : "ble-fallback", { + message: `${previous} → ${current}${reason ? `: ${reason}` : ""}`, + }); + if (state.phase === "waiting-clean") { + if (!finishPendingTransition()) notify(); + return true; + } + if (["physical-key-active", "chord-active", "mismatch"].includes(state.phase)) { + state.phase = "waiting-down"; + state.received = null; + state.pendingTransition = null; + } + notify(); + return true; + } + + function handlePhysicalKey(position, eventType) { + if (!Number.isInteger(position) || position < 0 || !["down", "up"].includes(eventType)) return false; + const wasPressed = state.pressedPositions.has(position); + if (eventType === "down") { + if (wasPressed) return false; + state.pressedPositions.add(position); + } else if (!wasPressed) { + return false; + } else { + state.pressedPositions.delete(position); + } + + if (state.phase === "waiting-clean") { + if (!finishPendingTransition()) notify(); + return false; + } + if (["setup", "complete"].includes(state.phase)) return false; + if (state.phase === "mismatch") { + notify(); + return false; + } + + const expected = currentEntry(); + if (expected?.kind === "combo") { + if (!expected.positions.includes(position)) { + recordDiagnostic("unexpected-ble-key", { position }); + state.phase = "mismatch"; + state.received = `Position ${position}`; + notify(); + return true; + } + notify(); + return false; + } + + if (state.phase === "waiting-down") { + if (eventType === "up") { + notify(); + return false; + } + state.received = `Position ${position}`; + if (position === expected?.index) { + state.activePhysicalPosition = position; + state.phase = "physical-key-active"; + } else { + recordDiagnostic("unexpected-ble-key", { position, expectedPosition: expected?.index }); + state.phase = "mismatch"; + } + notify(); + return true; + } + + if (state.phase === "physical-key-active" && eventType === "down") { + recordDiagnostic("unexpected-ble-key", { position, expectedPosition: expected?.index }); + state.phase = "mismatch"; + state.received = `Position ${position}`; + state.activePhysicalPosition = null; + notify(); + return true; + } + if (state.phase === "physical-key-active" && eventType === "up" && position === state.activePhysicalPosition) { + state.results.set(expected.index, { status: "passed", expected: expected.rawCode }); + waitForCleanBoundary("advance"); + return true; + } + notify(); + return false; + } + + function handleCombo(comboId, positions, eventType) { + if (eventType !== "down" || !Array.isArray(positions)) return false; + const expected = currentEntry(); + const matches = expected?.kind === "combo" + && ((expected.comboId && expected.comboId === comboId) || samePositions(expected.positions, positions)); + if (!matches) { + reportDiagnostic("unmatched-ble-combo", { comboId, positions: [...positions] }); + return false; + } + state.results.set(expected.index, { status: "passed", expected: expected.rawCode }); + state.received = comboId ? `Combo ${comboId}` : `Positions ${positions.join("+")}`; + waitForCleanBoundary("advance"); + return true; + } + function retry() { if (state.phase !== "mismatch") return false; waitForCleanBoundary("retry"); @@ -243,7 +432,7 @@ export function createSelfTestController({ onChange = () => {} } = {}) { } function skip() { - if (!["waiting-down", "chord-active"].includes(state.phase)) return false; + if (!["waiting-down", "chord-active", "physical-key-active"].includes(state.phase)) return false; const entry = currentEntry(); state.results.set(entry.index, { status: "skipped", expected: entry.rawCode }); waitForCleanBoundary("advance"); @@ -259,7 +448,10 @@ export function createSelfTestController({ onChange = () => {} } = {}) { results: new Map(), received: null, activeChord: null, + activePhysicalPosition: null, pendingTransition: null, + pressedPositions: new Set(), + diagnostics: [], }; notify(); } @@ -276,11 +468,25 @@ export function createSelfTestController({ onChange = () => {} } = {}) { entries: Object.freeze(state.plan.entries.map((entry) => freezeEntry({ ...entry, testable: problems.includes(entry.index), - excludedFromRetest: entry.descriptor.supported && !problems.includes(entry.index), + excludedFromRetest: entry.sourceTestable && !problems.includes(entry.index), }))), }); return start(plan); } - return { getSnapshot: snapshot, start, handleKey, retry, markProblem, skip, stop, retestProblems, dispose: stop }; + return { + getSnapshot: snapshot, + start, + handleKey, + handlePhysicalKey, + handleCombo, + reportDiagnostic, + handleSourceTransition, + retry, + markProblem, + skip, + stop, + retestProblems, + dispose: stop, + }; } diff --git a/src/self_test/overlay_presentation.js b/src/self_test/overlay_presentation.js index 67851bb..c5bf7bd 100644 --- a/src/self_test/overlay_presentation.js +++ b/src/self_test/overlay_presentation.js @@ -12,13 +12,17 @@ export function createOverlayPresentationPayload(snapshot) { if (!snapshot?.plan || snapshot.phase === "setup") return { active: false, states: {} }; const states = {}; for (const entry of snapshot.plan.entries) { - if (!entry.descriptor.supported && !entry.excludedFromRetest) states[entry.index] = "not-testable"; + if (entry.kind !== "combo" && !entry.testable && !entry.excludedFromRetest) states[entry.index] = "not-testable"; } for (const [index, result] of Object.entries(snapshot.results ?? {})) { if (SELF_TEST_KEY_STATES.includes(result.status)) states[index] = result.status; } if (snapshot.current && snapshot.phase !== "waiting-clean") { - states[snapshot.current.index] = snapshot.phase === "mismatch" ? "unexpected" : "expected"; + if (snapshot.current.kind === "combo") { + for (const position of snapshot.current.positions) states[position] = "expected"; + } else { + states[snapshot.current.index] = snapshot.phase === "mismatch" ? "unexpected" : "expected"; + } } return { active: true, diff --git a/tests/ble_status.test.mjs b/tests/ble_status.test.mjs index f5d11f4..685df78 100644 --- a/tests/ble_status.test.mjs +++ b/tests/ble_status.test.mjs @@ -21,6 +21,31 @@ test("automatic fallback and stock ZMK limitations remain visible", () => { { mode: "stock", reason: "extension-capabilities-unavailable" }, ); assert.equal(status.summary, "Active: System listener"); - assert.equal(status.detail, "stock ZMK · ble-capabilities-unavailable"); + assert.equal(status.detail, "stock ZMK · extension-capabilities-unavailable"); assert.equal(status.battery, "Battery unavailable"); }); + +test("capability read diagnostics remain visible in unsupported mode", () => { + const reason = "extension-capabilities-invalid: InvalidCapabilitiesLength(3); received 3 bytes [01 00 77]"; + const status = formatBleKeyboardStatus( + { effectiveSource: "system", reason }, + { mode: "unsupported", reason }, + 87, + ); + assert.equal(status.detail, `unsupported extension · ${reason}`); + assert.equal(status.battery, "Battery 87%"); +}); + +test("fresh backend diagnostics override stale input-source fallback reasons", () => { + const status = formatBleKeyboardStatus( + { effectiveSource: "system", reason: "extension-event-stream-unavailable" }, + { + mode: "unsupported", + reason: "extension-event-subscribe-failed: insufficient encryption", + }, + ); + assert.equal( + status.detail, + "unsupported extension · extension-event-subscribe-failed: insufficient encryption", + ); +}); diff --git a/tests/self_test_controller.test.mjs b/tests/self_test_controller.test.mjs index 21323c5..b2f5857 100644 --- a/tests/self_test_controller.test.mjs +++ b/tests/self_test_controller.test.mjs @@ -211,3 +211,81 @@ test("empty plans do not start and stopping disposes session results", () => { assert.equal(controller.getSnapshot().phase, "setup"); assert.deepEqual(controller.getSnapshot().results, {}); }); + +test("BLE plans include every physical position and firmware-resolved combos", () => { + const definition = { + name: "BLE fixture", + keySize: { w: 40, h: 40, gap: 4 }, + keyPositions: [{ row: 0, col: 0 }, { row: 0, col: 1 }], + combos: [{ id: 9, positions: [0, 1], code: "Escape" }], + }; + const plan = buildTestPlan({ + layoutKey: "ble-fixture", + definition, + layers: [[null, ["A", "KeyA"]]], + layerNames: ["Base"], + inputSource: "ble", + }); + assert.deepEqual(plan.testableIndexes, [0, 1, 2]); + assert.equal(plan.entries[0].kind, "key"); + assert.equal(plan.entries[2].kind, "combo"); + assert.equal(plan.entries[2].comboId, 9); +}); + +test("BLE physical key events pass the requested position and diagnose a wrong key", () => { + const matching = createSelfTestController(); + matching.start(onlyPosition(fixture(), 1)); + matching.handlePhysicalKey(1, "down"); + assert.equal(matching.getSnapshot().phase, "physical-key-active"); + matching.handlePhysicalKey(1, "up"); + assert.equal(matching.getSnapshot().phase, "complete"); + assert.equal(matching.getSnapshot().counts.passed, 1); + + const wrong = createSelfTestController(); + wrong.start(onlyPosition(fixture(), 1)); + wrong.handlePhysicalKey(2, "down"); + assert.equal(wrong.getSnapshot().phase, "mismatch"); + assert.equal(wrong.getSnapshot().diagnostics.at(-1).code, "unexpected-ble-key"); +}); + +test("BLE combo events pass only matching metadata and retain unmatched diagnostics", () => { + const definition = { + name: "Combo fixture", + keySize: { w: 40, h: 40, gap: 4 }, + keyPositions: [{ row: 0, col: 0 }, { row: 0, col: 1 }], + combos: [{ id: 9, positions: [0, 1], code: "Escape" }], + }; + const plan = buildTestPlan({ + layoutKey: "combo-fixture", + definition, + layers: [[["A", "KeyA"], ["B", "KeyB"]]], + layerNames: ["Base"], + inputSource: "ble", + onlyIndexes: [2], + }); + const controller = createSelfTestController(); + controller.start(plan); + controller.handleCombo(77, [4, 5], "down"); + assert.equal(controller.getSnapshot().counts.passed, 0); + assert.equal(controller.getSnapshot().diagnostics.at(-1).code, "unmatched-ble-combo"); + controller.handlePhysicalKey(0, "down"); + controller.handlePhysicalKey(1, "down"); + controller.handleCombo(9, [0, 1], "down"); + assert.equal(controller.getSnapshot().phase, "waiting-clean"); + controller.handlePhysicalKey(0, "up"); + controller.handlePhysicalKey(1, "up"); + assert.equal(controller.getSnapshot().phase, "complete"); + assert.equal(controller.getSnapshot().counts.passed, 1); +}); + +test("BLE fallback clears physical state, records the transition, and rearms the current step", () => { + const controller = createSelfTestController(); + controller.start(onlyPosition(fixture(), 1)); + controller.handlePhysicalKey(1, "down"); + assert.deepEqual(controller.getSnapshot().pressedPositions, [1]); + controller.handleSourceTransition("ble", "system", "ble-disconnected"); + const snapshot = controller.getSnapshot(); + assert.equal(snapshot.phase, "waiting-down"); + assert.deepEqual(snapshot.pressedPositions, []); + assert.equal(snapshot.diagnostics.at(-1).code, "ble-fallback"); +}); From 7da628b5c8b732476d1367f30f20a9e15add0c00 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Tue, 1 Sep 2026 07:06:21 +0200 Subject: [PATCH 10/13] allow several clients --- src-tauri/src/ble_layer_sync.rs | 8 ++++---- tests/ble_status.test.mjs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/ble_layer_sync.rs b/src-tauri/src/ble_layer_sync.rs index 42b1f80..dc0cf2a 100644 --- a/src-tauri/src/ble_layer_sync.rs +++ b/src-tauri/src/ble_layer_sync.rs @@ -1068,7 +1068,7 @@ fn event_subscription_issue(detail: &str) -> String { || lower.contains("att error: 0x11") || lower.contains("att error 0x11") { - "extension-event-stream-busy: another BLE connection owns the event stream".into() + format!("extension-event-subscription-capacity-unavailable: {detail}") } else { format!("extension-event-subscribe-failed: {detail}") } @@ -1265,14 +1265,14 @@ mod tests { use super::*; #[test] - fn maps_firmware_single_owner_rejection_to_busy_status() { + fn maps_att_resource_rejection_to_capacity_status() { assert_eq!( event_subscription_issue("Operation failed with ATT error: 0x11"), - "extension-event-stream-busy: another BLE connection owns the event stream" + "extension-event-subscription-capacity-unavailable: Operation failed with ATT error: 0x11" ); assert_eq!( event_subscription_issue("Resources are insufficient."), - "extension-event-stream-busy: another BLE connection owns the event stream" + "extension-event-subscription-capacity-unavailable: Resources are insufficient." ); } diff --git a/tests/ble_status.test.mjs b/tests/ble_status.test.mjs index 685df78..a3f6f5c 100644 --- a/tests/ble_status.test.mjs +++ b/tests/ble_status.test.mjs @@ -49,3 +49,16 @@ test("fresh backend diagnostics override stale input-source fallback reasons", ( "unsupported extension · extension-event-subscribe-failed: insufficient encryption", ); }); + +test("subscription capacity diagnostics remain generic and preserve fallback", () => { + const reason = + "extension-event-subscription-capacity-unavailable: Operation failed with ATT error: 0x11"; + const status = formatBleKeyboardStatus( + { effectiveSource: "system", reason: "extension-event-stream-unavailable" }, + { mode: "unsupported", reason }, + 100, + ); + assert.equal(status.summary, "Active: System listener"); + assert.equal(status.detail, `unsupported extension · ${reason}`); + assert.equal(status.battery, "Battery 100%"); +}); From c3c179869ce781e23a5ef83f0e90ae4dc4c24a91 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Wed, 2 Sep 2026 02:02:37 +0200 Subject: [PATCH 11/13] fix self test --- README.md | 9 +- src/global_overlay_hotkey.js | 72 +++++++ src/input_source_layer_reconciler.js | 11 ++ src/layout_catalog.js | 23 ++- src/main.js | 129 ++++++++----- src/self-test.css | 3 + src/self-test.html | 13 +- src/self-test.js | 92 +++++++-- src/self_test/controller.js | 132 +++++++++---- src/self_test/layer_lease.js | 188 +++++++++++++++++++ src/self_test/layer_session.js | 142 ++++++++++++++ src/system_key_event_router.js | 8 + tests/fixtures/README.md | 3 +- tests/fixtures/layouts/corne-connected.json | 7 + tests/global_overlay_hotkey.test.mjs | 71 +++++++ tests/input_source_layer_reconciler.test.mjs | 17 ++ tests/layout_runtime.test.mjs | 42 +++++ tests/self_test_controller.test.mjs | 143 +++++++++++++- tests/self_test_layer_lease.test.mjs | 113 +++++++++++ tests/self_test_layer_session.test.mjs | 107 +++++++++++ tests/self_test_ui.test.mjs | 22 ++- tests/system_key_event_router.test.mjs | 88 +++++++++ 22 files changed, 1318 insertions(+), 117 deletions(-) create mode 100644 src/global_overlay_hotkey.js create mode 100644 src/self_test/layer_lease.js create mode 100644 src/self_test/layer_session.js create mode 100644 src/system_key_event_router.js create mode 100644 tests/global_overlay_hotkey.test.mjs create mode 100644 tests/self_test_layer_lease.test.mjs create mode 100644 tests/self_test_layer_session.test.mjs create mode 100644 tests/system_key_event_router.test.mjs diff --git a/README.md b/README.md index f3577c5..c6c0b39 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,13 @@ The first release uses curated English words and keeps results only for the curr Open the overlay menu, expand **Keyboard**, and choose **Keyboard Self-test**. The desktop app opens one separate test window (or focuses the existing one), initially selecting the overlay's current layout and its base layer. 1. Choose a configured built-in or external layout and the layer you want to verify. -2. Activate that layer on the physical keyboard yourself, then choose **Start guided test**. +2. Choose **Start guided test**. If the layout explicitly maps that layer and writable BLE layer control is ready, Keyboard Helper activates it and waits for authoritative confirmation. Otherwise activate the layer manually and continue in HID-only mode. 3. Press and release the highlighted physical position. An unexpected output can be retried or recorded as a problem; use **Skip this position** when a key produces no event. 4. Review passed, unexpected, skipped, and not-testable positions. You can retest only the problems or return to choose another layer. -The self-test window is a compact controller; the existing overlay remains the only keyboard visualization. Guided outlines are added by physical position while normal live pressed-key highlighting continues, so the expected position and the received key can be seen together. The controller does not switch the overlay or firmware layer—make sure its layout/layer selectors match what the overlay and keyboard currently show. +The self-test window is a compact controller; the existing overlay remains the only keyboard visualization. Guided outlines are added by physical position while normal live pressed-key highlighting continues, so the expected position and the received key can be seen together. A confirmed automatic layer selection is temporary: input-source reconciliation is deferred during the lease, an unexpected authoritative layer change pauses the test, and Stop, completion, Test another layer, or close conditionally restores the preceding layer without overwriting newer user intent. -The report exists only until the self-test window closes. It verifies the configured global HID output, not raw ZMK switch or matrix health: events can come from any attached keyboard, and combos, macros, hold-tap timing, layer activation, and unsupported/multi-step codes are outside the first version. +The report exists only until the self-test window closes. System HID events are the sole authority for ordinary Passed and Unexpected results, so stock ZMK and non-ZMK keyboards remain testable without custom telemetry. Optional BLE key telemetry only corroborates a physical position or adds a warning; it may be unavailable or disabled for privacy and never blocks a correct HID verdict. Without corroboration, events can come from any attached keyboard. Explicit firmware combo items remain BLE-authoritative, while raw matrix health and unsupported or multi-step behaviors are outside the test. ## Release process @@ -129,7 +129,8 @@ Advanced users can still edit the compatible JSON configuration directly. The ap - `defaultLayout`: key of the layout to select at startup (must exist in `layouts`). - `layouts`: object mapping layout keys to either `true` (use built-in) or a filesystem path (load external JSON). - Layout combos (layout-specific): add a `combos` array inside a layout file with entries like `{ "key1": { "row": 1, "col": 4 }, "key2": { "row": 1, "col": 5 }, "code": "Enter" }`. -- Layout file format: JSON with `name`, optional `bleLayerSource`, optional `inputSourceSync`, `keySize` (`w`, `h`, `gap` in px), `keyPositions` (array of `{row,col}` with optional `w`/`h` overrides), and `keyLayers`. `keyLayers` can be an object with `default`, `shift`, etc., or an array where index 0 is the base layer. Each layer entry is `[label, code]` (or an object with `text`/`image` for custom labels). +- Layout file format: JSON with `name`, optional `bleLayerSource`, optional `inputSourceSync`, optional `layerMetadata`, `keySize` (`w`, `h`, `gap` in px), `keyPositions` (array of `{row,col}` with optional `w`/`h` overrides), and `keyLayers`. `keyLayers` can be an object with `default`, `shift`, etc., or an array where index 0 is the base layer. Each layer entry is `[label, code]` (or an object with `text`/`image` for custom labels). +- `layerMetadata` optionally maps the exact raw `keyLayers` key to `{ "firmwareLayerIndex": 2, "selfTestExcludedPositions": [24, 39] }`. The non-negative firmware index enables confirmed automatic layer activation during self-test; it is never inferred from JSON order or the displayed name. Excluded positions are zero-based `keyPositions` indexes for layer-control, dual-role, or other firmware behaviors that must not be exercised while that layer is forced. Empty or unsupported HID codes remain automatically not testable. - `bleLayerSource` is optional and enables BLE-authoritative layer updates for the selected keyboard only. Shape: - `deviceName`: BLE keyboard name to match - `serviceUuid`: custom GATT service UUID diff --git a/src/global_overlay_hotkey.js b/src/global_overlay_hotkey.js new file mode 100644 index 0000000..0a3df62 --- /dev/null +++ b/src/global_overlay_hotkey.js @@ -0,0 +1,72 @@ +const MODIFIER_CODES = Object.freeze({ + shift: new Set(["ShiftLeft", "ShiftRight"]), + meta: new Set(["MetaLeft", "MetaRight"]), + ctrl: new Set(["ControlLeft", "ControlRight"]), + alt: new Set(["Alt", "AltGr"]), +}); + +export function parseGlobalOverlayHotkey(value) { + if (!value) return null; + const modifiers = { shift: false, meta: false, ctrl: false, alt: false }; + let triggerKey = null; + + for (const rawPart of String(value).split("+")) { + const part = rawPart.trim(); + switch (part.toLowerCase()) { + case "shift": modifiers.shift = true; break; + case "meta": case "cmd": case "command": modifiers.meta = true; break; + case "ctrl": case "control": modifiers.ctrl = true; break; + case "alt": case "option": modifiers.alt = true; break; + default: + if (part) triggerKey = part.length === 1 ? `Key${part.toUpperCase()}` : part; + } + } + + return triggerKey ? Object.freeze({ modifiers: Object.freeze(modifiers), triggerKey }) : null; +} + +export function createGlobalOverlayHotkey({ hotkey = null, onToggle = () => {} } = {}) { + const shortcut = parseGlobalOverlayHotkey(hotkey); + const pressedCodes = new Set(); + + function modifierHeld(name) { + for (const code of MODIFIER_CODES[name]) { + if (pressedCodes.has(code)) return true; + } + return false; + } + + function modifiersMatch() { + return Object.entries(shortcut.modifiers) + .every(([name, required]) => modifierHeld(name) === required); + } + + function handleEvent(event) { + if ( + event?.kind !== "key" + || event.source !== "system" + || !["down", "up"].includes(event.action) + || typeof event.code !== "string" + ) return false; + + if (event.action === "up") { + pressedCodes.delete(event.code); + return false; + } + + const repeated = pressedCodes.has(event.code); + pressedCodes.add(event.code); + if (repeated || !shortcut || event.code !== shortcut.triggerKey || !modifiersMatch()) { + return false; + } + + onToggle(); + return true; + } + + function reset() { + pressedCodes.clear(); + } + + return { handleEvent, reset }; +} diff --git a/src/input_source_layer_reconciler.js b/src/input_source_layer_reconciler.js index 6fdfb11..8508b27 100644 --- a/src/input_source_layer_reconciler.js +++ b/src/input_source_layer_reconciler.js @@ -19,6 +19,7 @@ export function createInputSourceLayerReconciler({ let timer = null; let generation = 0; let pending = false; + let suspended = false; let state = { status: "waiting", message: null, sourceId: null, observedLayer: null }; const sourceByInputId = new Map( @@ -50,6 +51,10 @@ export function createInputSourceLayerReconciler({ const reconcile = () => { cancelSettling(); + if (suspended) { + publish("suspended", "Self-test controls the keyboard layer"); + return; + } const desired = sourceByInputId.get(sourceId); if (!desired) { publish(sourceId ? "unsupported-source" : "waiting"); @@ -130,6 +135,12 @@ export function createInputSourceLayerReconciler({ bleWritable = Boolean(writable); reconcile(); }, + setSuspended(nextSuspended) { + const next = Boolean(nextSuspended); + if (next === suspended) return; + suspended = next; + reconcile(); + }, resume: reconcile, dispose() { cancelSettling(); diff --git a/src/layout_catalog.js b/src/layout_catalog.js index 71fa54d..5363f8a 100644 --- a/src/layout_catalog.js +++ b/src/layout_catalog.js @@ -6,12 +6,29 @@ export function formatLayerName(rawName, index) { return spaced.charAt(0).toUpperCase() + spaced.slice(1); } -export function normalizeLayerData(layerSource) { - if (!layerSource) return { layers: [], names: [] }; +function normalizeSelfTestLayerMetadata(raw, positionCount) { + const firmwareLayerIndex = Number.isInteger(raw?.firmwareLayerIndex) && raw.firmwareLayerIndex >= 0 + ? raw.firmwareLayerIndex + : null; + const selfTestExcludedPositions = Array.isArray(raw?.selfTestExcludedPositions) + ? [...new Set(raw.selfTestExcludedPositions.filter((position) => ( + Number.isInteger(position) + && position >= 0 + && (!Number.isInteger(positionCount) || position < positionCount) + )))] + : []; + return { firmwareLayerIndex, selfTestExcludedPositions }; +} + +export function normalizeLayerData(layerSource, metadataSource = {}, positionCount = null) { + if (!layerSource) return { layers: [], names: [], layerKeys: [], layerMetadata: [] }; if (Array.isArray(layerSource)) { + const layerKeys = layerSource.map((_, index) => String(index)); return { layers: layerSource, names: layerSource.map((_, index) => `Layer ${index + 1}`), + layerKeys, + layerMetadata: layerKeys.map((key) => normalizeSelfTestLayerMetadata(metadataSource?.[key], positionCount)), }; } @@ -24,6 +41,8 @@ export function normalizeLayerData(layerSource) { return { layers: entries.map(([, layer]) => layer), names: entries.map(([name], index) => formatLayerName(name, index)), + layerKeys: entries.map(([name]) => name), + layerMetadata: entries.map(([name]) => normalizeSelfTestLayerMetadata(metadataSource?.[name], positionCount)), }; } diff --git a/src/main.js b/src/main.js index 9e0bb53..67397c6 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,8 @@ import { createPressedKeyTracker, resolveKeyElement } from "./key_highlight.js"; import { normalizeBleKeyboardFrame, normalizeSystemKeyEvent } from "./input_events.js"; import { createInputSourceController } from "./input_source_controller.js"; import { createBleHighlightController } from "./ble_highlight.js"; +import { createGlobalOverlayHotkey } from "./global_overlay_hotkey.js"; +import { routeSystemKeyEvent } from "./system_key_event_router.js"; import { formatBleKeyboardStatus } from "./ble_status.js"; import { BUILTIN_LAYOUT_FILES, normalizeConfig, pickAvailableLayout } from "./app_config.js"; import { reloadOverlayAfterSettingsSave } from "./settings_runtime.js"; @@ -22,6 +24,7 @@ import { createMacosInputSourceController } from "./macos_input_source.js"; import { createInputSourceLayerReconciler } from "./input_source_layer_reconciler.js"; import { calcBounds, calcKeyBounds, renderKeyLabel } from "./keyboard_renderer.js"; import { createSelfTestOverlayPresentation } from "./self_test/overlay_presentation.js"; +import { createSelfTestLayerLeaseCoordinator } from "./self_test/layer_lease.js"; const builtinLayoutFiles = BUILTIN_LAYOUT_FILES; let layoutDefinitions = {}; @@ -29,6 +32,8 @@ let normalizedLayoutLayers = {}; let layouts = {}; let layoutLayers = {}; let layoutLayerNames = {}; +let layoutLayerKeys = {}; +let layoutLayerMetadata = {}; let layoutSources = {}; let layoutBleSources = {}; let layoutInputSourceSync = {}; @@ -38,6 +43,9 @@ let comboBordersById = new Map(); let comboBorderEls = []; let layoutLoadErrors = []; let selfTestSourceStateSubscribed = false; +let selfTestLayerLease = null; +let observedBleLayer = null; +let bleLayerControlStatus = { state: "idle", writable: false }; function publishSelfTestSourceState(status) { if (!selfTestSourceStateSubscribed || !window.__TAURI__?.event?.emitTo) return; @@ -46,6 +54,13 @@ function publishSelfTestSourceState(status) { .catch(() => { selfTestSourceStateSubscribed = false; }); } +function publishSelfTestLayerLeaseStatus(status) { + if (!window.__TAURI__?.event?.emitTo) return; + window.__TAURI__.event + .emitTo("keyboard-self-test", "self-test-layer-lease-status", status) + .catch(() => {}); +} + async function loadLayoutDefinition(key, source) { // source: true (builtin) or string path if (source === true) { @@ -134,15 +149,23 @@ async function loadLayoutDefinitions(config) { function rebuildLayoutData() { normalizedLayoutLayers = {}; layoutLayerNames = {}; + layoutLayerKeys = {}; + layoutLayerMetadata = {}; layouts = {}; layoutBleSources = {}; layoutInputSourceSync = {}; comboDefinitionsByLayout = {}; for (const [key, def] of Object.entries(layoutDefinitions)) { - const { layers, names } = normalizeLayerData(def.keyLayers); + const { layers, names, layerKeys, layerMetadata } = normalizeLayerData( + def.keyLayers, + def.layerMetadata, + def.keyPositions?.length, + ); normalizedLayoutLayers[key] = layers; layoutLayerNames[key] = names; + layoutLayerKeys[key] = layerKeys; + layoutLayerMetadata[key] = layerMetadata; layouts[key] = buildLayout(def, layers); layoutBleSources[key] = normalizeBleLayerSource(def); const inputSourceSync = normalizeInputSourceSync(def, layers.length); @@ -189,12 +212,9 @@ let languageMenuState = { }; let shiftHeld = false; let altGrHeld = false; -let metaHeld = false; -let ctrlHeld = false; -let altHeld = false; -let parsedToggleHotkey = null; let tauriHandle = null; let inputSourceController = null; +let globalOverlayHotkey = null; let bleHighlightController = null; let highlightingStatus = null; let bleKeyboardStatus = null; @@ -280,6 +300,7 @@ async function startLanguageSync(layoutKey) { async function selectLanguage(inputSourceId) { if (!macosInputSource || languageMenuState.languagePendingId) return false; updateLanguageMenu({ languagePendingId: inputSourceId, languageMessage: null }); + selfTestLayerLease?.invalidate("input-source-selected"); try { await macosInputSource.select(inputSourceId); return true; @@ -591,24 +612,6 @@ function applyLayer(index) { renderLayerIndicator(); } -function parseToggleHotkey(str) { - if (!str) return null; - const parts = str.split("+").map((p) => p.trim()); - const modifiers = { shift: false, meta: false, ctrl: false, alt: false }; - let triggerKey = null; - for (const part of parts) { - switch (part.toLowerCase()) { - case "shift": modifiers.shift = true; break; - case "meta": case "cmd": case "command": modifiers.meta = true; break; - case "ctrl": case "control": modifiers.ctrl = true; break; - case "alt": case "option": modifiers.alt = true; break; - default: - triggerKey = part.length === 1 ? `Key${part.toUpperCase()}` : part; - } - } - return triggerKey ? { modifiers, triggerKey } : null; -} - function handleKey(code, type) { const wasShiftHeld = shiftHeld; const wasAltGrHeld = altGrHeld; @@ -616,23 +619,11 @@ function handleKey(code, type) { if (type === "down") { setComboActive(code, true); if (code === "ShiftLeft" || code === "ShiftRight") shiftHeld = true; - if (code === "AltGr") { altGrHeld = true; altHeld = true; } - if (code === "Alt") altHeld = true; - if (code === "MetaLeft" || code === "MetaRight") metaHeld = true; - if (code === "ControlLeft" || code === "ControlRight") ctrlHeld = true; - if (parsedToggleHotkey && code === parsedToggleHotkey.triggerKey) { - const m = parsedToggleHotkey.modifiers; - if (shiftHeld === m.shift && metaHeld === m.meta && ctrlHeld === m.ctrl && altHeld === m.alt) { - tauriHandle?.core?.invoke("toggle_window").catch(console.error); - } - } + if (code === "AltGr") altGrHeld = true; } else if (type === "up") { setComboActive(code, false); if (code === "ShiftLeft" || code === "ShiftRight") shiftHeld = false; - if (code === "AltGr") { altGrHeld = false; altHeld = false; } - if (code === "Alt") altHeld = false; - if (code === "MetaLeft" || code === "MetaRight") metaHeld = false; - if (code === "ControlLeft" || code === "ControlRight") ctrlHeld = false; + if (code === "AltGr") altGrHeld = false; } console.log(`Key ${code} ${type}`); @@ -659,9 +650,6 @@ function clearHighlightState() { pressedKeyTracker.clear(); shiftHeld = false; altGrHeld = false; - metaHeld = false; - ctrlHeld = false; - altHeld = false; } function handleNormalizedInputEvent(event) { @@ -682,6 +670,7 @@ async function refreshExternalLayout(key) { } async function reloadCurrentLayout(key) { + selfTestLayerLease?.invalidate("layout-reloaded"); return reloadActiveExternalLayout({ key, getCurrentLayoutKey: () => currentLayoutKey, @@ -758,6 +747,7 @@ async function enterMiniMode() { } async function setLayout(key) { + if (key !== currentLayoutKey) selfTestLayerLease?.invalidate("layout-changed"); const previousKey = currentLayoutKey; const { ok, error } = await refreshExternalLayout(key); if (!ok) { @@ -823,6 +813,10 @@ window.addEventListener("DOMContentLoaded", async () => { .catch((err) => console.error("Failed to listen enter-mini-mode-requested:", err)); } const config = await loadConfig(); + globalOverlayHotkey = createGlobalOverlayHotkey({ + hotkey: config?.toggleHotkey ?? null, + onToggle: () => tauriHandle?.core?.invoke("toggle_window").catch(console.error), + }); inputSourceController = createInputSourceController({ onEvent: handleNormalizedInputEvent, onClearSourceState: clearHighlightState, @@ -844,7 +838,6 @@ window.addEventListener("DOMContentLoaded", async () => { : `BLE position ${event.position} is not present in the loaded layout.`, ), }); - parsedToggleHotkey = parseToggleHotkey(config?.toggleHotkey ?? null); await loadLayoutDefinitions(config); if (Object.keys(layoutDefinitions).length === 0) { console.error("No layouts loaded; cannot initialize UI"); @@ -860,10 +853,17 @@ window.addEventListener("DOMContentLoaded", async () => { bleLayerSync = createBleLayerSyncController({ tauri, onLayerChange: (layer) => { + observedBleLayer = layer; + selfTestLayerLease?.observeLayer(layer); applyLayer(layer); sourceLayerReconciler?.setLayer(layer); }, onStatusChange: (status) => { + bleLayerControlStatus = { state: status.state, writable: Boolean(status.writable) }; + if (status.state !== "connected") observedBleLayer = null; + if (status.state !== "connected" || !status.writable) { + selfTestLayerLease?.reportUnavailable(status.message ?? "Writable BLE layer control is unavailable"); + } menuStateController?.handleBleStatus(status); if (status.layoutKey === currentLayoutKey) { sourceLayerReconciler?.setBleStatus(status.state, status.writable); @@ -873,6 +873,22 @@ window.addEventListener("DOMContentLoaded", async () => { } }, }); + selfTestLayerLease = createSelfTestLayerLeaseCoordinator({ + getActiveLayoutKey: () => currentLayoutKey, + getObservedLayer: () => observedBleLayer, + isWritable: () => bleLayerControlStatus.state === "connected" + && bleLayerControlStatus.writable + && bleLayerSync?.getActiveLayoutKey() === currentLayoutKey, + validateLayerRequest: (request) => { + const layerIndex = layoutLayerKeys[currentLayoutKey]?.indexOf(request.layerKey) ?? -1; + return layerIndex >= 0 + && layoutLayerMetadata[currentLayoutKey]?.[layerIndex]?.firmwareLayerIndex + === request.firmwareLayerIndex; + }, + writeLayer: (layer, acceptableLayers) => bleLayerSync.writeLayer(layer, acceptableLayers), + setReconciliationSuspended: (suspended) => sourceLayerReconciler?.setSuspended(suspended), + onStatus: publishSelfTestLayerLeaseStatus, + }); if (tauri?.core?.invoke && tauri?.event?.listen) { macosInputSource = createMacosInputSourceController({ @@ -940,7 +956,12 @@ window.addEventListener("DOMContentLoaded", async () => { tauri.event .listen("key_event", (e) => { const event = normalizeSystemKeyEvent(e.payload); - if (event) inputSourceController.handleEvent(event); + if (event) { + routeSystemKeyEvent(event, { + hotkeyController: globalOverlayHotkey, + inputSourceController, + }); + } }) .catch((err) => console.error("Failed to listen key_event:", err)); @@ -1004,6 +1025,30 @@ window.addEventListener("DOMContentLoaded", async () => { }) .catch((err) => console.error("Failed to listen self-test-source-request:", err)); + tauri.event + .listen("self-test-layer-lease-request", (event) => { + selfTestLayerLease.acquire(event.payload ?? {}); + }) + .catch((err) => console.error("Failed to listen self-test-layer-lease-request:", err)); + + tauri.event + .listen("self-test-layer-lease-reassert", (event) => { + selfTestLayerLease.reassert(event.payload?.generation); + }) + .catch((err) => console.error("Failed to listen self-test-layer-lease-reassert:", err)); + + tauri.event + .listen("self-test-layer-lease-release", (event) => { + selfTestLayerLease.release(event.payload?.generation); + }) + .catch((err) => console.error("Failed to listen self-test-layer-lease-release:", err)); + + tauri.event + .listen("self-test-layer-lease-manual", (event) => { + selfTestLayerLease.invalidateGeneration(event.payload?.generation, "manual-continuation"); + }) + .catch((err) => console.error("Failed to listen self-test-layer-lease-manual:", err)); + tauri.event .listen("layout_selected", (e) => { const key = e.payload?.layout; diff --git a/src/self-test.css b/src/self-test.css index feefdc0..f51abcc 100644 --- a/src/self-test.css +++ b/src/self-test.css @@ -7,6 +7,9 @@ body { margin: 0; min-width: 500px; background: radial-gradient(circle at top, # h1, h2, p { margin-top: 0; } h1 { margin-bottom: 0; font-size: 2rem; } h2 { margin-bottom: 12px; } .eyebrow { color: #93a4c7; font-size: .75rem; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; } .panel { margin-bottom: 16px; padding: 20px; border: 1px solid #36415d; border-radius: 16px; background: rgba(23, 28, 40, .94); box-shadow: 0 16px 46px rgba(0,0,0,.25); } +.layer-control { margin-bottom: 16px; padding: 12px 16px; border: 1px solid #44516d; border-radius: 12px; background: rgba(20, 26, 39, .88); } +.layer-control p { margin: 0; color: #c7d0e4; }.layer-control p[data-state="active"] { color: #9fe3b0; }.layer-control p[data-state="lost"], .layer-control p[data-state="error"] { color: #ffbd87; } +.layer-control .actions { justify-content: flex-start; margin-top: 10px; } .selectors { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; color: #c7d0e4; font-weight: 700; } select, .button { font: inherit; border-radius: 9px; border: 1px solid #53617e; } diff --git a/src/self-test.html b/src/self-test.html index 0eba49b..9ed12c2 100644 --- a/src/self-test.html +++ b/src/self-test.html @@ -13,6 +13,14 @@ +
    +

    Layer activation will be selected when the test starts.

    + +
    +

    Choose what to test

    @@ -22,7 +30,7 @@

    Choose what to test

    Loading layouts…

    -
    Activate the selected layer and release all keys before starting. A ready BLE event stream tests physical positions and firmware-resolved combos. Otherwise the test falls back to global HID events from any attached keyboard. Modifier chords pass only after the trigger and every modifier are released.
    +
    System HID output determines every ordinary Passed result. When the layout and keyboard support writable BLE layer control, the selected layer is activated and confirmed automatically. Otherwise activate it manually. BLE key telemetry is optional position evidence and may be unavailable or disabled for privacy. Modifier chords pass only after the trigger and every modifier are released.
    @@ -49,8 +57,9 @@

    Follow the highlighted key

    diff --git a/src/self-test.js b/src/self-test.js index be12cfd..6bb1044 100644 --- a/src/self-test.js +++ b/src/self-test.js @@ -1,5 +1,5 @@ import { loadLayoutCatalog, normalizeLayerData } from "./layout_catalog.js"; -import { buildTestPlan, createSelfTestController } from "./self_test/controller.js"; +import { buildComboTestPlan, buildTestPlan, createSelfTestController } from "./self_test/controller.js"; import { createOverlayPresentationPayload } from "./self_test/overlay_presentation.js"; import { initializeSecondaryWindow, SECONDARY_WINDOWS } from "./secondary_window_ready.js"; import { normalizeBleKeyboardFrame, normalizeSystemKeyEvent } from "./input_events.js"; @@ -7,11 +7,11 @@ import { createSelfTestLayerSession } from "./self_test/layer_session.js"; const elements = Object.fromEntries([ "setupView", "activeView", "resultsView", "layoutSelect", "layerSelect", "catalogErrors", "testability", - "startButton", "selectionLabel", "progressText", "progressBar", "instruction", "expectedCode", + "startButton", "comboTestButton", "comboTestStatus", "selectionLabel", "progressText", "progressBar", "instruction", "expectedCode", "receivedCode", "mismatchActions", "waitingActions", "retryButton", "problemButton", "skipButton", "stopButton", "resultCounts", "problemList", "retestButton", "anotherLayerButton", "closeButton", "closeResultsButton", "liveStatus", "transportDiagnostics", - "layerControlStatus", "layerControlActions", "layerRetryButton", "manualContinueButton", "resultEvidence", + "layerControlStatus", "layerControlActions", "layerRetryButton", "manualContinueButton", "resultEvidence", "resultNotice", ].map((id) => [id, document.getElementById(id)])); const tauri = window.__TAURI__; @@ -31,16 +31,12 @@ async function readConfig() { function currentData() { const definition = catalog?.definitions[selectedLayoutKey]; - const normalized = normalizeLayerData( - definition?.keyLayers, - definition?.layerMetadata, - definition?.keyPositions?.length, - ); + const normalized = normalizeLayerData(definition?.keyLayers); return { definition, ...normalized }; } function buildSelectedPlan() { - const { definition, layers, names, layerKeys, layerMetadata } = currentData(); + const { definition, layers, names, layerKeys } = currentData(); if (!definition) return null; return buildTestPlan({ layoutKey: selectedLayoutKey, @@ -48,12 +44,17 @@ function buildSelectedPlan() { layers, layerNames: names, layerKeys, - layerMetadata, layerIndex: selectedLayerIndex, inputSource: effectiveInputSource, }); } +function buildGlobalComboPlan() { + const { definition } = currentData(); + if (!definition) return null; + return buildComboTestPlan({ layoutKey: selectedLayoutKey, definition }); +} + function renderSetup() { const { definition, layers, names } = currentData(); elements.layerSelect.innerHTML = ""; @@ -64,14 +65,22 @@ function renderSetup() { elements.layerSelect.value = String(selectedLayerIndex); elements.layerSelect.disabled = !layers.length; const plan = buildSelectedPlan(); + const comboPlan = buildGlobalComboPlan(); const testable = plan?.testableIndexes.length ?? 0; const notTestable = plan?.entries.filter((entry) => entry.kind === "key" && !entry.testable).length ?? 0; - const combos = plan?.entries.filter((entry) => entry.kind === "combo").length ?? 0; elements.testability.textContent = plan - ? `${testable} guided items${combos ? ` · ${combos} firmware combos` : ""} · HID output verification${effectiveInputSource === "ble" ? " · optional BLE position diagnostics" : " · no BLE position telemetry"}${notTestable ? ` · ${notTestable} not testable` : ""}` + ? `${testable} guided layer items · HID output verification${effectiveInputSource === "ble" ? " · optional BLE position diagnostics" : " · no BLE position telemetry"}${notTestable ? ` · ${notTestable} not testable` : ""}` : "No compatible layout is available."; + const comboCount = comboPlan?.testableIndexes.length ?? 0; + const comboReady = effectiveInputSource === "ble" && comboCount > 0; + elements.comboTestStatus.textContent = comboCount === 0 + ? "No global combos with a non-empty output code are defined for this layout." + : comboReady + ? `${comboCount} global firmware combos are ready for BLE verification.` + : `${comboCount} global firmware combos require ready, permitted BLE telemetry.`; const activating = layerSessionState.mode === "activating"; elements.startButton.disabled = testable === 0 || activating; + elements.comboTestButton.disabled = !comboReady || activating; elements.layoutSelect.disabled = !layers.length || activating; elements.layerSelect.disabled = !layers.length || activating; elements.selectionLabel.textContent = definition ? `${definition.name} · ${names[selectedLayerIndex] ?? "Layer"}` : ""; @@ -95,7 +104,8 @@ function publishOverlay(snapshot) { function renderResults(snapshot) { elements.resultCounts.innerHTML = ""; - for (const [status, label] of Object.entries({ passed: "Passed (HID)", unexpected: "Unexpected", skipped: "Skipped", "not-testable": "Not testable" })) { + const globalCombos = snapshot.plan.planKind === "global-combos"; + for (const [status, label] of Object.entries({ passed: globalCombos ? "Passed (firmware)" : "Passed (HID)", unexpected: "Unexpected", skipped: "Skipped", "not-testable": "Not testable" })) { const item = document.createElement("div"); item.innerHTML = `${snapshot.counts[status]}${label}`; elements.resultCounts.appendChild(item); } elements.problemList.innerHTML = ""; @@ -107,10 +117,16 @@ function renderResults(snapshot) { elements.problemList.appendChild(item); } elements.retestButton.disabled = snapshot.counts.unexpected + snapshot.counts.skipped === 0; - const passedResults = Object.values(snapshot.results).filter((result) => result.status === "passed"); - const corroborated = passedResults.filter((result) => result.bleCorroborated).length; - const warnings = passedResults.filter((result) => result.blePositionWarning).length; - elements.resultEvidence.textContent = `${snapshot.counts.passed} ordinary items passed from system HID evidence. ${corroborated} had matching BLE position corroboration.${warnings ? ` ${warnings} had a BLE position warning.` : ""}`; + if (globalCombos) { + elements.resultEvidence.textContent = `${snapshot.counts.passed} global combos passed from matching firmware BLE events.`; + elements.resultNotice.textContent = "Global combo results verify declared firmware combo events. They are independent of the selected layer and do not use system HID events as their verdict."; + } else { + const passedResults = Object.values(snapshot.results).filter((result) => result.status === "passed"); + const corroborated = passedResults.filter((result) => result.bleCorroborated).length; + const warnings = passedResults.filter((result) => result.blePositionWarning).length; + elements.resultEvidence.textContent = `${snapshot.counts.passed} ordinary items passed from system HID evidence. ${corroborated} had matching BLE position corroboration.${warnings ? ` ${warnings} had a BLE position warning.` : ""}`; + elements.resultNotice.textContent = "Ordinary results verify system HID output. BLE position corroboration is reported separately and never substitutes for or blocks that verdict."; + } elements.liveStatus.textContent = `Test complete. ${snapshot.counts.passed} passed, ${snapshot.counts.unexpected} unexpected, ${snapshot.counts.skipped} skipped.`; } @@ -180,6 +196,12 @@ async function startSelectedPlan() { if (plan) await layerSession.start(plan); } +async function startGlobalComboPlan() { + if (effectiveInputSource !== "ble") return; + const plan = buildGlobalComboPlan(); + if (plan?.testableIndexes.length) await layerSession.start(plan); +} + async function closeWindow() { await layerSession.release(); controller.dispose(); @@ -190,6 +212,7 @@ async function closeWindow() { elements.layoutSelect.addEventListener("change", () => { selectedLayoutKey = elements.layoutSelect.value; selectedLayerIndex = 0; renderSetup(); }); elements.layerSelect.addEventListener("change", () => { selectedLayerIndex = Number(elements.layerSelect.value); renderSetup(); }); elements.startButton.addEventListener("click", startSelectedPlan); +elements.comboTestButton.addEventListener("click", startGlobalComboPlan); elements.retryButton.addEventListener("click", () => controller.retry()); elements.problemButton.addEventListener("click", () => controller.markProblem()); elements.skipButton.addEventListener("click", () => controller.skip()); @@ -251,6 +274,11 @@ async function initialize() { const previous = effectiveInputSource; effectiveInputSource = next; controller.handleSourceTransition(previous, next, event.payload?.reason); + const snapshot = controller.getSnapshot(); + if (snapshot.plan?.planKind === "global-combos") { + if (next !== "ble") controller.pause("BLE combo telemetry is unavailable. Restore it or stop the test."); + else if (snapshot.phase === "paused") controller.resume(); + } if (controller.getSnapshot().phase === "setup") renderSetup(); }); await tauri.event.listen("self-test-layer-lease-status", (event) => { diff --git a/src/self_test/controller.js b/src/self_test/controller.js index a0820eb..d02f3bb 100644 --- a/src/self_test/controller.js +++ b/src/self_test/controller.js @@ -18,23 +18,25 @@ function positionsForCombo(combo, keyPositions) { )); } -function comboEntries(definition, startIndex, allowed) { +function comboEntries(definition, allowed) { const entries = []; for (const combo of (Array.isArray(definition.combos) ? definition.combos : [])) { - const positions = positionsForCombo(combo, definition.keyPositions); + const code = typeof combo?.code === "string" ? combo.code.trim() : ""; + if (!code) continue; + const positions = positionsForCombo(combo, definition.keyPositions ?? []); const comboId = Number.isInteger(combo?.id) && combo.id > 0 ? combo.id : null; if ((!comboId && positions.length === 0) || positions.some((position) => !Number.isInteger(position) || position < 0 || position >= definition.keyPositions.length)) { continue; } - const index = startIndex + entries.length; + const index = entries.length; const sourceTestable = true; entries.push(freezeEntry({ index, kind: "combo", position: {}, - label: combo.code ?? (comboId ? `Combo ${comboId}` : `Combo ${positions.join("+")}`), - rawCode: combo.code ?? "", + label: code, + rawCode: code, descriptor: { supported: false, trigger: null, modifiers: [] }, comboId, positions: Object.freeze([...positions]), @@ -52,19 +54,16 @@ export function buildTestPlan({ layers, layerNames, layerKeys = [], - layerMetadata = [], layerIndex = 0, onlyIndexes = null, inputSource = "system", }) { const allowed = onlyIndexes ? new Set(onlyIndexes) : null; - const selectedMetadata = layerMetadata[layerIndex] ?? {}; - const excludedPositions = new Set(selectedMetadata.selfTestExcludedPositions ?? []); const entries = definition.keyPositions.map((position, index) => { const rawEntry = effectiveLayerEntry(layers, layerIndex, index); const normalized = normalizeKeyEntry(rawEntry); const descriptor = normalizeHidDescriptor(normalized.code); - const sourceTestable = descriptor.supported && !excludedPositions.has(index); + const sourceTestable = descriptor.supported; return freezeEntry({ index, kind: "key", @@ -79,19 +78,14 @@ export function buildTestPlan({ excludedFromRetest: Boolean(allowed && !allowed.has(index) && sourceTestable), }); }); - if (inputSource === "ble") { - entries.push(...comboEntries(definition, entries.length, allowed)); - } const testableIndexes = entries.filter((entry) => entry.testable).map((entry) => entry.index); return Object.freeze({ + planKind: "layer", layoutKey, layoutName: definition.name ?? layoutKey, layerIndex, layerKey: layerKeys[layerIndex] ?? String(layerIndex), - firmwareLayerIndex: Number.isInteger(selectedMetadata.firmwareLayerIndex) - ? selectedMetadata.firmwareLayerIndex - : null, - selfTestExcludedPositions: Object.freeze([...excludedPositions]), + firmwareLayerIndex: layerIndex, inputSource, layerName: layerNames?.[layerIndex] ?? `Layer ${layerIndex + 1}`, keySize: Object.freeze({ ...definition.keySize }), @@ -100,6 +94,25 @@ export function buildTestPlan({ }); } +export function buildComboTestPlan({ layoutKey, definition, onlyIndexes = null }) { + const allowed = onlyIndexes ? new Set(onlyIndexes) : null; + const entries = comboEntries(definition, allowed); + const testableIndexes = entries.filter((entry) => entry.testable).map((entry) => entry.index); + return Object.freeze({ + planKind: "global-combos", + layoutKey, + layoutName: definition.name ?? layoutKey, + layerIndex: null, + layerKey: null, + firmwareLayerIndex: null, + inputSource: "ble", + layerName: "Global firmware combos", + keySize: Object.freeze({ ...definition.keySize }), + entries: Object.freeze(entries), + testableIndexes: Object.freeze(testableIndexes), + }); +} + function initialResults(plan) { return new Map(plan.entries .filter((entry) => !entry.testable && !entry.excludedFromRetest) diff --git a/src/self_test/layer_lease.js b/src/self_test/layer_lease.js index ad521db..4b4d0d8 100644 --- a/src/self_test/layer_lease.js +++ b/src/self_test/layer_lease.js @@ -13,6 +13,12 @@ function validRequest(request) { && request.firmwareLayerIndex >= 0; } +export function matchesOrderedLayerRequest(layerKeys, request) { + if (!Array.isArray(layerKeys)) return false; + const layerIndex = layerKeys.indexOf(request?.layerKey); + return layerIndex >= 0 && layerIndex === request?.firmwareLayerIndex; +} + export function createSelfTestLayerLeaseCoordinator({ getActiveLayoutKey, getObservedLayer, diff --git a/src/self_test/layer_session.js b/src/self_test/layer_session.js index 5692c29..bcef71e 100644 --- a/src/self_test/layer_session.js +++ b/src/self_test/layer_session.js @@ -34,16 +34,22 @@ export function createSelfTestLayerSession({ await emit("self-test-layer-lease-release", { generation: current.generation }); } const generation = ++nextGeneration; + const layerNotRequired = plan?.planKind === "global-combos"; const automatic = Number.isInteger(plan?.firmwareLayerIndex) && plan.firmwareLayerIndex >= 0; current = { generation, plan, automatic, started: false, - mode: automatic ? "activating" : "manual", + mode: layerNotRequired ? "not-required" : automatic ? "activating" : "manual", message: null, reassertRequested: false, }; + if (layerNotRequired) { + beginPlan(); + publish("not-required", "Global combo testing does not change the active layer."); + return generation; + } if (!automatic) { beginPlan(); publish("manual", "Activate the selected layer manually; its state cannot be confirmed."); @@ -124,7 +130,9 @@ export function createSelfTestLayerSession({ const release = async () => { if (!current) return false; const generation = current.generation; - await emit("self-test-layer-lease-release", { generation }); + if (current.plan?.planKind !== "global-combos") { + await emit("self-test-layer-lease-release", { generation }); + } current = null; onState({ generation, mode: "idle", message: null, automatic: false, started: false }); return true; diff --git a/src/self_test/overlay_presentation.js b/src/self_test/overlay_presentation.js index c5bf7bd..d0aae13 100644 --- a/src/self_test/overlay_presentation.js +++ b/src/self_test/overlay_presentation.js @@ -15,7 +15,13 @@ export function createOverlayPresentationPayload(snapshot) { if (entry.kind !== "combo" && !entry.testable && !entry.excludedFromRetest) states[entry.index] = "not-testable"; } for (const [index, result] of Object.entries(snapshot.results ?? {})) { - if (SELF_TEST_KEY_STATES.includes(result.status)) states[index] = result.status; + if (!SELF_TEST_KEY_STATES.includes(result.status)) continue; + const entry = snapshot.plan.entries[Number(index)]; + if (entry?.kind === "combo") { + for (const position of entry.positions) states[position] = result.status; + } else { + states[index] = result.status; + } } if (snapshot.current && snapshot.phase !== "waiting-clean") { if (snapshot.current.kind === "combo") { diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index e9c9efa..af5ca2e 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -4,7 +4,7 @@ These files are reviewed examples of supported Keyboard Helper metadata. Tests should reuse them before inventing a private schema variant. - `layouts/` contains minimal layouts that preserve the same positional, - layered, per-layer self-test metadata, combo/chord, BLE, and input-source + ordered-layer, code-derived self-test, combo/chord, BLE, and input-source shapes as production layouts. - `configs/` contains startup, external-layout, and intentionally invalid draft configurations. diff --git a/tests/fixtures/layouts/corne-connected.json b/tests/fixtures/layouts/corne-connected.json index 1ae3017..c08e6f8 100644 --- a/tests/fixtures/layouts/corne-connected.json +++ b/tests/fixtures/layouts/corne-connected.json @@ -15,13 +15,6 @@ "ruShift": [["Й", "Shift+KeyQ"], ["Ц", "Shift+KeyW"], ["Ф", "Shift+KeyA"], ["Ы", "Shift+KeyS"], null], "utility": [["BT", "F19"], null, null, null, null] }, - "layerMetadata": { - "de": { "firmwareLayerIndex": 0, "selfTestExcludedPositions": [4] }, - "deShift": { "firmwareLayerIndex": 1, "selfTestExcludedPositions": [] }, - "ru": { "firmwareLayerIndex": 2, "selfTestExcludedPositions": [4] }, - "ruShift": { "firmwareLayerIndex": 3, "selfTestExcludedPositions": [] }, - "utility": { "firmwareLayerIndex": 4, "selfTestExcludedPositions": [0] } - }, "combos": [ { "id": "escape-combo", diff --git a/tests/golden_fixtures.test.mjs b/tests/golden_fixtures.test.mjs index 43e4afd..df8bd72 100644 --- a/tests/golden_fixtures.test.mjs +++ b/tests/golden_fixtures.test.mjs @@ -41,6 +41,7 @@ test("golden layout fixtures are accepted by the external layout parser", () => test("golden Corne fixture exercises layer, combo, BLE, and input-source contracts", () => { const corne = readJsonFixture("layouts/corne-connected.json"); assert.equal(Object.keys(corne.keyLayers).length, 5); + assert.equal("layerMetadata" in corne, false); assert.equal(corne.combos[0].id, "escape-combo"); assert.equal(normalizeBleLayerSource(corne)?.format, "int32-le"); assert.equal(normalizeInputSourceSync(corne, 5, { platform: "macos" }).error, null); diff --git a/tests/layout_runtime.test.mjs b/tests/layout_runtime.test.mjs index 1d07319..cbb4372 100644 --- a/tests/layout_runtime.test.mjs +++ b/tests/layout_runtime.test.mjs @@ -18,50 +18,19 @@ test("normalizes named layers and keeps stable display order", () => { layers: [base, lower], names: ["Default", "Lower layer"], layerKeys: ["default", "lower_layer"], - layerMetadata: [ - { firmwareLayerIndex: null, selfTestExcludedPositions: [] }, - { firmwareLayerIndex: null, selfTestExcludedPositions: [] }, - ], }); }); -test("normalizes explicit firmware layer metadata without inferring from order or labels", () => { +test("normalized layer order is stable for firmware ordinal mapping", () => { const base = [["A", "KeyA"], ["Fn", ""]]; const lower = [["1", "Digit1"], null]; - assert.deepEqual(normalizeLayerData( - { Friendly_base: base, lower_layer: lower }, - { - Friendly_base: { firmwareLayerIndex: 7, selfTestExcludedPositions: [1, 1, -1, 2, "0"] }, - lower_layer: { firmwareLayerIndex: 13, selfTestExcludedPositions: [0] }, - }, - 2, - ), { + assert.deepEqual(normalizeLayerData({ Friendly_base: base, lower_layer: lower }), { layers: [base, lower], names: ["Friendly base", "Lower layer"], layerKeys: ["Friendly_base", "lower_layer"], - layerMetadata: [ - { firmwareLayerIndex: 7, selfTestExcludedPositions: [1] }, - { firmwareLayerIndex: 13, selfTestExcludedPositions: [0] }, - ], }); }); -test("rejects malformed firmware indexes and exclusions instead of guessing", () => { - const normalized = normalizeLayerData( - { alpha: [["A", "KeyA"]], beta: [["B", "KeyB"]] }, - { - alpha: { firmwareLayerIndex: "0", selfTestExcludedPositions: "all" }, - Renamed_Beta: { firmwareLayerIndex: 1, selfTestExcludedPositions: [0] }, - }, - 1, - ); - assert.deepEqual(normalized.layerKeys, ["alpha", "beta"]); - assert.deepEqual(normalized.layerMetadata, [ - { firmwareLayerIndex: null, selfTestExcludedPositions: [] }, - { firmwareLayerIndex: null, selfTestExcludedPositions: [] }, - ]); -}); - test("effective layer entries fall back only for absent or null entries", () => { const layers = [ [["A", "KeyA"], ["B", "KeyB"]], diff --git a/tests/self_test_controller.test.mjs b/tests/self_test_controller.test.mjs index 875d524..f38bd70 100644 --- a/tests/self_test_controller.test.mjs +++ b/tests/self_test_controller.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { buildTestPlan, createSelfTestController } from "../src/self_test/controller.js"; +import { buildComboTestPlan, buildTestPlan, createSelfTestController } from "../src/self_test/controller.js"; function fixture(layer = null) { const base = [["A", "KeyA"], ["A2", "KeyA"], ["Q", "Shift+KeyQ"], ["Layer", ""]]; @@ -212,7 +212,7 @@ test("empty plans do not start and stopping disposes session results", () => { assert.deepEqual(controller.getSnapshot().results, {}); }); -test("BLE plans keep unsupported ordinary positions untestable and include firmware-resolved combos", () => { +test("selected-layer plans keep unsupported ordinary positions untestable and never include global combos", () => { const definition = { name: "BLE fixture", keySize: { w: 40, h: 40, gap: 4 }, @@ -226,14 +226,39 @@ test("BLE plans keep unsupported ordinary positions untestable and include firmw layerNames: ["Base"], inputSource: "ble", }); - assert.deepEqual(plan.testableIndexes, [1, 2]); + assert.deepEqual(plan.testableIndexes, [1]); + assert.equal(plan.planKind, "layer"); assert.equal(plan.entries[0].kind, "key"); assert.equal(plan.entries[0].testable, false); - assert.equal(plan.entries[2].kind, "combo"); - assert.equal(plan.entries[2].comboId, 9); + assert.equal(plan.entries.some((entry) => entry.kind === "combo"), false); }); -test("selected-layer metadata excludes a valid fallback HID position and preserves firmware mapping", () => { +test("global combo plans include only valid combos with non-empty trimmed codes", () => { + const plan = buildComboTestPlan({ + layoutKey: "combo-fixture", + definition: { + name: "Combo fixture", + keySize: { w: 40, h: 40, gap: 4 }, + keyPositions: [{ row: 0, col: 0 }, { row: 0, col: 1 }], + combos: [ + { id: 9, positions: [0, 1], code: " Escape " }, + { id: 10, positions: [0, 1], code: "" }, + { id: 11, positions: [0, 1], code: " " }, + { id: 12, positions: [0, 1] }, + { positions: [0, 7], code: "Invalid positions" }, + ], + }, + }); + assert.equal(plan.planKind, "global-combos"); + assert.equal(plan.layerIndex, null); + assert.equal(plan.firmwareLayerIndex, null); + assert.deepEqual(plan.testableIndexes, [0]); + assert.equal(plan.entries.length, 1); + assert.equal(plan.entries[0].rawCode, "Escape"); + assert.equal(plan.entries[0].comboId, 9); +}); + +test("selected-layer order becomes the firmware index and empty codes remain untestable", () => { const plan = buildTestPlan({ layoutKey: "mapped", definition: { @@ -241,20 +266,15 @@ test("selected-layer metadata excludes a valid fallback HID position and preserv keySize: { w: 40, h: 40, gap: 4 }, keyPositions: [{ row: 0, col: 0 }, { row: 0, col: 1 }], }, - layers: [[["Shift", "ShiftLeft"], ["A", "KeyA"]], [null, ["B", "KeyB"]]], + layers: [[["Shift", "ShiftLeft"], ["A", "KeyA"]], [["Layer", ""], ["B", "KeyB"]]], layerNames: ["Base", "Forced shift"], layerKeys: ["base", "forced_shift"], - layerMetadata: [ - { firmwareLayerIndex: 0, selfTestExcludedPositions: [] }, - { firmwareLayerIndex: 7, selfTestExcludedPositions: [0] }, - ], layerIndex: 1, inputSource: "ble", }); assert.equal(plan.layerKey, "forced_shift"); - assert.equal(plan.firmwareLayerIndex, 7); - assert.deepEqual(plan.selfTestExcludedPositions, [0]); - assert.equal(plan.entries[0].rawCode, "ShiftLeft"); + assert.equal(plan.firmwareLayerIndex, 1); + assert.equal(plan.entries[0].rawCode, ""); assert.equal(plan.entries[0].testable, false); assert.equal(plan.entries[1].testable, true); }); @@ -284,13 +304,9 @@ test("BLE combo events pass only matching metadata and retain unmatched diagnost keyPositions: [{ row: 0, col: 0 }, { row: 0, col: 1 }], combos: [{ id: 9, positions: [0, 1], code: "Escape" }], }; - const plan = buildTestPlan({ + const plan = buildComboTestPlan({ layoutKey: "combo-fixture", definition, - layers: [[["A", "KeyA"], ["B", "KeyB"]]], - layerNames: ["Base"], - inputSource: "ble", - onlyIndexes: [2], }); const controller = createSelfTestController(); controller.start(plan); @@ -380,13 +396,9 @@ test("system HID events do not turn a firmware combo step into an ordinary misma keyPositions: [{ row: 0, col: 0 }, { row: 0, col: 1 }], combos: [{ id: 4, positions: [0, 1], code: "Escape" }], }; - const plan = buildTestPlan({ + const plan = buildComboTestPlan({ layoutKey: "combo-only", definition, - layers: [[null, null]], - layerNames: ["Base"], - inputSource: "ble", - onlyIndexes: [2], }); const controller = createSelfTestController(); controller.start(plan); diff --git a/tests/self_test_layer_lease.test.mjs b/tests/self_test_layer_lease.test.mjs index 8a57f51..2f499fb 100644 --- a/tests/self_test_layer_lease.test.mjs +++ b/tests/self_test_layer_lease.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { createSelfTestLayerLeaseCoordinator } from "../src/self_test/layer_lease.js"; +import { + createSelfTestLayerLeaseCoordinator, + matchesOrderedLayerRequest, +} from "../src/self_test/layer_lease.js"; function harness({ writable = true, failWrite = null, validateLayerRequest = () => true } = {}) { let activeLayoutKey = "corney"; @@ -59,7 +62,20 @@ test("rejects invalid requests and stale generation operations", async () => { assert.deepEqual(subject.writes, [5]); }); -test("rejects a request that does not match overlay-owned layer metadata", async () => { +test("ordered layer validation requires matching raw identity and ordinal", () => { + const layerKeys = ["Default", "Linux: Default", "Mac: Shift"]; + assert.equal(matchesOrderedLayerRequest(layerKeys, { + layerKey: "Mac: Shift", firmwareLayerIndex: 2, + }), true); + assert.equal(matchesOrderedLayerRequest(layerKeys, { + layerKey: "Mac: Shift", firmwareLayerIndex: 1, + }), false); + assert.equal(matchesOrderedLayerRequest(layerKeys, { + layerKey: "Missing", firmwareLayerIndex: 2, + }), false); +}); + +test("rejects a request that does not match the overlay-owned layer order", async () => { const subject = harness({ validateLayerRequest: (candidate) => candidate.firmwareLayerIndex === 5 }); assert.equal(await subject.coordinator.acquire({ ...request, firmwareLayerIndex: 18 }), false); assert.deepEqual(subject.writes, []); diff --git a/tests/self_test_layer_session.test.mjs b/tests/self_test_layer_session.test.mjs index 5633881..1d412d1 100644 --- a/tests/self_test_layer_session.test.mjs +++ b/tests/self_test_layer_session.test.mjs @@ -48,7 +48,7 @@ test("mapped plan waits for authoritative lease confirmation before starting", a assert.equal(subject.states.at(-1).mode, "active"); }); -test("unmapped and unavailable layer control preserve manual HID testing", async () => { +test("manual plans and unavailable layer control preserve manual HID testing", async () => { const unmapped = harness(); await unmapped.session.start(plan({ firmwareLayerIndex: null })); assert.equal(unmapped.started.length, 1); @@ -63,6 +63,19 @@ test("unmapped and unavailable layer control preserve manual HID testing", async assert.match(unavailable.states.at(-1).message, /No writable BLE/); }); +test("global combo plan starts without a layer lease or manual-layer warning", async () => { + const subject = harness(); + await subject.session.start(plan({ + planKind: "global-combos", + layerKey: null, + firmwareLayerIndex: null, + })); + assert.equal(subject.started.length, 1); + assert.deepEqual(subject.emitted, []); + assert.equal(subject.states.at(-1).mode, "not-required"); + assert.match(subject.states.at(-1).message, /does not change the active layer/i); +}); + test("stale confirmations cannot start or alter a newer plan", async () => { const subject = harness(); const first = await subject.session.start(plan()); diff --git a/tests/self_test_overlay_presentation.test.mjs b/tests/self_test_overlay_presentation.test.mjs index 5d6bfd4..9ab8179 100644 --- a/tests/self_test_overlay_presentation.test.mjs +++ b/tests/self_test_overlay_presentation.test.mjs @@ -42,6 +42,28 @@ test("presentation payload maps results and the current physical position", () = }); }); +test("combo results and guidance map to participating physical positions", () => { + const plan = { + layoutKey: "corne", layerIndex: null, + entries: [{ index: 0, kind: "combo", positions: [1, 2], testable: true }], + }; + const active = createOverlayPresentationPayload({ + phase: "waiting-down", + plan, + current: { index: 0, kind: "combo", positions: [1, 2] }, + results: {}, + }); + assert.deepEqual(active.states, { 1: "expected", 2: "expected" }); + + const complete = createOverlayPresentationPayload({ + phase: "complete", + plan, + current: null, + results: { 0: { status: "passed" } }, + }); + assert.deepEqual(complete.states, { 1: "passed", 2: "passed" }); +}); + test("self-test outlines coexist with pressed state and clear independently", () => { const { elements, body, presentation } = harness(); presentation.update({ active: true, states: { 0: "expected", 1: "passed", 2: "skipped" } }); diff --git a/tests/self_test_ui.test.mjs b/tests/self_test_ui.test.mjs index 19fd099..c196f49 100644 --- a/tests/self_test_ui.test.mjs +++ b/tests/self_test_ui.test.mjs @@ -9,7 +9,7 @@ const overlayCss = readFileSync(new URL("../src/styles.css", import.meta.url), " const capability = JSON.parse(readFileSync(new URL("../src-tauri/capabilities/self-test.json", import.meta.url), "utf8")); test("self-test entry point exposes setup, layer control, guidance, results, and accessible status regions", () => { - for (const id of ["layoutSelect", "layerSelect", "startButton", "selectionLabel", "retryButton", "problemButton", "skipButton", "stopButton", "resultCounts", "resultEvidence", "retestButton", "anotherLayerButton", "layerControlStatus", "layerRetryButton", "manualContinueButton"]) { + for (const id of ["layoutSelect", "layerSelect", "startButton", "comboTestButton", "comboTestStatus", "selectionLabel", "retryButton", "problemButton", "skipButton", "stopButton", "resultCounts", "resultEvidence", "retestButton", "anotherLayerButton", "layerControlStatus", "layerRetryButton", "manualContinueButton"]) { assert.match(html, new RegExp(`id="${id}"`)); } assert.match(html, /role="status" aria-live="polite"/); @@ -19,6 +19,8 @@ test("self-test entry point exposes setup, layer control, guidance, results, and assert.match(html, /disabled for privacy/); assert.match(html, /Modifier chords pass only after the trigger and every modifier are released/); assert.match(html, /BLE position corroboration is reported separately/); + assert.match(html, /Test global combos/); + assert.match(html, /non-empty output code/); assert.doesNotMatch(html, /id="keyboardPreview"/); assert.match(html, /existing Keyboard Helper overlay/); }); @@ -33,6 +35,8 @@ test("self-test runtime loads its own catalog, observes native events, and dispo assert.match(script, /beforeunload/); assert.match(script, /controller\.dispose\(\)/); assert.match(script, /createSelfTestLayerSession/); + assert.match(script, /buildComboTestPlan/); + assert.match(script, /startGlobalComboPlan/); assert.match(script, /listen\("self-test-layer-lease-status"/); assert.match(script, /snapshot\.phase === "complete"/); assert.match(script, /layerSession\?\.release\(\)/); From 4f9228e528b05a9622e2d12c07257c44d0583804 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Mon, 7 Sep 2026 19:30:32 +0200 Subject: [PATCH 13/13] add android build --- docs/android-development.md | 165 ++++++++++++++++++++ package.json | 3 + src-mobile/index.html | 27 ++++ src-mobile/styles.css | 117 ++++++++++++++ src-tauri/Cargo.toml | 11 +- src-tauri/capabilities/default.json | 1 + src-tauri/capabilities/mobile-shell.json | 10 ++ src-tauri/capabilities/self-test.json | 1 + src-tauri/capabilities/settings.json | 1 + src-tauri/capabilities/typing-invaders.json | 1 + src-tauri/src/lib.rs | 10 +- src-tauri/tauri.android.conf.json | 22 +++ src-tauri/tauri.conf.json | 2 +- src-tauri/tauri.macos.conf.json | 6 + tests/mobile_shell.test.mjs | 81 ++++++++++ 15 files changed, 444 insertions(+), 14 deletions(-) create mode 100644 docs/android-development.md create mode 100644 src-mobile/index.html create mode 100644 src-mobile/styles.css create mode 100644 src-tauri/capabilities/mobile-shell.json create mode 100644 src-tauri/tauri.android.conf.json create mode 100644 src-tauri/tauri.macos.conf.json create mode 100644 tests/mobile_shell.test.mjs diff --git a/docs/android-development.md b/docs/android-development.md new file mode 100644 index 0000000..cb799e0 --- /dev/null +++ b/docs/android-development.md @@ -0,0 +1,165 @@ +# Android Companion Development + +## Scope + +The current Android target is the Stage 1 Keyboard Helper Companion shell. It verifies mobile +application startup and lifecycle separation from the desktop app. It does not request Bluetooth +permissions, scan for keyboards, connect to BLE devices, render keyboard layouts, or control +firmware layers. + +## Agreed Platform Values + +- Display name: `Keyboard Helper Companion` +- Application ID: `me.maxistar.keyboardhelper.companion` +- Minimum Android API level: 24 +- Physical acceptance device: a phone running Android 12 / API level 31 or newer +- Frontend: `src-mobile/`, selected by `src-tauri/tauri.android.conf.json` +- Generated Android project: `src-tauri/gen/android/`, regenerated locally and ignored by Git + +The existing desktop identifier remains `me.maxistar.keyri-app`. + +## Verified Development Host Baseline + +The initial development environment recorded on 2026-09-07 uses: + +- macOS 26.6.2 on Apple silicon +- Node.js 24.16.0 and npm 11.13.0 +- Tauri CLI 2.9.4 and Tauri API 2.9.0 +- Rust 1.96.0 with the `aarch64-linux-android` target +- JDK 21 +- Android SDK platforms 33 through 37 +- Android SDK Build Tools 34 through 37 +- Android NDK 30.0.14904198 +- Android Debug Bridge 37.0.0 + +Use the current Tauri 2 prerequisites when preparing another host. Confirm the active environment +before initialization: + +```bash +node --version +npm --version +rustc --version +rustup target list --installed +java -version +adb version +npm run tauri -- info +``` + +Set `JAVA_HOME`, `ANDROID_HOME`, and `NDK_HOME` to the installed JDK, Android SDK, and NDK when they +are not already supplied by the shell or Android Studio. + +## Initialize or Regenerate Android + +Install JavaScript dependencies, then generate the ignored Android project non-interactively: + +```bash +npm ci +npm run android:init +``` + +The initialization script uses `--skip-targets-install`: it does not download Rust targets and +therefore remains deterministic on restricted or offline development hosts. Install the required +target explicitly before initialization; the current acceptance baseline uses +`rustup target add aarch64-linux-android`. + +`src-tauri/gen/` is intentionally ignored. Do not place durable application configuration, source +of truth, signing material, or machine-specific SDK paths there. Android identity, frontend, and +minimum API settings belong in `src-tauri/tauri.android.conf.json`. + +If generated state becomes stale, remove only `src-tauri/gen/android/`, rerun +`npm run android:init`, and review the regenerated manifest before continuing. Never remove the +whole repository or an unresolved path. + +## Prepare a Physical Device + +1. Enable Developer options and USB debugging on an Android 12 / API level 31 or newer phone. +2. Connect the phone over USB and accept its debugging authorization prompt. +3. Verify that exactly the intended device is available: + + ```bash + adb devices -l + ``` + +4. If multiple devices are attached, pass the chosen device identifier to the Tauri command rather + than relying on an implicit selection. + +## Build and Run + +Start a development build on the connected device: + +```bash +npm run android:dev +``` + +Build a debug APK for the recorded arm64 acceptance baseline without starting a device session: + +```bash +npm run android:build +``` + +Use the Tauri CLI directly with another `--target` when validating a different device ABI. + +Use `adb logcat` while reproducing startup or lifecycle failures. Inspect the package installed on +the device with: + +```bash +adb shell dumpsys package me.maxistar.keyboardhelper.companion +``` + +Remove the development installation with: + +```bash +adb uninstall me.maxistar.keyboardhelper.companion +``` + +## Stage 1 Physical Acceptance + +On the physical phone: + +1. Install from a clean state and cold-start the app in portrait. +2. Confirm the launcher label and status surface say `Keyboard Helper Companion`. +3. Confirm no Bluetooth, nearby-device, location, notification, or background-service permission + prompt appears. +4. Rotate to landscape and confirm the status surface remains readable. +5. Return to portrait, background the app, and resume it. +6. Close the process, relaunch it, and confirm one usable shell initializes. +7. Confirm the app contains no overlay, tray, global-listener, self-test, game, layout, BLE, or + remote-layer controls. + +An emulator may supplement compatibility checks, but it does not satisfy this physical acceptance +gate. + +## Recorded Bootstrap Verification + +On 2026-09-07 the ignored Android project was generated from absent local state with +`npm run android:init`, then the arm64 debug APK was built with `npm run android:build`. The APK was +installed on an API level 35 arm64 emulator and passed cold start, portrait, landscape, +background/resume, force-stop/relaunch, clean uninstall/install, and empty runtime-permission +checks. This verifies reproducibility on the recorded host; the Android 12+ physical-phone gate +remains intentionally separate and pending. + +The same APK also passed a supplementary physical-device smoke on a Xiaomi Mi 9 Lite running +Android 11 / API level 30: clean install, portrait and landscape rendering, hot resume with the +same process, cold relaunch with a new process, an empty runtime-permission set, and no app-process +crash entries. Its first WebView render took about five seconds after installation. This result +does not satisfy the agreed Android 12 / API level 31-or-newer acceptance authority. + +Final physical acceptance was completed on 2026-09-07 using a Xiaomi `2511FPC34G` running Android +16 / API level 36. The arm64 debug APK was installed from an absent package state, declared +`minSdk=24` and `targetSdk=36`, and cold-started in 452 ms. Portrait and landscape presentation were +readable, background/resume retained the same process, force-stop/relaunch created a new healthy +process, the app-specific crash log was empty, and the clean installation exposed no runtime +permissions or permission prompt. Device auto-rotation was restored after the landscape check. + +## Desktop Regression Gate + +Android bootstrap is incomplete unless the desktop path remains healthy: + +```bash +npm run check:js +cargo test --manifest-path src-tauri/Cargo.toml +npm run tauri -- build --debug +``` + +The desktop build must retain its existing identity and overlay entry surface without requiring an +Android runtime or connected device. diff --git a/package.json b/package.json index 83ebb46..407f873 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "check:js": "npm run lint && npm test", "test:privacy": "node scripts/run-js-tests.mjs tests/privacy", "test:compatibility": "node scripts/run-js-tests.mjs tests/compatibility", + "android:init": "tauri android init --ci --skip-targets-install", + "android:dev": "tauri android dev", + "android:build": "tauri android build --debug --target aarch64 --apk true --aab false --ci", "tauri": "tauri" }, "devDependencies": { diff --git a/src-mobile/index.html b/src-mobile/index.html new file mode 100644 index 0000000..4ac8333 --- /dev/null +++ b/src-mobile/index.html @@ -0,0 +1,27 @@ + + + + + + + Keyboard Helper Companion + + + +
    +
    + +

    Android foundation

    +

    Keyboard Helper Companion

    +

    Mobile shell ready

    +

    + This first stage verifies the standalone mobile application. Keyboard discovery and BLE + controls will arrive in the next verified stage. +

    +
    +
    + + diff --git a/src-mobile/styles.css b/src-mobile/styles.css new file mode 100644 index 0000000..4f10e91 --- /dev/null +++ b/src-mobile/styles.css @@ -0,0 +1,117 @@ +:root { + color-scheme: dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #090e1a; + color: #eef2ff; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; +} + +body { + min-height: 100vh; + min-height: 100dvh; + background: + radial-gradient(circle at 20% 10%, rgb(45 212 191 / 18%), transparent 38%), + radial-gradient(circle at 85% 85%, rgb(99 102 241 / 22%), transparent 44%), + #090e1a; +} + +.shell { + display: grid; + min-height: 100vh; + min-height: 100dvh; + place-items: center; + padding: + max(24px, env(safe-area-inset-top)) + max(20px, env(safe-area-inset-right)) + max(24px, env(safe-area-inset-bottom)) + max(20px, env(safe-area-inset-left)); +} + +.status-card { + width: min(100%, 430px); + padding: clamp(28px, 8vw, 44px); + border: 1px solid rgb(148 163 184 / 22%); + border-radius: 28px; + background: rgb(15 23 42 / 82%); + box-shadow: 0 24px 80px rgb(0 0 0 / 38%); + text-align: center; +} + +.keyboard-mark { + display: grid; + grid-template-columns: repeat(3, 18px); + gap: 6px; + justify-content: center; + margin-bottom: 28px; +} + +.keyboard-mark span { + width: 18px; + height: 18px; + border: 1px solid rgb(94 234 212 / 70%); + border-radius: 5px; + background: rgb(20 184 166 / 18%); + box-shadow: inset 0 -2px 0 rgb(20 184 166 / 28%); +} + +.eyebrow { + margin: 0 0 10px; + color: #5eead4; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: clamp(2rem, 8vw, 3rem); + line-height: 1.05; +} + +.status { + display: inline-block; + margin: 24px 0 16px; + padding: 8px 13px; + border-radius: 999px; + background: rgb(45 212 191 / 13%); + color: #99f6e4; + font-size: 0.9rem; + font-weight: 650; +} + +.detail { + margin: 0; + color: #cbd5e1; + font-size: 1rem; + line-height: 1.65; +} + +@media (orientation: landscape) and (max-height: 520px) { + .shell { + padding-block: max(14px, env(safe-area-inset-top)); + } + + .status-card { + width: min(100%, 680px); + padding: 22px 30px; + } + + .keyboard-mark { + margin-bottom: 16px; + } + + .status { + margin-block: 14px 10px; + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d5e0ea0..e6ca73d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,10 +18,12 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +tauri = { version = "2", features = [ "tray-icon"] } + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] anyhow = "1" btleplug = "0.12" futures-util = "0.3" -tauri = { version = "2", features = ["macos-private-api", "tray-icon"] } tauri-plugin-single-instance = "2" tauri-plugin-opener = "2" tauri-plugin-dialog = "2" @@ -33,11 +35,12 @@ uuid = { version = "1", features = ["v4"] } # Vendored rdev from crates.io 0.5.3 with macOS key-name lookup disabled to avoid macOS 15 event tap crashes. rdev = { path = "vendor/rdev" } -[target.'cfg(target_vendor = "apple")'.dependencies] +[target.'cfg(target_os = "macos")'.dependencies] +tauri = { version = "2", features = ["macos-private-api"] } objc2 = "0.5.2" core-foundation-sys = "0.8" -[target.'cfg(target_vendor = "apple")'.dependencies.objc2-core-bluetooth] +[target.'cfg(target_os = "macos")'.dependencies.objc2-core-bluetooth] version = "0.2.2" features = [ "std", @@ -55,7 +58,7 @@ default-features = false [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] } -[target.'cfg(target_vendor = "apple")'.dependencies.objc2-foundation] +[target.'cfg(target_os = "macos")'.dependencies.objc2-foundation] version = "0.2.2" features = [ "std", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 7a66bc2..a3ffd07 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,6 +3,7 @@ "identifier": "default", "description": "Capability for the main window", "windows": ["main", "overlay"], + "platforms": ["macOS", "windows", "linux"], "permissions": [ "core:default", { diff --git a/src-tauri/capabilities/mobile-shell.json b/src-tauri/capabilities/mobile-shell.json new file mode 100644 index 0000000..b154e6c --- /dev/null +++ b/src-tauri/capabilities/mobile-shell.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile-shell", + "description": "Minimal capability for the Android companion shell", + "windows": ["mobile"], + "platforms": ["android"], + "permissions": [ + "core:default" + ] +} diff --git a/src-tauri/capabilities/self-test.json b/src-tauri/capabilities/self-test.json index f7f4158..8843e4f 100644 --- a/src-tauri/capabilities/self-test.json +++ b/src-tauri/capabilities/self-test.json @@ -3,6 +3,7 @@ "identifier": "self-test", "description": "Capability for the Keyboard Self-test window", "windows": ["keyboard-self-test"], + "platforms": ["macOS", "windows", "linux"], "permissions": [ "core:default", "core:window:allow-close", diff --git a/src-tauri/capabilities/settings.json b/src-tauri/capabilities/settings.json index 7c13a02..64ad4c9 100644 --- a/src-tauri/capabilities/settings.json +++ b/src-tauri/capabilities/settings.json @@ -3,6 +3,7 @@ "identifier": "settings", "description": "Capability for the Settings window", "windows": ["settings"], + "platforms": ["macOS", "windows", "linux"], "permissions": [ "core:default", "core:window:allow-close", diff --git a/src-tauri/capabilities/typing-invaders.json b/src-tauri/capabilities/typing-invaders.json index 72dabfc..a6b6bb2 100644 --- a/src-tauri/capabilities/typing-invaders.json +++ b/src-tauri/capabilities/typing-invaders.json @@ -3,6 +3,7 @@ "identifier": "typing-invaders", "description": "Capability for the Shift-Space Invaders window", "windows": ["typing-invaders"], + "platforms": ["macOS", "windows", "linux"], "permissions": [ "core:default" ] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..910b1fe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,14 +1,6 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .expect("error while running Keyboard Helper Companion"); } diff --git a/src-tauri/tauri.android.conf.json b/src-tauri/tauri.android.conf.json new file mode 100644 index 0000000..52114f7 --- /dev/null +++ b/src-tauri/tauri.android.conf.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Keyboard Helper Companion", + "identifier": "me.maxistar.keyboardhelper.companion", + "build": { + "frontendDist": "../src-mobile" + }, + "app": { + "macOSPrivateApi": false, + "windows": [ + { + "label": "mobile", + "title": "Keyboard Helper Companion" + } + ] + }, + "bundle": { + "android": { + "minSdkVersion": 24 + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fc77c8e..cc967ec 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "app": { "withGlobalTauri": true, - "macOSPrivateApi": true, + "macOSPrivateApi": false, "windows": [ { "label": "overlay", diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..a841493 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "macOSPrivateApi": true + } +} diff --git a/tests/mobile_shell.test.mjs b/tests/mobile_shell.test.mjs new file mode 100644 index 0000000..1838609 --- /dev/null +++ b/tests/mobile_shell.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +async function read(relativePath) { + return readFile(path.join(root, relativePath), "utf8"); +} + +test("Android configuration keeps a distinct companion identity and frontend", async () => { + const desktop = JSON.parse(await read("src-tauri/tauri.conf.json")); + const android = JSON.parse(await read("src-tauri/tauri.android.conf.json")); + const macos = JSON.parse(await read("src-tauri/tauri.macos.conf.json")); + + assert.equal(desktop.identifier, "me.maxistar.keyri-app"); + assert.equal(desktop.app.macOSPrivateApi, false); + assert.equal(macos.app.macOSPrivateApi, true); + assert.equal(android.productName, "Keyboard Helper Companion"); + assert.equal(android.identifier, "me.maxistar.keyboardhelper.companion"); + assert.equal(android.build.frontendDist, "../src-mobile"); + assert.equal(android.bundle.android.minSdkVersion, 24); + assert.deepEqual(android.app.windows.map(({ label }) => label), ["mobile"]); + assert.equal(android.app.macOSPrivateApi, false); +}); + +test("mobile surface is bounded to bootstrap status", async () => { + const html = await read("src-mobile/index.html"); + + assert.match(html, /data-surface="android-companion-shell"/); + assert.match(html, /Keyboard Helper Companion/); + assert.match(html, /Mobile shell ready/); + assert.doesNotMatch(html, /main\.js|greet|overlay|typing-invaders|self-test|remote layer/i); + assert.doesNotMatch(html, / { + const capability = JSON.parse(await read("src-tauri/capabilities/mobile-shell.json")); + const desktopCapabilities = await Promise.all( + ["default", "self-test", "settings", "typing-invaders"].map(async (name) => + JSON.parse(await read(`src-tauri/capabilities/${name}.json`)), + ), + ); + + assert.deepEqual(capability.windows, ["mobile"]); + assert.deepEqual(capability.platforms, ["android"]); + assert.deepEqual(capability.permissions, ["core:default"]); + assert.doesNotMatch(JSON.stringify(capability), /bluetooth|scan|location|notification|background/i); + for (const desktopCapability of desktopCapabilities) { + assert.deepEqual(desktopCapability.platforms, ["macOS", "windows", "linux"]); + } +}); + +test("mobile native entry has no demonstration or desktop service registration", async () => { + const mobileRust = await read("src-tauri/src/lib.rs"); + const cargoToml = await read("src-tauri/Cargo.toml"); + + assert.match(mobileRust, /mobile_entry_point/); + assert.match(mobileRust, /Keyboard Helper Companion/); + assert.doesNotMatch(mobileRust, /greet|plugin\(|invoke_handler|rdev|tray|ble_|input_source/i); + assert.match(cargoToml, /cfg\(not\(any\(target_os = "android", target_os = "ios"\)\)\)/); + assert.match(cargoToml, /cfg\(target_os = "macos"\)[\s\S]*macos-private-api/); +}); + +test("generated mobile projects remain reproducible ignored state", async () => { + const gitignore = await read(".gitignore"); + const packageJson = JSON.parse(await read("package.json")); + + assert.match(gitignore, /^src-tauri\/gen$/m); + assert.equal( + packageJson.scripts["android:init"], + "tauri android init --ci --skip-targets-install", + ); + assert.equal(packageJson.scripts["android:dev"], "tauri android dev"); + assert.equal( + packageJson.scripts["android:build"], + "tauri android build --debug --target aarch64 --apk true --aab false --ci", + ); +});