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..e84ff0d 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,85 @@ 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' + 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..02e49c3 100644 --- a/README.md +++ b/README.md @@ -93,25 +93,32 @@ 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 layer 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. +Layout-wide firmware combos are not repeated in each layer test. When detailed BLE telemetry is ready and permitted, choose **Test global combos** to run one separate firmware-authoritative plan. Only combo definitions with a non-empty `code` are included, and this test does not activate or lease a firmware layer. If BLE telemetry is unavailable or disabled for privacy, the combo action is unavailable without affecting ordinary HID layer testing. -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 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. 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. The separate global combo test remains BLE-authoritative, while raw matrix health and unsupported or multi-step behaviors are outside the test. ## Release process 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 @@ -125,6 +132,7 @@ Advanced users can still edit the compatible JSON configuration directly. The ap - `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). +- The normalized zero-based `keyLayers` order must match ZMK firmware layer numbering. Self-test uses that order for automatic layer activation and treats empty or unsupported output codes as not testable; layout JSON has no separate self-test mapping or exclusion metadata. - `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/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/docs/quality-gates.md b/docs/quality-gates.md new file mode 100644 index 0000000..3411729 --- /dev/null +++ b/docs/quality-gates.md @@ -0,0 +1,69 @@ +# 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. + +The smoke is a required Windows matrix step. A failure blocks the package matrix +and therefore prevents the release publication job from running. + +## 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 required Windows smoke, 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/docs/rdev-vendor.md b/docs/rdev-vendor.md new file mode 100644 index 0000000..ccd7da3 --- /dev/null +++ b/docs/rdev-vendor.md @@ -0,0 +1,69 @@ +# rdev vendor patch + +Keyboard Helper vendors `rdev` because upstream `rdev 0.5.3` resolves +`Event.name` inside the macOS event tap callback. Keyboard Helper only consumes +physical `EventType::KeyPress` and `EventType::KeyRelease` values, but upstream +still performs the layout-aware name lookup before our callback can ignore it. +On macOS 15 this can crash while accessing input-source/layout APIs. + +## Current patch + +The vendored source is copied from crates.io `rdev 0.5.3` and patched in +`src-tauri/vendor/rdev/src/macos/common.rs`: + +- keep the upstream physical key, mouse, wheel, and flag event mapping; +- replace the `EventType::KeyPress(_)` call to + `keyboard_state.create_string_for_key(code, flags)` with `let name = None`; +- document why names are intentionally unresolved on macOS. + +This preserves Keyboard Helper behavior because the app serializes the `Key` +variant name from `EventType`, not `Event.name`. + +## Refreshing the vendor + +1. Create a spike branch and try the upstream dependency first: + +```toml +rdev = "0.5.3" +``` + +2. Build and smoke test on macOS before removing the vendor. Exercise global + keyboard listening with normal keys, modifier keys, layout switching, and any + input methods known to have crashed before. + +3. If upstream still crashes, copy the registry source into the vendor folder: + +```bash +rm -rf src-tauri/vendor/rdev +cp -R ~/.cargo/registry/src/*/rdev-0.5.3 src-tauri/vendor/rdev +``` + +4. Reapply the macOS patch in `src-tauri/vendor/rdev/src/macos/common.rs` so the + `if let Some(event_type) = option_type` block sets `let name = None` without + calling `keyboard_state.create_string_for_key(...)`. + +5. Point `src-tauri/Cargo.toml` back to the vendored dependency: + +```toml +# 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" } +``` + +6. Run the checks that are available on the host: + +```bash +npm test +cargo check --manifest-path src-tauri/Cargo.toml +``` + +On Linux, `cargo check` also requires the Tauri GTK/WebKit/AppIndicator +development packages. A missing `gdk-3.0.pc` is an environment issue, not an +`rdev` regression. + +## Upstreaming + +A useful upstream `rdev` issue or PR should frame this as a fallible/optional +macOS name-resolution problem: `listen()` should be able to emit physical key +events even when `Event.name` cannot be safely resolved. Compatible fixes could +include returning `None` on macOS lookup failure, adding `ListenOptions` with +`resolve_names: false`, or gating name resolution behind an opt-in feature. 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..407f873 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,21 @@ "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", + "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": { - "@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-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 eb0f9a0..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" @@ -30,14 +32,15 @@ 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 +# 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 new file mode 100644 index 0000000..a6b6bb2 --- /dev/null +++ b/src-tauri/capabilities/typing-invaders.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "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/ble_keyboard_events.rs b/src-tauri/src/ble_keyboard_events.rs new file mode 100644 index 0000000..7914719 --- /dev/null +++ b/src-tauri/src/ble_keyboard_events.rs @@ -0,0 +1,458 @@ +use std::fmt; + +use serde::Serialize; + +const PROTOCOL_MAJOR: u8 = 1; +const CAPABILITIES_LENGTH: usize = 8; +const FRAME_HEADER_LENGTH: usize = 8; +const MAX_FRAME_LENGTH: usize = 20; +const MAX_COMBO_POSITIONS: usize = 4; +const POSITION_SCHEMA: u8 = 1; +const RESERVED_CAPABILITY_BIT: u16 = 1 << 3; +const KNOWN_FRAME_FLAGS: u8 = 0x07; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BleKeyboardCapabilities { + pub protocol_major: u8, + pub protocol_minor: u8, + pub flags: u16, + pub max_frame_length: u8, + pub position_schema: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum InputAction { + Up, + Down, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum BleKeyboardEvent { + Key { + action: InputAction, + position: u8, + layer: u8, + }, + Combo { + action: InputAction, + combo_id: u16, + positions: Vec, + layer: u8, + }, + Layer { + layer: u8, + previous_layer: u8, + cause: u8, + origin_position: u8, + }, + Diagnostic { + code: u16, + severity: u8, + source: u8, + count: u32, + detail: u32, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DecodedBleKeyboardFrame { + pub sequence: u32, + pub flags: u8, + pub event: BleKeyboardEvent, +} + +impl DecodedBleKeyboardFrame { + pub fn stream_start(&self) -> bool { + self.flags & 0x01 != 0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DecodeError { + InvalidCapabilitiesLength(usize), + InvalidFrameLength(usize), + UnsupportedProtocolMajor(u8), + ReservedCapabilitySet, + InvalidCapabilities, + InvalidFlags(u8), + UnsupportedEventType(u8), + InvalidPayloadLength { event_type: u8, actual: usize }, + InvalidAction(u8), + InvalidCombo, + InvalidLayerCause(u8), + InvalidDiagnosticSeverity(u8), +} + +impl fmt::Display for DecodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for DecodeError {} + +pub fn decode_capabilities(data: &[u8]) -> Result { + if data.len() != CAPABILITIES_LENGTH { + return Err(DecodeError::InvalidCapabilitiesLength(data.len())); + } + if data[0] != PROTOCOL_MAJOR { + return Err(DecodeError::UnsupportedProtocolMajor(data[0])); + } + + let flags = u16::from_le_bytes([data[2], data[3]]); + if flags & RESERVED_CAPABILITY_BIT != 0 { + return Err(DecodeError::ReservedCapabilitySet); + } + if data[4] < FRAME_HEADER_LENGTH as u8 + || data[4] > MAX_FRAME_LENGTH as u8 + || data[5] != POSITION_SCHEMA + || data[6] != 0 + || data[7] != 0 + { + return Err(DecodeError::InvalidCapabilities); + } + + Ok(BleKeyboardCapabilities { + protocol_major: data[0], + protocol_minor: data[1], + flags, + max_frame_length: data[4], + position_schema: data[5], + }) +} + +pub fn decode_frame(data: &[u8]) -> Result { + if data.len() < FRAME_HEADER_LENGTH || data.len() > MAX_FRAME_LENGTH { + return Err(DecodeError::InvalidFrameLength(data.len())); + } + if data[0] != PROTOCOL_MAJOR { + return Err(DecodeError::UnsupportedProtocolMajor(data[0])); + } + if data[2] & !KNOWN_FRAME_FLAGS != 0 { + return Err(DecodeError::InvalidFlags(data[2])); + } + + let event_type = data[1]; + let payload_length = data[3] as usize; + if data.len() != FRAME_HEADER_LENGTH + payload_length { + return Err(DecodeError::InvalidPayloadLength { + event_type, + actual: payload_length, + }); + } + + let sequence = read_u32(&data[4..8]); + let payload = &data[FRAME_HEADER_LENGTH..]; + let event = match event_type { + 0x01 => decode_key(payload)?, + 0x02 => decode_combo(payload)?, + 0x03 => decode_layer(payload)?, + 0x04 => return Err(DecodeError::UnsupportedEventType(event_type)), + 0x05 => decode_diagnostic(payload)?, + _ => return Err(DecodeError::UnsupportedEventType(event_type)), + }; + + Ok(DecodedBleKeyboardFrame { + sequence, + flags: data[2], + event, + }) +} + +fn decode_key(payload: &[u8]) -> Result { + if payload.len() != 3 { + return Err(DecodeError::InvalidPayloadLength { + event_type: 0x01, + actual: payload.len(), + }); + } + Ok(BleKeyboardEvent::Key { + action: decode_action(payload[0])?, + position: payload[1], + layer: payload[2], + }) +} + +fn decode_combo(payload: &[u8]) -> Result { + if !(5..=9).contains(&payload.len()) { + return Err(DecodeError::InvalidPayloadLength { + event_type: 0x02, + actual: payload.len(), + }); + } + let combo_id = read_u16(&payload[0..2]); + let position_count = payload[4] as usize; + if combo_id == 0 || position_count > MAX_COMBO_POSITIONS || payload.len() != 5 + position_count + { + return Err(DecodeError::InvalidCombo); + } + Ok(BleKeyboardEvent::Combo { + combo_id, + action: decode_action(payload[2])?, + layer: payload[3], + positions: payload[5..].to_vec(), + }) +} + +fn decode_layer(payload: &[u8]) -> Result { + if payload.len() != 4 { + return Err(DecodeError::InvalidPayloadLength { + event_type: 0x03, + actual: payload.len(), + }); + } + if payload[2] > 3 { + return Err(DecodeError::InvalidLayerCause(payload[2])); + } + Ok(BleKeyboardEvent::Layer { + layer: payload[0], + previous_layer: payload[1], + cause: payload[2], + origin_position: payload[3], + }) +} + +fn decode_diagnostic(payload: &[u8]) -> Result { + if payload.len() != 12 { + return Err(DecodeError::InvalidPayloadLength { + event_type: 0x05, + actual: payload.len(), + }); + } + if payload[2] > 2 { + return Err(DecodeError::InvalidDiagnosticSeverity(payload[2])); + } + Ok(BleKeyboardEvent::Diagnostic { + code: read_u16(&payload[0..2]), + severity: payload[2], + source: payload[3], + count: read_u32(&payload[4..8]), + detail: read_u32(&payload[8..12]), + }) +} + +fn decode_action(value: u8) -> Result { + match value { + 0 => Ok(InputAction::Up), + 1 => Ok(InputAction::Down), + _ => Err(DecodeError::InvalidAction(value)), + } +} + +fn read_u16(data: &[u8]) -> u16 { + u16::from_le_bytes([data[0], data[1]]) +} + +fn read_u32(data: &[u8]) -> u32 { + u32::from_le_bytes([data[0], data[1], data[2], data[3]]) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SequenceObservation { + Baseline, + Contiguous, + Gap { + expected: u32, + actual: u32, + distance: u32, + }, +} + +#[derive(Default)] +pub struct SequenceTracker { + last: Option, +} + +impl SequenceTracker { + pub fn observe(&mut self, frame: &DecodedBleKeyboardFrame) -> SequenceObservation { + if frame.stream_start() || self.last.is_none() { + self.last = Some(frame.sequence); + return SequenceObservation::Baseline; + } + + let expected = self.last.unwrap().wrapping_add(1); + self.last = Some(frame.sequence); + if frame.sequence == expected { + SequenceObservation::Contiguous + } else { + SequenceObservation::Gap { + expected, + actual: frame.sequence, + distance: frame.sequence.wrapping_sub(expected), + } + } + } + + pub fn clear(&mut self) { + self.last = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../tests/fixtures/ble/keyboard-events-v1.json" + )) + .unwrap() + } + + fn bytes(hex: &str) -> Vec { + hex.as_bytes() + .chunks_exact(2) + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + .collect() + } + + fn frame(name: &str) -> Vec { + let fixture = fixture(); + let event = fixture["events"] + .as_array() + .unwrap() + .iter() + .find(|event| event["name"] == name) + .unwrap(); + bytes(event["hex"].as_str().unwrap()) + } + + #[test] + fn decodes_reviewed_capabilities_and_accepts_minor_versions() { + let fixture = fixture(); + let mut data = bytes(fixture["capabilities"]["hex"].as_str().unwrap()); + data[1] = 7; + let capabilities = decode_capabilities(&data).unwrap(); + assert_eq!(capabilities.protocol_minor, 7); + assert_eq!(capabilities.flags, 0x77); + assert_eq!(capabilities.max_frame_length, 20); + assert_eq!(capabilities.position_schema, 1); + } + + #[test] + fn rejects_unsupported_or_invalid_capabilities() { + assert_eq!( + decode_capabilities(&[1, 0]), + Err(DecodeError::InvalidCapabilitiesLength(2)) + ); + let mut data = bytes("0100770014010000"); + data[0] = 2; + assert_eq!( + decode_capabilities(&data), + Err(DecodeError::UnsupportedProtocolMajor(2)) + ); + data[0] = 1; + data[2] |= 1 << 3; + assert_eq!( + decode_capabilities(&data), + Err(DecodeError::ReservedCapabilitySet) + ); + } + + #[test] + fn decodes_reviewed_minimal_event_frames() { + let key = decode_frame(&frame("key-down")).unwrap(); + assert_eq!(key.sequence, 42); + assert_eq!( + key.event, + BleKeyboardEvent::Key { + action: InputAction::Down, + position: 1, + layer: 1, + } + ); + + let combo = decode_frame(&frame("combo-activated")).unwrap(); + assert_eq!( + combo.event, + BleKeyboardEvent::Combo { + action: InputAction::Down, + combo_id: 1, + positions: vec![1, 2], + layer: 1, + } + ); + + assert!(matches!( + decode_frame(&frame("stream-start-layer-snapshot")) + .unwrap() + .event, + BleKeyboardEvent::Layer { cause: 3, .. } + )); + assert!(matches!( + decode_frame(&frame("queue-overflow-diagnostic")) + .unwrap() + .event, + BleKeyboardEvent::Diagnostic { + code: 1, + count: 3, + detail: 48, + .. + } + )); + } + + #[test] + fn rejects_reserved_unknown_and_malformed_frames_without_text_fields() { + assert_eq!( + decode_frame(&bytes("0104000001000000")), + Err(DecodeError::UnsupportedEventType(4)) + ); + assert_eq!( + decode_frame(&bytes("01ff000001000000")), + Err(DecodeError::UnsupportedEventType(0xff)) + ); + assert!(matches!( + decode_frame(&bytes("010100032a0000000101")), + Err(DecodeError::InvalidPayloadLength { .. }) + )); + assert!(matches!( + decode_frame(&bytes("010100032a000000020101")), + Err(DecodeError::InvalidAction(2)) + )); + + let serialized = serde_json::to_value(decode_frame(&frame("key-down")).unwrap()).unwrap(); + let serialized = serialized.to_string(); + for prohibited in ["text", "keycode", "hidUsage", "unicode", "behavior"] { + assert!(!serialized.contains(prohibited)); + } + + let combo = serde_json::to_value(decode_frame(&frame("combo-activated")).unwrap()).unwrap(); + assert_eq!(combo["event"]["comboId"], 1); + } + + #[test] + fn tracks_gaps_wraps_stream_starts_and_clear() { + let mut tracker = SequenceTracker::default(); + let mut current = decode_frame(&frame("key-down")).unwrap(); + assert_eq!(tracker.observe(¤t), SequenceObservation::Baseline); + current.sequence = 43; + assert_eq!(tracker.observe(¤t), SequenceObservation::Contiguous); + current.sequence = 45; + assert_eq!( + tracker.observe(¤t), + SequenceObservation::Gap { + expected: 44, + actual: 45, + distance: 1, + } + ); + current.sequence = u32::MAX; + current.flags = 1; + assert_eq!(tracker.observe(¤t), SequenceObservation::Baseline); + current.sequence = 0; + current.flags = 0; + assert_eq!(tracker.observe(¤t), SequenceObservation::Contiguous); + tracker.clear(); + assert_eq!(tracker.observe(¤t), SequenceObservation::Baseline); + } +} diff --git a/src-tauri/src/ble_layer_macos.rs b/src-tauri/src/ble_layer_macos.rs index 1a18404..ebc2425 100644 --- a/src-tauri/src/ble_layer_macos.rs +++ b/src-tauri/src/ble_layer_macos.rs @@ -21,16 +21,39 @@ const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); const READ_TIMEOUT: Duration = Duration::from_secs(3); const NOTIFY_STATE_TIMEOUT: Duration = Duration::from_secs(3); const WRITE_TIMEOUT: Duration = Duration::from_secs(3); +const CAPABILITIES_UUID: &str = "b34a0003-e782-4706-8f9c-6c056c416507"; +const EVENT_UUID: &str = "b34a0004-e782-4706-8f9c-6c056c416507"; +const BATTERY_SERVICE_UUID: &str = "0000180f-0000-1000-8000-00805f9b34fb"; +const BATTERY_LEVEL_UUID: &str = "00002a19-0000-1000-8000-00805f9b34fb"; +const DEVICE_INFORMATION_SERVICE_UUID: &str = "0000180a-0000-1000-8000-00805f9b34fb"; + +pub enum Notification { + Layer(u32), + KeyboardEvent(Vec), + Battery(Vec), +} + +pub struct NotificationSubscriptions { + pub events: bool, + pub event_issue: Option, +} pub struct ConnectedKeyboard { _delegate: Retained, _manager: Retained, peripheral: Retained, layer_char: Retained, + capabilities_char: Option>, + event_char: Option>, + battery_char: Option>, + device_information_available: bool, events: Receiver, deferred_events: Mutex>, peripheral_id: Uuid, layer_char_uuid: Uuid, + capabilities_char_uuid: Uuid, + event_char_uuid: Uuid, + battery_char_uuid: Uuid, } impl ConnectedKeyboard { @@ -76,66 +99,135 @@ impl ConnectedKeyboard { } pub fn read_active_layer(&self) -> Result { + decode_active_layer(&self.read_characteristic(&self.layer_char, self.layer_char_uuid)?) + } + + pub fn has_capabilities_characteristic(&self) -> bool { + self.capabilities_char.is_some() + } + + pub fn has_battery_characteristic(&self) -> bool { + self.battery_char.is_some() + } + + pub fn has_device_information_service(&self) -> bool { + self.device_information_available + } + + pub fn read_capabilities(&self) -> Result>> { + self.capabilities_char + .as_ref() + .map(|characteristic| { + self.read_characteristic(characteristic, self.capabilities_char_uuid) + }) + .transpose() + } + + pub fn read_battery(&self) -> Result>> { + self.battery_char + .as_ref() + .filter(|characteristic| { + unsafe { characteristic.properties() } + .contains(CBCharacteristicProperties::CBCharacteristicPropertyRead) + }) + .map(|characteristic| self.read_characteristic(characteristic, self.battery_char_uuid)) + .transpose() + } + + fn read_characteristic( + &self, + characteristic: &CBCharacteristic, + characteristic_uuid: Uuid, + ) -> Result> { unsafe { - self.peripheral.readValueForCharacteristic(&self.layer_char); + self.peripheral.readValueForCharacteristic(characteristic); } - let data: Vec = wait_for_event_preserving( - &self.events, - &self.deferred_events, - READ_TIMEOUT, - |event| match event { - DelegateEvent::CharacteristicValue(peripheral_id, characteristic_uuid, result) + wait_for_event_preserving(&self.events, &self.deferred_events, READ_TIMEOUT, |event| { + match event { + DelegateEvent::CharacteristicValue(peripheral_id, observed_uuid, result) if *peripheral_id == self.peripheral_id - && *characteristic_uuid == self.layer_char_uuid => + && *observed_uuid == characteristic_uuid => { Some(result.clone()) } _ => None, - }, - )? - .map_err(|error| anyhow!(error))?; + } + })? + .map_err(|error| anyhow!(error)) + } - decode_active_layer(&data) + pub fn start_notifications(&self, enable_events: bool) -> Result { + self.start_notification(&self.layer_char, self.layer_char_uuid)?; + let (events, event_issue) = if enable_events { + if let Some(characteristic) = &self.event_char { + match self.start_notification(characteristic, self.event_char_uuid) { + Ok(()) => (true, None), + Err(error) => ( + false, + Some(super::event_subscription_issue(&format!("{error:#}"))), + ), + } + } else { + ( + false, + Some("extension-event-characteristic-unavailable".into()), + ) + } + } else { + (false, None) + }; + if let Some(characteristic) = &self.battery_char { + let _ = self.start_notification(characteristic, self.battery_char_uuid); + } + Ok(NotificationSubscriptions { + events, + event_issue, + }) } - pub fn start_notifications(&self) -> Result<()> { - let properties = unsafe { self.layer_char.properties() }; + fn start_notification( + &self, + characteristic: &CBCharacteristic, + characteristic_uuid: Uuid, + ) -> Result<()> { + let properties = unsafe { characteristic.properties() }; if !properties.contains(CBCharacteristicProperties::CBCharacteristicPropertyNotify) { return Err(anyhow!( - "Layer characteristic does not support notifications" + "Characteristic {characteristic_uuid} does not support notifications" )); } - if unsafe { self.layer_char.isNotifying() } { + if unsafe { characteristic.isNotifying() } { return Ok(()); } unsafe { self.peripheral - .setNotifyValue_forCharacteristic(true, &self.layer_char); + .setNotifyValue_forCharacteristic(true, characteristic); } - wait_for_event_preserving( + let result = wait_for_event_preserving( &self.events, &self.deferred_events, NOTIFY_STATE_TIMEOUT, |event| match event { - DelegateEvent::NotificationState(peripheral_id, characteristic_uuid, result) + DelegateEvent::NotificationState(peripheral_id, observed_uuid, result) if *peripheral_id == self.peripheral_id - && *characteristic_uuid == self.layer_char_uuid => + && *observed_uuid == characteristic_uuid => { Some(result.clone()) } _ => None, }, - )? - .map_err(|error| anyhow!(error))?; + ); - Ok(()) + result? + .map_err(|error| anyhow!(error)) + .with_context(|| format!("failed to enable notifications for {characteristic_uuid}")) } - pub fn wait_for_notification_layer_timeout(&self, timeout: Duration) -> Result> { + pub fn wait_for_notification_timeout(&self, timeout: Duration) -> Result> { let deferred = self .deferred_events .lock() @@ -158,7 +250,33 @@ impl ConnectedKeyboard { && characteristic_uuid == self.layer_char_uuid => { let data = result.map_err(|error| anyhow!(error))?; - Ok(Some(decode_active_layer(&data)?)) + Ok(Some(Notification::Layer(decode_active_layer(&data)?))) + } + DelegateEvent::CharacteristicValue(peripheral_id, characteristic_uuid, result) + if peripheral_id == self.peripheral_id + && characteristic_uuid == self.event_char_uuid => + { + Ok(Some(Notification::KeyboardEvent( + result.map_err(|error| anyhow!(error))?, + ))) + } + DelegateEvent::CharacteristicValue(peripheral_id, characteristic_uuid, result) + if peripheral_id == self.peripheral_id + && characteristic_uuid == self.battery_char_uuid => + { + Ok(Some(Notification::Battery( + result.map_err(|error| anyhow!(error))?, + ))) + } + DelegateEvent::Disconnected(peripheral_id, error) + if peripheral_id == self.peripheral_id => + { + Err(anyhow!(match error { + Some(error) if !error.is_empty() => { + format!("Bluetooth peripheral disconnected: {error}") + } + _ => "Bluetooth peripheral disconnected".to_string(), + })) } _ => Ok(None), } @@ -170,6 +288,11 @@ pub fn find_connected_keyboard( char_uuid: Uuid, name_filter: Option<&str>, ) -> Result> { + let capabilities_char_uuid = Uuid::parse_str(CAPABILITIES_UUID)?; + let event_char_uuid = Uuid::parse_str(EVENT_UUID)?; + let battery_service_uuid = Uuid::parse_str(BATTERY_SERVICE_UUID)?; + let battery_char_uuid = Uuid::parse_str(BATTERY_LEVEL_UUID)?; + let device_information_service_uuid = Uuid::parse_str(DEVICE_INFORMATION_SERVICE_UUID)?; let (sender, receiver) = mpsc::channel(); let delegate = CoreBluetoothDelegate::new(sender); @@ -222,7 +345,13 @@ pub fn find_connected_keyboard( } let requested_service = uuid_to_cbuuid(service_uuid); - let service_array = NSArray::from_id_slice(&[requested_service.clone()]); + let requested_battery_service = uuid_to_cbuuid(battery_service_uuid); + let requested_device_information_service = uuid_to_cbuuid(device_information_service_uuid); + let service_array = NSArray::from_id_slice(&[ + requested_service.clone(), + requested_battery_service.clone(), + requested_device_information_service.clone(), + ]); unsafe { peripheral.discoverServices(Some(&service_array)); } @@ -238,10 +367,8 @@ pub fn find_connected_keyboard( let service = find_service(&peripheral, service_uuid) .with_context(|| format!("Service {service_uuid} not found on connected keyboard"))?; - let requested_char = uuid_to_cbuuid(char_uuid); - let char_array = NSArray::from_id_slice(&[requested_char.clone()]); unsafe { - peripheral.discoverCharacteristics_forService(Some(&char_array), &service); + peripheral.discoverCharacteristics_forService(None, &service); } wait_for_event(&receiver, DISCOVERY_TIMEOUT, |event| match event { @@ -257,6 +384,34 @@ pub fn find_connected_keyboard( let layer_char = find_characteristic(&service, char_uuid).with_context(|| { format!("Characteristic {char_uuid} not found on connected keyboard") })?; + let capabilities_char = find_optional_characteristic(&service, capabilities_char_uuid)?; + let event_char = find_optional_characteristic(&service, event_char_uuid)?; + + let battery_char = if let Some(battery_service) = + find_optional_service(&peripheral, battery_service_uuid)? + { + let requested_battery_char = uuid_to_cbuuid(battery_char_uuid); + let battery_chars = NSArray::from_id_slice(&[requested_battery_char.clone()]); + unsafe { + peripheral + .discoverCharacteristics_forService(Some(&battery_chars), &battery_service); + } + wait_for_event(&receiver, DISCOVERY_TIMEOUT, |event| match event { + DelegateEvent::CharacteristicsDiscovered(id, discovered_service_uuid, result) + if *id == peripheral_id && *discovered_service_uuid == battery_service_uuid => + { + Some(result.clone()) + } + _ => None, + })? + .map_err(|error| anyhow!(error))?; + find_optional_characteristic(&battery_service, battery_char_uuid)? + } else { + None + }; + + let device_information_available = + find_optional_service(&peripheral, device_information_service_uuid)?.is_some(); let properties = unsafe { layer_char.properties() }; if !properties.contains(CBCharacteristicProperties::CBCharacteristicPropertyRead) { @@ -268,10 +423,17 @@ pub fn find_connected_keyboard( _manager: manager, peripheral, layer_char, + capabilities_char, + event_char, + battery_char, + device_information_available, events: receiver, deferred_events: Mutex::new(VecDeque::new()), peripheral_id, layer_char_uuid: char_uuid, + capabilities_char_uuid, + event_char_uuid, + battery_char_uuid, })); } @@ -290,6 +452,7 @@ enum DelegateEvent { ManagerState(CBManagerState), Connected(Uuid), ConnectionFailed(Uuid, String), + Disconnected(Uuid, Option), ServicesDiscovered(Uuid, Result<(), String>), CharacteristicsDiscovered(Uuid, Uuid, Result<(), String>), NotificationState(Uuid, Uuid, Result<(), String>), @@ -344,6 +507,21 @@ declare_class!( )); } } + + #[method(centralManager:didDisconnectPeripheral:error:)] + fn central_manager_did_disconnect_peripheral_error( + &self, + _central: &CBCentralManager, + peripheral: &CBPeripheral, + error: Option<&NSError>, + ) { + if let Ok(id) = nsuuid_to_uuid(unsafe { peripheral.identifier() }.as_ref()) { + self.send(DelegateEvent::Disconnected( + id, + error.map(|error| error.localizedDescription().to_string()), + )); + } + } } unsafe impl CBPeripheralDelegate for CoreBluetoothDelegate { @@ -531,6 +709,20 @@ fn find_service(peripheral: &CBPeripheral, expected: Uuid) -> Result Result>> { + let services = + unsafe { peripheral.services() }.ok_or_else(|| anyhow!("No services discovered"))?; + for service in services { + if cbuuid_to_uuid(unsafe { service.UUID() }.as_ref())? == expected { + return Ok(Some(service)); + } + } + Ok(None) +} + fn find_characteristic(service: &CBService, expected: Uuid) -> Result> { let chars = unsafe { service.characteristics() } .ok_or_else(|| anyhow!("No characteristics discovered"))?; @@ -542,6 +734,20 @@ fn find_characteristic(service: &CBService, expected: Uuid) -> Result Result>> { + let chars = unsafe { service.characteristics() } + .ok_or_else(|| anyhow!("No characteristics discovered"))?; + for characteristic in chars { + if cbuuid_to_uuid(unsafe { characteristic.UUID() }.as_ref())? == expected { + return Ok(Some(characteristic)); + } + } + Ok(None) +} + fn get_characteristic_value(characteristic: &CBCharacteristic) -> Vec { unsafe { characteristic.value() } .map(|value: Retained| value.bytes().into()) @@ -553,8 +759,16 @@ fn nsuuid_to_uuid(uuid: &objc2_foundation::NSUUID) -> Result { } fn cbuuid_to_uuid(uuid: &CBUUID) -> Result { - Uuid::parse_str(&unsafe { uuid.UUIDString() }.to_string()) - .context("invalid service/characteristic UUID") + parse_cbuuid_string(&unsafe { uuid.UUIDString() }.to_string()) +} + +fn parse_cbuuid_string(value: &str) -> Result { + let normalized = match value.len() { + 4 => format!("0000{value}-0000-1000-8000-00805f9b34fb"), + 8 => format!("{value}-0000-1000-8000-00805f9b34fb"), + _ => value.to_string(), + }; + Uuid::parse_str(&normalized).context("invalid service/characteristic UUID") } fn uuid_to_cbuuid(uuid: Uuid) -> Retained { @@ -584,3 +798,32 @@ unsafe extern "C" { #[cfg_attr(target_os = "macos", link(name = "AppKit", kind = "framework"))] unsafe extern "C" {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expands_sig_assigned_cbuuid_strings() { + assert_eq!( + parse_cbuuid_string("180F").unwrap(), + Uuid::parse_str(BATTERY_SERVICE_UUID).unwrap() + ); + assert_eq!( + parse_cbuuid_string("2A19").unwrap(), + Uuid::parse_str(BATTERY_LEVEL_UUID).unwrap() + ); + assert_eq!( + parse_cbuuid_string("0000180A").unwrap(), + Uuid::parse_str(DEVICE_INFORMATION_SERVICE_UUID).unwrap() + ); + } + + #[test] + fn preserves_custom_128_bit_cbuuid_strings() { + assert_eq!( + parse_cbuuid_string("B34A0004-E782-4706-8F9C-6C056C416507").unwrap(), + Uuid::parse_str(EVENT_UUID).unwrap() + ); + } +} diff --git a/src-tauri/src/ble_layer_sync.rs b/src-tauri/src/ble_layer_sync.rs index 37fb673..dc0cf2a 100644 --- a/src-tauri/src/ble_layer_sync.rs +++ b/src-tauri/src/ble_layer_sync.rs @@ -14,6 +14,11 @@ use tauri::{AppHandle, Emitter}; use tokio::time::sleep; use uuid::Uuid; +use crate::ble_keyboard_events::{ + decode_capabilities, decode_frame, BleKeyboardCapabilities, BleKeyboardEvent, + DecodedBleKeyboardFrame, SequenceObservation, SequenceTracker, +}; + #[cfg(target_vendor = "apple")] #[path = "ble_layer_macos.rs"] mod ble_layer_macos; @@ -22,6 +27,11 @@ const DEFAULT_SCAN_SECS: u64 = 2; const PROBE_TIMEOUT_SECS: u64 = 2; const NOTIFICATION_POLL_TIMEOUT_MS: u64 = 500; const COMMAND_CONFIRM_TIMEOUT_SECS: u64 = 4; +const LAYER_CONFLICT_SETTLE_MS: u64 = 1_000; +const CAPABILITIES_UUID: &str = "b34a0003-e782-4706-8f9c-6c056c416507"; +const EVENT_UUID: &str = "b34a0004-e782-4706-8f9c-6c056c416507"; +const BATTERY_LEVEL_UUID: &str = "00002a19-0000-1000-8000-00805f9b34fb"; +const DEVICE_INFORMATION_SERVICE_UUID: &str = "0000180a-0000-1000-8000-00805f9b34fb"; struct LayerCommand { generation: u64, @@ -43,6 +53,68 @@ struct PendingCommand { deadline: Instant, } +#[derive(Debug)] +struct LayerReconciler { + legacy: u32, + stream: Option, + mismatch: Option<(u32, u32, Instant)>, + reported: Option<(u32, u32)>, +} + +impl LayerReconciler { + fn new(legacy: u32) -> Self { + Self { + legacy, + stream: None, + mismatch: None, + reported: None, + } + } + + fn observe_legacy(&mut self, layer: u32, now: Instant) { + self.legacy = layer; + self.reconcile(now); + } + + fn observe_stream(&mut self, layer: u32, now: Instant) { + self.stream = Some(layer); + self.reconcile(now); + } + + fn reconcile(&mut self, now: Instant) { + let Some(stream) = self.stream else { + return; + }; + if self.legacy == stream { + self.mismatch = None; + self.reported = None; + return; + } + let pair = (self.legacy, stream); + if self + .mismatch + .as_ref() + .map(|(legacy, stream, _)| (*legacy, *stream)) + != Some(pair) + { + self.mismatch = Some((self.legacy, stream, now)); + self.reported = None; + } + } + + fn take_stable_conflict(&mut self, now: Instant) -> Option<(u32, u32)> { + let (legacy, stream, since) = self.mismatch?; + let pair = (legacy, stream); + if now.duration_since(since) < Duration::from_millis(LAYER_CONFLICT_SETTLE_MS) + || self.reported == Some(pair) + { + return None; + } + self.reported = Some(pair); + Some(pair) + } +} + #[derive(Default)] pub struct BleLayerSyncState { generation: Arc, @@ -153,10 +225,57 @@ struct BleLayerStatusPayload { writable: bool, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct BleKeyboardStatusPayload { + layout: String, + state: String, + mode: String, + reason: Option, + capabilities_validated: bool, + subscribed: bool, + capabilities: Option, + battery_available: bool, + device_information_available: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct BleKeyboardEventPayload { + layout: String, + frame: DecodedBleKeyboardFrame, +} + +#[derive(Debug, Clone, Serialize)] +struct BleKeyboardDiagnosticPayload { + layout: String, + code: String, + message: String, +} + +#[derive(Debug, Clone, Serialize)] +struct BleBatteryPayload { + layout: String, + level: u8, +} + #[derive(Debug)] struct BtleKeyboard { peripheral: Peripheral, layer_char: Characteristic, + capabilities_char: Option, + event_char: Option, + battery_char: Option, + device_information_available: bool, +} + +#[derive(Default)] +struct BtleKeyboardFeatures { + capabilities: Option, + capability_issue: Option, + extension_present: bool, + battery_available: bool, + battery_level: Option, + device_information_available: bool, } enum KeyboardHandle { @@ -192,6 +311,18 @@ pub fn start_sync( Some(error.to_string()), false, ); + let _ = emit_keyboard_status( + &app_handle, + &config.layout_key, + "error", + "unknown", + Some(error.to_string()), + false, + false, + None, + false, + false, + ); } state.clear_command_session(generation); }); @@ -214,6 +345,18 @@ async fn run_sync( ) -> Result<()> { ensure_supported_format(&config)?; emit_status(&app_handle, &config.layout_key, "connecting", None, false)?; + emit_keyboard_status( + &app_handle, + &config.layout_key, + "connecting", + "unknown", + Some("ble-connecting".into()), + false, + false, + None, + false, + false, + )?; let service_uuid = Uuid::parse_str(&config.service_uuid) .with_context(|| format!("invalid service UUID: {}", config.service_uuid))?; @@ -237,9 +380,31 @@ async fn run_sync( } let layer = read_active_layer(&keyboard).await?; + let features = inspect_keyboard_features(&keyboard).await?; + if let Some(level) = features.battery_level { + emit_battery(&app_handle, &config.layout_key, level)?; + } emit_layer(&app_handle, &config.layout_key, layer)?; let writable = keyboard_supports_write(&keyboard); emit_status(&app_handle, &config.layout_key, "connected", None, writable)?; + emit_keyboard_status( + &app_handle, + &config.layout_key, + "connected", + if features.capabilities.is_some() { + "enhanced" + } else if features.extension_present { + "unsupported" + } else { + "stock" + }, + capabilities_status_reason(&features), + features.capabilities.is_some(), + false, + features.capabilities.clone(), + features.battery_available, + features.device_information_available, + )?; if writable { state.install_command_session(generation, config.layout_key.clone(), command_sender)?; @@ -251,6 +416,7 @@ async fn run_sync( generation, &config.layout_key, keyboard, + features, layer, command_receiver, ) @@ -270,14 +436,43 @@ async fn watch_layers( generation: u64, layout_key: &str, keyboard: KeyboardHandle, + features: BtleKeyboardFeatures, mut last_layer: u32, command_receiver: Receiver, ) -> Result<()> { let mut pending: Option = None; + let mut layer_reconciler = LayerReconciler::new(last_layer); let watch_result: Result<()> = async { match keyboard { KeyboardHandle::Btle(keyboard) => { - let mut notifications = notification_stream(&keyboard).await?; + let (mut notifications, event_subscribed, event_issue) = + notification_stream(&keyboard, features.capabilities.is_some()).await?; + emit_keyboard_status( + app_handle, + layout_key, + "connected", + if features.capabilities.is_some() { + "enhanced" + } else if features.extension_present { + "unsupported" + } else { + "stock" + }, + if features.capabilities.is_some() { + (!event_subscribed).then(|| { + event_issue + .unwrap_or_else(|| "extension-event-stream-unavailable".into()) + }) + } else { + capabilities_status_reason(&features) + }, + features.capabilities.is_some(), + event_subscribed, + features.capabilities.clone(), + features.battery_available, + features.device_information_available, + )?; + let mut sequence = SequenceTracker::default(); while state.is_current(generation) { process_command(&command_receiver, &mut pending, generation, |layer| { write_btle_layer(&keyboard, layer) @@ -293,41 +488,114 @@ async fn watch_layers( let Some(notification) = (match next { Ok(Some(notification)) => Some(notification), Ok(None) => return Err(anyhow!("BLE notification stream ended")), - Err(_) => None, + Err(_) => { + report_layer_conflict(app_handle, layout_key, &mut layer_reconciler)?; + None + } }) else { continue; }; - if notification.uuid != keyboard.layer_char.uuid { - continue; - } - - let layer = decode_active_layer(¬ification.value)?; - confirm_pending(&mut pending, layer); - if layer != last_layer { - emit_layer(app_handle, layout_key, layer)?; - last_layer = layer; + if notification.uuid == keyboard.layer_char.uuid { + let layer = decode_active_layer(¬ification.value)?; + layer_reconciler.observe_legacy(layer, Instant::now()); + confirm_pending(&mut pending, layer); + if layer != last_layer { + emit_layer(app_handle, layout_key, layer)?; + last_layer = layer; + } + } else if keyboard + .event_char + .as_ref() + .is_some_and(|characteristic| notification.uuid == characteristic.uuid) + { + if let Some(layer) = process_keyboard_event_notification( + app_handle, + layout_key, + &mut sequence, + ¬ification.value, + )? { + layer_reconciler.observe_stream(layer, Instant::now()); + } + } else if keyboard + .battery_char + .as_ref() + .is_some_and(|characteristic| notification.uuid == characteristic.uuid) + { + emit_battery(app_handle, layout_key, decode_battery(¬ification.value)?)?; } + report_layer_conflict(app_handle, layout_key, &mut layer_reconciler)?; } } #[cfg(target_vendor = "apple")] KeyboardHandle::Macos(keyboard) => { - keyboard.start_notifications()?; + let subscriptions = + keyboard.start_notifications(features.capabilities.is_some())?; + emit_keyboard_status( + app_handle, + layout_key, + "connected", + if features.capabilities.is_some() { + "enhanced" + } else if features.extension_present { + "unsupported" + } else { + "stock" + }, + if features.capabilities.is_some() { + (!subscriptions.events).then(|| { + subscriptions + .event_issue + .clone() + .unwrap_or_else(|| "extension-event-stream-unavailable".into()) + }) + } else { + capabilities_status_reason(&features) + }, + features.capabilities.is_some(), + subscriptions.events, + features.capabilities.clone(), + features.battery_available, + features.device_information_available, + )?; + let mut sequence = SequenceTracker::default(); while state.is_current(generation) { process_command(&command_receiver, &mut pending, generation, |layer| { std::future::ready(keyboard.write_active_layer(layer)) }) .await; expire_pending(&mut pending); - if let Some(layer) = keyboard.wait_for_notification_layer_timeout( + let Some(notification) = keyboard.wait_for_notification_timeout( Duration::from_millis(NOTIFICATION_POLL_TIMEOUT_MS), - )? { - confirm_pending(&mut pending, layer); - if layer != last_layer { - emit_layer(app_handle, layout_key, layer)?; - last_layer = layer; + )? + else { + report_layer_conflict(app_handle, layout_key, &mut layer_reconciler)?; + continue; + }; + match notification { + ble_layer_macos::Notification::Layer(layer) => { + layer_reconciler.observe_legacy(layer, Instant::now()); + confirm_pending(&mut pending, layer); + if layer != last_layer { + emit_layer(app_handle, layout_key, layer)?; + last_layer = layer; + } + } + ble_layer_macos::Notification::KeyboardEvent(value) => { + if let Some(layer) = process_keyboard_event_notification( + app_handle, + layout_key, + &mut sequence, + &value, + )? { + layer_reconciler.observe_stream(layer, Instant::now()); + } + } + ble_layer_macos::Notification::Battery(value) => { + emit_battery(app_handle, layout_key, decode_battery(&value)?)?; } } + report_layer_conflict(app_handle, layout_key, &mut layer_reconciler)?; } } } @@ -344,6 +612,18 @@ async fn watch_layers( if watch_result.is_ok() { emit_status(app_handle, layout_key, "idle", None, false)?; + emit_keyboard_status( + app_handle, + layout_key, + "idle", + "unknown", + Some("ble-session-stopped".into()), + false, + false, + None, + false, + false, + )?; } watch_result } @@ -604,9 +884,120 @@ async fn connect_btle_keyboard( Ok(BtleKeyboard { peripheral, layer_char, + capabilities_char: find_optional_characteristic(&chars, CAPABILITIES_UUID)?, + event_char: find_optional_characteristic(&chars, EVENT_UUID)?, + battery_char: find_optional_characteristic(&chars, BATTERY_LEVEL_UUID)?, + device_information_available: chars.iter().any(|characteristic| { + characteristic.service_uuid + == Uuid::parse_str(DEVICE_INFORMATION_SERVICE_UUID).expect("valid DIS UUID") + }), }) } +fn find_optional_characteristic( + characteristics: &std::collections::BTreeSet, + uuid: &str, +) -> Result> { + let expected = Uuid::parse_str(uuid)?; + Ok(characteristics + .iter() + .find(|characteristic| characteristic.uuid == expected) + .cloned()) +} + +async fn inspect_keyboard_features(handle: &KeyboardHandle) -> Result { + match handle { + KeyboardHandle::Btle(keyboard) => { + let extension_present = keyboard.capabilities_char.is_some(); + let (capabilities, capability_issue) = + if let Some(characteristic) = &keyboard.capabilities_char { + match keyboard.peripheral.read(characteristic).await { + Ok(value) => inspect_capabilities_value(&value), + Err(error) => ( + None, + Some(format!("extension-capabilities-read-failed: {error}")), + ), + } + } else { + (None, None) + }; + let mut battery_level = None; + if let Some(characteristic) = &keyboard.battery_char { + if characteristic.properties.contains(CharPropFlags::READ) { + if let Ok(value) = keyboard.peripheral.read(characteristic).await { + battery_level = Some(decode_battery(&value)?); + } + } + } + Ok(BtleKeyboardFeatures { + capabilities, + capability_issue, + extension_present, + battery_available: keyboard.battery_char.is_some(), + battery_level, + device_information_available: keyboard.device_information_available, + }) + } + #[cfg(target_vendor = "apple")] + KeyboardHandle::Macos(keyboard) => { + let extension_present = keyboard.has_capabilities_characteristic(); + let (capabilities, capability_issue) = match keyboard.read_capabilities() { + Ok(Some(value)) => inspect_capabilities_value(&value), + Ok(None) => (None, None), + Err(error) => ( + None, + Some(format!("extension-capabilities-read-failed: {error}")), + ), + }; + let mut battery_level = None; + if let Ok(Some(value)) = keyboard.read_battery() { + battery_level = Some(decode_battery(&value)?); + } + Ok(BtleKeyboardFeatures { + capabilities, + capability_issue, + extension_present, + battery_available: keyboard.has_battery_characteristic(), + battery_level, + device_information_available: keyboard.has_device_information_service(), + }) + } + } +} + +fn inspect_capabilities_value(value: &[u8]) -> (Option, Option) { + match decode_capabilities(value) { + Ok(capabilities) => (Some(capabilities), None), + Err(error) => { + let bytes = value + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" "); + ( + None, + Some(format!( + "extension-capabilities-invalid: {error}; received {} bytes [{}]", + value.len(), + bytes + )), + ) + } + } +} + +fn capabilities_status_reason(features: &BtleKeyboardFeatures) -> Option { + if features.capabilities.is_some() { + None + } else if let Some(issue) = &features.capability_issue { + Some(issue.clone()) + } else if features.extension_present { + Some("extension-capabilities-unsupported".into()) + } else { + Some("extension-capabilities-unavailable".into()) + } +} + async fn read_active_layer(handle: &KeyboardHandle) -> Result { match handle { KeyboardHandle::Btle(keyboard) => decode_active_layer( @@ -623,7 +1014,12 @@ async fn read_active_layer(handle: &KeyboardHandle) -> Result { async fn notification_stream( keyboard: &BtleKeyboard, -) -> Result + Send> { + enable_events: bool, +) -> Result<( + impl futures_util::Stream + Send, + bool, + Option, +)> { if !keyboard .layer_char .properties @@ -636,7 +1032,46 @@ async fn notification_stream( let stream = keyboard.peripheral.notifications().await?; keyboard.peripheral.subscribe(&keyboard.layer_char).await?; - Ok(stream) + let (event_subscribed, event_issue) = if enable_events { + if let Some(characteristic) = &keyboard.event_char { + if characteristic.properties.contains(CharPropFlags::NOTIFY) { + match keyboard.peripheral.subscribe(characteristic).await { + Ok(()) => (true, None), + Err(error) => (false, Some(event_subscription_issue(&format!("{error:#}")))), + } + } else { + ( + false, + Some("extension-event-characteristic-does-not-notify".into()), + ) + } + } else { + ( + false, + Some("extension-event-characteristic-unavailable".into()), + ) + } + } else { + (false, None) + }; + if let Some(characteristic) = &keyboard.battery_char { + if characteristic.properties.contains(CharPropFlags::NOTIFY) { + let _ = keyboard.peripheral.subscribe(characteristic).await; + } + } + Ok((stream, event_subscribed, event_issue)) +} + +fn event_subscription_issue(detail: &str) -> String { + let lower = detail.to_ascii_lowercase(); + if lower.contains("resources are insufficient") + || lower.contains("att error: 0x11") + || lower.contains("att error 0x11") + { + format!("extension-event-subscription-capacity-unavailable: {detail}") + } else { + format!("extension-event-subscribe-failed: {detail}") + } } fn decode_active_layer(data: &[u8]) -> Result { @@ -646,6 +1081,76 @@ fn decode_active_layer(data: &[u8]) -> Result { Ok(u32::from_le_bytes(bytes)) } +fn decode_battery(data: &[u8]) -> Result { + let [level]: [u8; 1] = data + .try_into() + .map_err(|_| anyhow!("Expected 1 battery byte, got {}", data.len()))?; + if level > 100 { + return Err(anyhow!("Invalid Battery Level {level}")); + } + Ok(level) +} + +fn process_keyboard_event_notification( + app_handle: &AppHandle, + layout_key: &str, + sequence: &mut SequenceTracker, + value: &[u8], +) -> Result> { + match decode_frame(value) { + Ok(frame) => { + if let SequenceObservation::Gap { + expected, + actual, + distance, + } = sequence.observe(&frame) + { + emit_keyboard_diagnostic( + app_handle, + layout_key, + "sequence-gap", + format!( + "BLE event sequence gap: expected {expected}, received {actual} ({distance} dropped)." + ), + )?; + } + let layer = match &frame.event { + BleKeyboardEvent::Layer { layer, .. } => Some(u32::from(*layer)), + _ => None, + }; + emit_keyboard_event(app_handle, layout_key, frame)?; + Ok(layer) + } + Err(error) => { + emit_keyboard_diagnostic( + app_handle, + layout_key, + "invalid-frame", + format!("Rejected BLE keyboard event frame: {error}"), + )?; + Ok(None) + } + } +} + +fn report_layer_conflict( + app_handle: &AppHandle, + layout_key: &str, + reconciler: &mut LayerReconciler, +) -> Result<()> { + if let Some((legacy, stream)) = reconciler.take_stable_conflict(Instant::now()) { + emit_keyboard_diagnostic( + app_handle, + layout_key, + "layer-conflict", + format!( + "BLE layer sources disagree: layer characteristic reports {legacy}, event stream reports {stream}." + ), + )?; + } + Ok(()) +} + fn emit_layer(app_handle: &AppHandle, layout_key: &str, layer: u32) -> Result<()> { app_handle .emit( @@ -678,10 +1183,146 @@ fn emit_status( .map_err(|error| anyhow!("failed to emit ble_layer_status: {error}")) } +#[allow(clippy::too_many_arguments)] +fn emit_keyboard_status( + app_handle: &AppHandle, + layout_key: &str, + state: &str, + mode: &str, + reason: Option, + capabilities_validated: bool, + subscribed: bool, + capabilities: Option, + battery_available: bool, + device_information_available: bool, +) -> Result<()> { + app_handle + .emit( + "ble_keyboard_status", + BleKeyboardStatusPayload { + layout: layout_key.to_string(), + state: state.to_string(), + mode: mode.to_string(), + reason, + capabilities_validated, + subscribed, + capabilities, + battery_available, + device_information_available, + }, + ) + .map_err(|error| anyhow!("failed to emit ble_keyboard_status: {error}")) +} + +fn emit_keyboard_event( + app_handle: &AppHandle, + layout_key: &str, + frame: DecodedBleKeyboardFrame, +) -> Result<()> { + app_handle + .emit( + "ble_keyboard_event", + BleKeyboardEventPayload { + layout: layout_key.to_string(), + frame, + }, + ) + .map_err(|error| anyhow!("failed to emit ble_keyboard_event: {error}")) +} + +fn emit_keyboard_diagnostic( + app_handle: &AppHandle, + layout_key: &str, + code: &str, + message: String, +) -> Result<()> { + app_handle + .emit( + "ble_keyboard_diagnostic", + BleKeyboardDiagnosticPayload { + layout: layout_key.to_string(), + code: code.to_string(), + message, + }, + ) + .map_err(|error| anyhow!("failed to emit ble_keyboard_diagnostic: {error}")) +} + +fn emit_battery(app_handle: &AppHandle, layout_key: &str, level: u8) -> Result<()> { + app_handle + .emit( + "ble_battery_update", + BleBatteryPayload { + layout: layout_key.to_string(), + level, + }, + ) + .map_err(|error| anyhow!("failed to emit ble_battery_update: {error}")) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn maps_att_resource_rejection_to_capacity_status() { + assert_eq!( + event_subscription_issue("Operation failed with ATT error: 0x11"), + "extension-event-subscription-capacity-unavailable: Operation failed with ATT error: 0x11" + ); + assert_eq!( + event_subscription_issue("Resources are insufficient."), + "extension-event-subscription-capacity-unavailable: Resources are insufficient." + ); + } + + #[test] + fn capability_probe_preserves_invalid_bytes_for_hardware_diagnostics() { + let (capabilities, issue) = inspect_capabilities_value(&[1, 0, 0x77]); + assert_eq!(capabilities, None); + assert_eq!( + issue.as_deref(), + Some( + "extension-capabilities-invalid: InvalidCapabilitiesLength(3); received 3 bytes [01 00 77]" + ) + ); + + let (capabilities, issue) = inspect_capabilities_value(&[1, 0, 0x77, 0, 0x14, 1, 0, 0]); + assert!(capabilities.is_some()); + assert_eq!(issue, None); + } + + #[test] + fn layer_reconciliation_ignores_reordering_but_reports_stable_conflicts_once() { + let start = Instant::now(); + let mut reconciler = LayerReconciler::new(1); + reconciler.observe_stream(2, start); + assert_eq!( + reconciler.take_stable_conflict(start + Duration::from_millis(999)), + None + ); + reconciler.observe_legacy(2, start + Duration::from_millis(999)); + assert_eq!( + reconciler.take_stable_conflict(start + Duration::from_secs(2)), + None + ); + + reconciler.observe_stream(3, start + Duration::from_secs(3)); + assert_eq!( + reconciler.take_stable_conflict(start + Duration::from_secs(4)), + Some((2, 3)) + ); + assert_eq!( + reconciler.take_stable_conflict(start + Duration::from_secs(5)), + None + ); + reconciler.observe_legacy(3, start + Duration::from_secs(5)); + assert_eq!( + reconciler.take_stable_conflict(start + Duration::from_secs(6)), + None + ); + } + fn pending( layer: u32, acceptable_layers: Vec, @@ -703,6 +1344,14 @@ mod tests { assert_eq!(encode_layer(0x1234_5678), [0x78, 0x56, 0x34, 0x12]); } + #[test] + fn standard_battery_level_is_one_bounded_byte() { + assert_eq!(decode_battery(&[0]).unwrap(), 0); + assert_eq!(decode_battery(&[100]).unwrap(), 100); + assert!(decode_battery(&[101]).is_err()); + assert!(decode_battery(&[50, 0]).is_err()); + } + #[test] fn only_write_with_response_capability_is_accepted() { assert!(supports_write_with_response(CharPropFlags::WRITE)); 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/src/main.rs b/src-tauri/src/main.rs index 2f4faca..d63c9d1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,6 +4,8 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; +mod ble_keyboard_events; mod ble_layer_sync; mod config_store; #[cfg(target_os = "macos")] @@ -48,6 +50,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 +716,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 +1236,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 +1273,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/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/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-tauri/vendor/rdev/README.md b/src-tauri/vendor/rdev/README.md index c228d90..e247236 100644 --- a/src-tauri/vendor/rdev/README.md +++ b/src-tauri/vendor/rdev/README.md @@ -1,8 +1,220 @@ -# Vendored rdev (macOS fix) +![](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) -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. +# rdev -Key change: -- `src/macos/common.rs`: return `None` for `name` instead of calling `create_string_for_key` in the event tap callback. +Simple library to listen and send events **globally** to keyboard and mouse on macOS, Windows and Linux +(x11). -If upstream fixes this, we can remove this vendor directory and return to the crates.io dependency. +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 + +```rust +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: + +### macOS +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 (i.e. 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` callback 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 + +```rust +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. + +```rust +/// 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 and 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). + +```rust +/// 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 + +```rust +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. + +```rust +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 supplying 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 lets 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 + +```rust +#[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: + +#### macOS +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 (i.e. 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 running 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 deserialized with +Serde if you install this library with the `serialize` feature. diff --git a/src-tauri/vendor/rdev/src/linux/grab.rs b/src-tauri/vendor/rdev/src/linux/grab.rs index 43aa07e..7ced7d2 100644 --- a/src-tauri/vendor/rdev/src/linux/grab.rs +++ b/src-tauri/vendor/rdev/src/linux/grab.rs @@ -148,20 +148,6 @@ convert_keys!( KEY_F8, F8, KEY_F9, F9, KEY_F10, F10, - KEY_F11, F11, - KEY_F12, F12, - KEY_F13, F13, - KEY_F14, F14, - KEY_F15, F15, - KEY_F16, F16, - KEY_F17, F17, - KEY_F18, F18, - KEY_F19, F19, - KEY_F20, F20, - KEY_F21, F21, - KEY_F22, F22, - KEY_F23, F23, - KEY_F24, F24, KEY_NUMLOCK, NumLock, KEY_SCROLLLOCK, ScrollLock, KEY_KP7, Kp7, @@ -176,6 +162,8 @@ convert_keys!( KEY_KP2, Kp2, KEY_KP3, Kp3, KEY_KP0, Kp0, + KEY_F11, F11, + KEY_F12, F12, KEY_KPENTER, KpReturn, KEY_RIGHTCTRL, ControlRight, KEY_KPSLASH, KpDivide, diff --git a/src-tauri/vendor/rdev/src/linux/keycodes.rs b/src-tauri/vendor/rdev/src/linux/keycodes.rs index a50c8e7..85adecf 100644 --- a/src-tauri/vendor/rdev/src/linux/keycodes.rs +++ b/src-tauri/vendor/rdev/src/linux/keycodes.rs @@ -42,18 +42,6 @@ decl_keycodes!( F10, 76, F11, 95, F12, 96, - F13, 191, - F14, 192, - F15, 193, - F16, 194, - F17, 195, - F18, 196, - F19, 197, - F20, 198, - F21, 199, - F22, 200, - F23, 201, - F24, 202, F2, 68, F3, 69, F4, 70, diff --git a/src-tauri/vendor/rdev/src/linux/listen.rs b/src-tauri/vendor/rdev/src/linux/listen.rs index 74bd4ea..dac0cd8 100644 --- a/src-tauri/vendor/rdev/src/linux/listen.rs +++ b/src-tauri/vendor/rdev/src/linux/listen.rs @@ -43,7 +43,7 @@ where let context = xrecord::XRecordCreateContext( dpy_control, 0, - &raw mut RECORD_ALL_CLIENTS, + &mut RECORD_ALL_CLIENTS, 1, &mut &mut record_range as *mut &mut xrecord::XRecordRange as *mut *mut xrecord::XRecordRange, @@ -105,10 +105,8 @@ unsafe extern "C" fn record_callback( let x = xdatum.root_x as f64; let y = xdatum.root_y as f64; - let keyboard = &raw mut KEYBOARD; - if let Some(event) = convert(unsafe { &mut *keyboard }, code, type_, x, y) { - let callback = &raw mut GLOBAL_CALLBACK; - if let Some(callback) = unsafe { &mut *callback } { + if let Some(event) = convert(&mut KEYBOARD, code, type_, x, y) { + if let Some(callback) = &mut GLOBAL_CALLBACK { callback(event); } } diff --git a/src-tauri/vendor/rdev/src/macos/common.rs b/src-tauri/vendor/rdev/src/macos/common.rs index f741c2b..5cc7301 100644 --- a/src-tauri/vendor/rdev/src/macos/common.rs +++ b/src-tauri/vendor/rdev/src/macos/common.rs @@ -133,10 +133,10 @@ pub unsafe fn convert( _ => None, }; if let Some(event_type) = option_type { - // macOS 15+ asserts when fetching input source data off the main thread - // (crash in islGetInputSourceListWithAdditions). We only need the key - // code, so skip the localized name lookup entirely to keep the listener - // stable across platforms. + // Keyboard Helper only needs physical key codes. Resolving Event.name here + // calls macOS input-source APIs from the event tap callback and crashes on + // macOS 15 for some input methods/layout states, before our callback can + // ignore the name. Keep the listener fallible by leaving names unresolved. let name = None; return Some(Event { event_type, diff --git a/src-tauri/vendor/rdev/src/macos/keycodes.rs b/src-tauri/vendor/rdev/src/macos/keycodes.rs index 768a6a8..eeb6f98 100644 --- a/src-tauri/vendor/rdev/src/macos/keycodes.rs +++ b/src-tauri/vendor/rdev/src/macos/keycodes.rs @@ -16,14 +16,6 @@ const F1: CGKeyCode = 122; const F10: CGKeyCode = 109; const F11: CGKeyCode = 103; const F12: CGKeyCode = 111; -const F13: CGKeyCode = 105; -const F14: CGKeyCode = 107; -const F15: CGKeyCode = 113; -const F16: CGKeyCode = 106; -const F17: CGKeyCode = 64; -const F18: CGKeyCode = 79; -const F19: CGKeyCode = 80; -const F20: CGKeyCode = 90; const F2: CGKeyCode = 120; const F3: CGKeyCode = 99; const F4: CGKeyCode = 118; @@ -105,14 +97,6 @@ pub fn code_from_key(key: Key) -> Option { Key::F10 => Some(F10), Key::F11 => Some(F11), Key::F12 => Some(F12), - Key::F13 => Some(F13), - Key::F14 => Some(F14), - Key::F15 => Some(F15), - Key::F16 => Some(F16), - Key::F17 => Some(F17), - Key::F18 => Some(F18), - Key::F19 => Some(F19), - Key::F20 => Some(F20), Key::F2 => Some(F2), Key::F3 => Some(F3), Key::F4 => Some(F4), @@ -197,14 +181,6 @@ pub fn key_from_code(code: CGKeyCode) -> Key { F10 => Key::F10, F11 => Key::F11, F12 => Key::F12, - F13 => Key::F13, - F14 => Key::F14, - F15 => Key::F15, - F16 => Key::F16, - F17 => Key::F17, - F18 => Key::F18, - F19 => Key::F19, - F20 => Key::F20, F2 => Key::F2, F3 => Key::F3, F4 => Key::F4, diff --git a/src-tauri/vendor/rdev/src/rdev.rs b/src-tauri/vendor/rdev/src/rdev.rs index 134c45b..277be3b 100644 --- a/src-tauri/vendor/rdev/src/rdev.rs +++ b/src-tauri/vendor/rdev/src/rdev.rs @@ -115,18 +115,6 @@ pub enum Key { F10, F11, F12, - F13, - F14, - F15, - F16, - F17, - F18, - F19, - F20, - F21, - F22, - F23, - F24, F2, F3, F4, diff --git a/src-tauri/vendor/rdev/src/windows/keycodes.rs b/src-tauri/vendor/rdev/src/windows/keycodes.rs index 7e03c8e..c3707f7 100644 --- a/src-tauri/vendor/rdev/src/windows/keycodes.rs +++ b/src-tauri/vendor/rdev/src/windows/keycodes.rs @@ -44,18 +44,6 @@ decl_keycodes! { F10, 121, F11, 122, F12, 123, - F13, 124, - F14, 125, - F15, 126, - F16, 127, - F17, 128, - F18, 129, - F19, 130, - F20, 131, - F21, 132, - F22, 133, - F23, 134, - F24, 135, F2, 113, F3, 114, F4, 115, diff --git a/src/app_config.js b/src/app_config.js index 6c513c1..9fdcce4 100644 --- a/src/app_config.js +++ b/src/app_config.js @@ -26,7 +26,6 @@ const MODIFIER_KEYS = new Set([ "Shift", "ShiftLeft", "ShiftRight", "Control", "ControlLeft", "ControlRight", "Alt", "AltLeft", "AltRight", "Meta", "MetaLeft", "MetaRight", ]); - function isPlainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -95,8 +94,10 @@ export function normalizeConfig(value) { const defaultLayout = requestedDefault && Object.hasOwn(layouts, requestedDefault) ? requestedDefault : Object.keys(layouts)[0] ?? defaults.defaultLayout; + const normalized = { ...value }; + delete normalized.highlightingSource; return { - ...value, + ...normalized, defaultLayout, toggleHotkey: normalizeHotkey(value.toggleHotkey), layouts, @@ -104,7 +105,8 @@ export function normalizeConfig(value) { } export function serializeConfig(original, draft) { - const base = isPlainObject(original) ? original : {}; + const base = isPlainObject(original) ? { ...original } : {}; + delete base.highlightingSource; return { ...base, defaultLayout: draft.defaultLayout, diff --git a/src/ble_highlight.js b/src/ble_highlight.js new file mode 100644 index 0000000..df55ec6 --- /dev/null +++ b/src/ble_highlight.js @@ -0,0 +1,55 @@ +export function createBleHighlightController({ + resolvePosition, + setComboActive, + showPositionLabel = () => {}, + reportDiagnostic = () => {}, +}) { + const pressedPositions = new Map(); + const activeCombos = new Set(); + + function handleKey(event) { + const element = event.action === "up" + ? pressedPositions.get(event.position) ?? resolvePosition(event.position, event.layer) + : resolvePosition(event.position, event.layer); + if (!element) { + reportDiagnostic({ code: "unmatched-position", event }); + return false; + } + if (event.action === "down") { + element.classList.add("pressed"); + pressedPositions.set(event.position, element); + showPositionLabel(element, event); + } else { + element.classList.remove("pressed"); + pressedPositions.delete(event.position); + } + return true; + } + + function handleCombo(event) { + const active = event.action === "down"; + if (!setComboActive(event.comboId, active, event.positions)) { + reportDiagnostic({ code: "unmatched-combo", event }); + return false; + } + if (active) activeCombos.add(event.comboId); + else activeCombos.delete(event.comboId); + return true; + } + + function handleEvent(event) { + if (event?.source !== "ble") return false; + if (event.kind === "key") return handleKey(event); + if (event.kind === "combo") return handleCombo(event); + return false; + } + + function clear() { + pressedPositions.forEach((element) => element.classList.remove("pressed")); + activeCombos.forEach((comboId) => setComboActive(comboId, false)); + pressedPositions.clear(); + activeCombos.clear(); + } + + return { handleEvent, clear }; +} diff --git a/src/ble_status.js b/src/ble_status.js new file mode 100644 index 0000000..442366a --- /dev/null +++ b/src/ble_status.js @@ -0,0 +1,21 @@ +const SOURCE_LABELS = Object.freeze({ + ble: "BLE", + system: "System listener", +}); + +export function formatBleKeyboardStatus(inputStatus, bleStatus, batteryLevel = null) { + const effective = SOURCE_LABELS[inputStatus?.effectiveSource] ?? "Unavailable"; + const mode = bleStatus?.mode === "enhanced" + ? "extension v1" + : bleStatus?.mode === "stock" + ? "stock ZMK" + : bleStatus?.mode === "unsupported" + ? "unsupported extension" + : "not connected"; + const reason = bleStatus?.reason ?? inputStatus?.reason ?? null; + return Object.freeze({ + summary: `Active: ${effective}`, + detail: reason ? `${mode} · ${reason}` : mode, + battery: Number.isInteger(batteryLevel) ? `Battery ${batteryLevel}%` : "Battery unavailable", + }); +} 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_events.js b/src/input_events.js new file mode 100644 index 0000000..e7d732f --- /dev/null +++ b/src/input_events.js @@ -0,0 +1,100 @@ +export const HIGHLIGHTING_SOURCES = Object.freeze({ + BLE: "ble", + SYSTEM: "system", +}); + +const VALID_ACTIONS = new Set(["down", "up"]); + +function isByte(value) { + return Number.isInteger(value) && value >= 0 && value <= 0xff; +} + +function isSequence(value) { + return Number.isInteger(value) && value >= 0 && value <= 0xffffffff; +} + +export function normalizeSystemKeyEvent(payload) { + const action = payload?.event_type ?? payload?.action; + if (typeof payload?.key !== "string" || !payload.key || !VALID_ACTIONS.has(action)) return null; + return Object.freeze({ + kind: "key", + source: HIGHLIGHTING_SOURCES.SYSTEM, + action, + code: payload.key, + }); +} + +export function normalizeBleKeyboardFrame(frame) { + const event = frame?.event; + if (!event || !isSequence(frame.sequence)) return null; + const common = { + source: HIGHLIGHTING_SOURCES.BLE, + sequence: frame.sequence, + streamStart: Boolean(frame.flags & 0x01), + }; + + if (event.kind === "key") { + if (!VALID_ACTIONS.has(event.action) || !isByte(event.position) || !isByte(event.layer)) return null; + return Object.freeze({ + ...common, + kind: "key", + action: event.action, + position: event.position, + layer: event.layer, + }); + } + + if (event.kind === "combo") { + const positions = event.positions; + if ( + !VALID_ACTIONS.has(event.action) + || !Number.isInteger(event.comboId) + || event.comboId <= 0 + || event.comboId > 0xffff + || !isByte(event.layer) + || !Array.isArray(positions) + || positions.length > 4 + || positions.some((position) => !isByte(position)) + ) return null; + return Object.freeze({ + ...common, + kind: "combo", + action: event.action, + comboId: event.comboId, + positions: Object.freeze([...positions]), + layer: event.layer, + }); + } + + if (event.kind === "layer") { + if ( + !isByte(event.layer) + || !isByte(event.previousLayer) + || !isByte(event.cause) + || !isByte(event.originPosition) + ) return null; + return Object.freeze({ + ...common, + kind: "layer", + layer: event.layer, + previousLayer: event.previousLayer, + cause: event.cause, + originPosition: event.originPosition, + }); + } + + if (event.kind === "diagnostic") { + if (!Number.isInteger(event.code) || !isByte(event.severity) || !isByte(event.source)) return null; + return Object.freeze({ + ...common, + kind: "diagnostic", + code: event.code, + severity: event.severity, + diagnosticSource: event.source, + count: event.count, + detail: event.detail, + }); + } + + return null; +} diff --git a/src/input_source_controller.js b/src/input_source_controller.js new file mode 100644 index 0000000..3fbcd91 --- /dev/null +++ b/src/input_source_controller.js @@ -0,0 +1,116 @@ +import { HIGHLIGHTING_SOURCES } from "./input_events.js"; + +export function createInputSourceController({ + onEvent = () => {}, + onClearSourceState = () => {}, + onEffectiveSourceChange = () => {}, + onStatusChange = () => {}, +} = {}) { + let capabilitiesValidated = false; + let subscribed = false; + let streamStarted = false; + let bleReason = "ble-not-connected"; + let effectiveSource = selectEffectiveSource(); + + function bleReady() { + return capabilitiesValidated && subscribed && streamStarted; + } + + function selectEffectiveSource() { + return capabilitiesValidated && subscribed && streamStarted + ? HIGHLIGHTING_SOURCES.BLE + : HIGHLIGHTING_SOURCES.SYSTEM; + } + + function statusReason() { + if (bleReady()) return null; + return bleReason; + } + + function snapshot() { + return Object.freeze({ + effectiveSource, + reason: statusReason(), + bleReady: bleReady(), + capabilitiesValidated, + subscribed, + streamStarted, + }); + } + + function publishStatus() { + const current = snapshot(); + onStatusChange(current); + return current; + } + + function reconcile(reason) { + const previous = effectiveSource; + const next = selectEffectiveSource(); + if (previous !== next) { + if (previous) onClearSourceState({ source: previous, reason }); + effectiveSource = next; + onEffectiveSourceChange({ previous, current: next, reason }); + } + return publishStatus(); + } + + function setBleConnection({ + capabilitiesValidated: nextCapabilities = capabilitiesValidated, + subscribed: nextSubscribed = subscribed, + reason = null, + } = {}) { + capabilitiesValidated = Boolean(nextCapabilities); + subscribed = Boolean(nextSubscribed); + if (!capabilitiesValidated || !subscribed) streamStarted = false; + bleReason = reason ?? (!capabilitiesValidated + ? "ble-capabilities-unavailable" + : !subscribed + ? "ble-subscription-pending" + : "ble-stream-start-pending"); + return reconcile("ble-readiness-changed"); + } + + function disconnectBle(reason = "ble-disconnected") { + capabilitiesValidated = false; + subscribed = false; + streamStarted = false; + bleReason = reason; + return reconcile("ble-disconnected"); + } + + function handleEvent(event) { + if (!event || !Object.values(HIGHLIGHTING_SOURCES).includes(event.source)) return false; + if ( + event.source === HIGHLIGHTING_SOURCES.BLE + && event.streamStart + && capabilitiesValidated + && subscribed + && !streamStarted + ) { + streamStarted = true; + bleReason = null; + reconcile("ble-stream-start"); + } + if (event.source !== effectiveSource) return false; + onEvent(event); + return true; + } + + function reportSequenceGap() { + if (effectiveSource === HIGHLIGHTING_SOURCES.BLE) { + onClearSourceState({ source: HIGHLIGHTING_SOURCES.BLE, reason: "sequence-gap" }); + } + return publishStatus(); + } + + publishStatus(); + + return { + getSnapshot: snapshot, + setBleConnection, + disconnectBle, + handleEvent, + reportSequenceGap, + }; +} 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..3826892 100644 --- a/src/layout_catalog.js +++ b/src/layout_catalog.js @@ -7,11 +7,13 @@ export function formatLayerName(rawName, index) { } export function normalizeLayerData(layerSource) { - if (!layerSource) return { layers: [], names: [] }; + if (!layerSource) return { layers: [], names: [], layerKeys: [] }; if (Array.isArray(layerSource)) { + const layerKeys = layerSource.map((_, index) => String(index)); return { layers: layerSource, names: layerSource.map((_, index) => `Layer ${index + 1}`), + layerKeys, }; } @@ -24,6 +26,7 @@ export function normalizeLayerData(layerSource) { return { layers: entries.map(([, layer]) => layer), names: entries.map(([name], index) => formatLayerName(name, index)), + layerKeys: entries.map(([name]) => name), }; } diff --git a/src/layout_corne.json b/src/layout_corne.json index 2132793..5db6ff5 100644 --- a/src/layout_corne.json +++ b/src/layout_corne.json @@ -2,8 +2,8 @@ "name": "Corne (split)", "bleLayerSource": { "deviceName": "Corney", - "serviceUuid": "12341234-1234-5678-7856-123412345678", - "characteristicUuid": "12341234-1234-5678-7856-123412345679", + "serviceUuid": "b34a0001-e782-4706-8f9c-6c056c416507", + "characteristicUuid": "b34a0002-e782-4706-8f9c-6c056c416507", "format": "int32-le" }, "keySize": {"w": 57,"h": 45,"gap": 10}, @@ -12,6 +12,15 @@ {"row": 1,"col": 1},{"row": 1,"col": 2},{"row": 1,"col": 3},{"row": 1,"col": 4},{"row": 1,"col": 5},{"row": 1,"col": 6},{"row": 1,"col": 10},{"row": 1,"col": 11},{"row": 1,"col": 12},{"row": 1,"col": 13},{"row": 1,"col": 14},{"row": 1,"col": 15}, {"row": 2,"col": 1},{"row": 2,"col": 2},{"row": 2,"col": 3},{"row": 2,"col": 4},{"row": 2,"col": 5},{"row": 2,"col": 6},{"row": 2,"col": 10},{"row": 2,"col": 11},{"row": 2,"col": 12},{"row": 2,"col": 13},{"row": 2,"col": 14},{"row": 2,"col": 15}, {"row": 3.2,"col": 5,"cls": "action"},{"row": 3.2,"col": 6,"cls": "action","angle": 5},{"row": 3.4,"col": 7,"cls": "action","angle": 10},{"row": 3.4,"col": 9,"cls": "action","angle": -10},{"row": 3.2,"col": 10,"cls": "action","angle": -5},{"row": 3.2,"col": 11,"cls": "action"}], + "combos": [ + {"id": 1, "positions": [1, 2], "code": "Escape"}, + {"id": 2, "positions": [15, 16], "code": "Return"}, + {"id": 3, "positions": [19, 20], "code": "Return"}, + {"id": 4, "positions": [27, 28], "code": "Backspace"}, + {"id": 5, "positions": [31, 32], "code": "Backspace"}, + {"id": 6, "positions": [40, 41], "code": "MouseLayer"}, + {"id": 7, "positions": [36, 37], "code": "MouseLayer"} + ], "keyLayers": { "default": [["Tab", "Tab"],["q", "KeyQ"],["w", "KeyW"],["e", "KeyE"],["r", "KeyR"],["t", "KeyT"],["z", "KeyY"],["u", "KeyU"],["i", "KeyI"],["o", "KeyO"],["p", "KeyP"],["⌫", "Backspace"],["Ctrl", "ControlLeft"],["a", "KeyA"],["s", "KeyS"],["d", "KeyD"],["f", "KeyF"],["g", "KeyG"],["h", "KeyH"],["j", "KeyJ"],["k", "KeyK"],["l", "KeyL"],["ö", "SemiColon"],["ä", "Quote"],["⇧", "ShiftLeft"],["y", "KeyZ"],["x", "KeyX"],["c", "KeyC"],["v", "KeyV"],["b", "KeyB"],["n", "KeyN"],["m", "KeyM"],[",", "Comma"],[".", "Dot"],["/", "Slash"],["ESC", "Escape"],["Alt", "Alt"],["Low", ""],["SPACE", "Enter"],["ENTER", "Return"],["Up", ""],["ALT", "AltGr"]], "layer1": [null,["Q", "KeyQ"],["W", "KeyW"],["E", "KeyE"],["R", "KeyR"],["T", "KeyT"],["Z", "KeyY"],["U", "KeyU"],["I", "KeyI"],["O", "KeyO"],["P", "KeyP"],null,null,["A", "KeyA"],["S", "KeyS"],["D", "KeyD"],["F", "KeyF"],["G", "KeyG"],["H", "KeyH"],["J", "KeyJ"],["K", "KeyK"],["L", "KeyL"],["Ö", "SemiColon"],["Ä", "Quote"],null,["Y", "KeyZ"],["X", "KeyX"],["C", "KeyC"],["V", "KeyV"],["B", "KeyB"],["N", "KeyM"],["M", "KeyM"],null,null,null,null,null,null,null,null,null,null,null], diff --git a/src/main.js b/src/main.js index 18c0945..002ecce 100644 --- a/src/main.js +++ b/src/main.js @@ -6,6 +6,12 @@ import { normalizeBleLayerSource, } from "./ble_layer_sync.js"; 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"; import { @@ -18,6 +24,10 @@ 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, + matchesOrderedLayerRequest, +} from "./self_test/layer_lease.js"; const builtinLayoutFiles = BUILTIN_LAYOUT_FILES; let layoutDefinitions = {}; @@ -25,13 +35,33 @@ let normalizedLayoutLayers = {}; let layouts = {}; let layoutLayers = {}; let layoutLayerNames = {}; +let layoutLayerKeys = {}; let layoutSources = {}; let layoutBleSources = {}; let layoutInputSourceSync = {}; let comboDefinitionsByLayout = {}; let comboBordersByCode = new Map(); +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; + window.__TAURI__.event + .emitTo("keyboard-self-test", "self-test-source-state", 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 @@ -121,15 +151,17 @@ async function loadLayoutDefinitions(config) { function rebuildLayoutData() { normalizedLayoutLayers = {}; layoutLayerNames = {}; + layoutLayerKeys = {}; layouts = {}; layoutBleSources = {}; layoutInputSourceSync = {}; comboDefinitionsByLayout = {}; for (const [key, def] of Object.entries(layoutDefinitions)) { - const { layers, names } = normalizeLayerData(def.keyLayers); + const { layers, names, layerKeys } = normalizeLayerData(def.keyLayers); normalizedLayoutLayers[key] = layers; layoutLayerNames[key] = names; + layoutLayerKeys[key] = layerKeys; layouts[key] = buildLayout(def, layers); layoutBleSources[key] = normalizeBleLayerSource(def); const inputSourceSync = normalizeInputSourceSync(def, layers.length); @@ -138,7 +170,9 @@ function rebuildLayoutData() { layoutLoadErrors.push(`${def.name ?? key}: ${inputSourceSync.error}`); } if (Array.isArray(def.combos)) { - comboDefinitionsByLayout[key] = def.combos.map(normalizeCombo).filter(Boolean); + comboDefinitionsByLayout[key] = def.combos + .map((combo) => normalizeCombo(combo, def.keyPositions)) + .filter(Boolean); } } @@ -154,6 +188,7 @@ let keyEventIndicatorEl = null; let keyEventHideTimer = null; let layoutErrorEl = null; let layoutErrorTimer = null; +let bleKeyboardStatusEl = null; let menuControls = null; let menuStateController = null; let overlayModeController = null; @@ -173,11 +208,13 @@ 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; +let bleBatteryLevel = null; const pressedKeyTracker = createPressedKeyTracker(); function languageStatusLabel(status) { @@ -259,6 +296,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; @@ -274,7 +312,7 @@ async function selectLanguage(inputSourceId) { } } -function getAllowedLayoutKeys(config) { +function getAllowedLayoutKeys(_config) { const availableKeys = Object.keys(layoutDefinitions); return availableKeys; } @@ -302,13 +340,22 @@ async function loadConfig() { } } -function normalizeCombo(combo) { +function normalizeCombo(combo, keyPositions = []) { if (!combo || typeof combo !== "object") return null; - const { key1, key2, code } = combo; + const positions = Array.isArray(combo.positions) ? combo.positions : null; + const key1 = combo.key1 ?? (Number.isInteger(positions?.[0]) ? keyPositions[positions[0]] : null); + const key2 = combo.key2 ?? (Number.isInteger(positions?.[1]) ? keyPositions[positions[1]] : null); + const { code } = combo; if (!key1 || !key2 || !code) return null; if (typeof key1.row !== "number" || typeof key1.col !== "number") return null; if (typeof key2.row !== "number" || typeof key2.col !== "number") return null; - return { key1, key2, code: String(code) }; + return { + key1, + key2, + code: String(code), + id: Number.isInteger(combo.id) && combo.id > 0 ? combo.id : null, + positions: positions ? [...positions] : null, + }; } function applyKeySizes({ w, h, gap }) { @@ -320,6 +367,7 @@ function applyKeySizes({ w, h, gap }) { function clearComboBorders() { comboBordersByCode.clear(); + comboBordersById.clear(); comboBorderEls.forEach((el) => el.remove()); comboBorderEls = []; } @@ -360,6 +408,7 @@ function renderComboBorders(layout, comboDefinitions) { comboBordersByCode.set(combo.code, []); } comboBordersByCode.get(combo.code).push(border); + if (combo.id !== null) comboBordersById.set(combo.id, border); }); } @@ -369,6 +418,13 @@ function setComboActive(code, active) { borders.forEach((border) => border.classList.toggle("active", active)); } +function setBleComboActive(comboId, active) { + const border = comboBordersById.get(comboId); + if (!border) return false; + border.classList.toggle("active", active); + return true; +} + function renderKeyboard(layout) { layoutRoot.innerHTML = ""; pressedKeyTracker.clear(); @@ -485,6 +541,19 @@ function ensureLayerIndicator() { } } +function renderBleKeyboardStatus() { + ensureHudContainer(); + if (!bleKeyboardStatusEl) { + bleKeyboardStatusEl = document.createElement("div"); + bleKeyboardStatusEl.className = "ble-keyboard-status"; + bleKeyboardStatusEl.setAttribute("role", "status"); + bleKeyboardStatusEl.setAttribute("aria-live", "polite"); + } + if (!hudContainer.contains(bleKeyboardStatusEl)) hudContainer.appendChild(bleKeyboardStatusEl); + const status = formatBleKeyboardStatus(highlightingStatus, bleKeyboardStatus, bleBatteryLevel); + bleKeyboardStatusEl.textContent = `${status.summary} · ${status.detail} · ${status.battery}`; +} + function renderLayerIndicator() { ensureLayerIndicator(); const totalLayers = layoutLayers[currentLayoutKey]?.length ?? 1; @@ -539,46 +608,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()); - 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; @@ -586,23 +615,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}`); @@ -622,6 +639,20 @@ function handleKey(code, type) { } } +function clearHighlightState() { + bleHighlightController?.clear(); + document.querySelectorAll(".key.pressed").forEach((element) => element.classList.remove("pressed")); + comboBorderEls.forEach((element) => element.classList.remove("active")); + pressedKeyTracker.clear(); + shiftHeld = false; + altGrHeld = false; +} + +function handleNormalizedInputEvent(event) { + if (event.kind === "key" && event.source === "system") handleKey(event.code, event.action); + else if (event.source === "ble") bleHighlightController?.handleEvent(event); +} + async function refreshExternalLayout(key) { const source = layoutSources[key]; if (typeof source !== "string") return { ok: true, error: null }; @@ -635,6 +666,7 @@ async function refreshExternalLayout(key) { } async function reloadCurrentLayout(key) { + selfTestLayerLease?.invalidate("layout-reloaded"); return reloadActiveExternalLayout({ key, getCurrentLayoutKey: () => currentLayoutKey, @@ -711,6 +743,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) { @@ -776,7 +809,31 @@ window.addEventListener("DOMContentLoaded", async () => { .catch((err) => console.error("Failed to listen enter-mini-mode-requested:", err)); } const config = await loadConfig(); - parsedToggleHotkey = parseToggleHotkey(config?.toggleHotkey ?? null); + globalOverlayHotkey = createGlobalOverlayHotkey({ + hotkey: config?.toggleHotkey ?? null, + onToggle: () => tauriHandle?.core?.invoke("toggle_window").catch(console.error), + }); + inputSourceController = createInputSourceController({ + onEvent: handleNormalizedInputEvent, + onClearSourceState: clearHighlightState, + onStatusChange: (status) => { + highlightingStatus = status; + renderBleKeyboardStatus(); + publishSelfTestSourceState(status); + }, + }); + bleHighlightController = createBleHighlightController({ + resolvePosition: (position) => document.querySelector(`.key[data-index="${position}"]`), + setComboActive: setBleComboActive, + showPositionLabel: (element, event) => showKeyEvent( + element.textContent?.trim() || `Position ${event.position}`, + ), + reportDiagnostic: ({ code, event }) => showLayoutError( + code === "unmatched-combo" + ? `BLE combo ${event.comboId} is not present in the loaded layout.` + : `BLE position ${event.position} is not present in the loaded layout.`, + ), + }); await loadLayoutDefinitions(config); if (Object.keys(layoutDefinitions).length === 0) { console.error("No layouts loaded; cannot initialize UI"); @@ -792,10 +849,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); @@ -805,6 +869,20 @@ window.addEventListener("DOMContentLoaded", async () => { } }, }); + selfTestLayerLease = createSelfTestLayerLeaseCoordinator({ + getActiveLayoutKey: () => currentLayoutKey, + getObservedLayer: () => observedBleLayer, + isWritable: () => bleLayerControlStatus.state === "connected" + && bleLayerControlStatus.writable + && bleLayerSync?.getActiveLayoutKey() === currentLayoutKey, + validateLayerRequest: (request) => matchesOrderedLayerRequest( + layoutLayerKeys[currentLayoutKey], + request, + ), + writeLayer: (layer, acceptableLayers) => bleLayerSync.writeLayer(layer, acceptableLayers), + setReconciliationSuspended: (suspended) => sourceLayerReconciler?.setSuspended(suspended), + onStatus: publishSelfTestLayerLeaseStatus, + }); if (tauri?.core?.invoke && tauri?.event?.listen) { macosInputSource = createMacosInputSourceController({ @@ -871,17 +949,100 @@ window.addEventListener("DOMContentLoaded", async () => { tauri.event .listen("key_event", (e) => { - const { key, event_type } = e.payload; - handleKey(key, event_type); + const event = normalizeSystemKeyEvent(e.payload); + if (event) { + routeSystemKeyEvent(event, { + hotkeyController: globalOverlayHotkey, + inputSourceController, + }); + } }) .catch((err) => console.error("Failed to listen key_event:", err)); + tauri.event + .listen("ble_keyboard_status", (event) => { + const payload = event.payload ?? {}; + if (payload.layout !== currentLayoutKey) return; + bleKeyboardStatus = payload; + renderBleKeyboardStatus(); + if (["disconnected", "error", "idle"].includes(payload.state)) { + inputSourceController.disconnectBle(payload.reason ?? `ble-${payload.state}`); + return; + } + inputSourceController.setBleConnection({ + capabilitiesValidated: Boolean(payload.capabilitiesValidated), + subscribed: Boolean(payload.subscribed), + reason: payload.reason, + }); + }) + .catch((err) => console.error("Failed to listen ble_keyboard_status:", err)); + + tauri.event + .listen("ble_keyboard_event", (event) => { + const payload = event.payload ?? {}; + if (payload.layout !== currentLayoutKey) return; + const normalized = normalizeBleKeyboardFrame(payload.frame); + if (normalized) inputSourceController.handleEvent(normalized); + }) + .catch((err) => console.error("Failed to listen ble_keyboard_event:", err)); + + tauri.event + .listen("ble_keyboard_diagnostic", (event) => { + const payload = event.payload ?? {}; + if (payload.layout !== currentLayoutKey) return; + if (payload.code === "sequence-gap") inputSourceController.reportSequenceGap(); + if (payload.message) showLayoutError(payload.message); + }) + .catch((err) => console.error("Failed to listen ble_keyboard_diagnostic:", err)); + + tauri.event + .listen("ble_battery_update", (event) => { + const payload = event.payload ?? {}; + if (payload.layout !== currentLayoutKey) return; + bleBatteryLevel = Number.isInteger(payload.level) && payload.level >= 0 && payload.level <= 100 + ? payload.level + : null; + renderBleKeyboardStatus(); + }) + .catch((err) => console.error("Failed to listen ble_battery_update:", err)); + tauri.event .listen("self-test-overlay-state", (event) => { selfTestOverlayPresentation.update(event.payload); }) .catch((err) => console.error("Failed to listen self-test-overlay-state:", err)); + tauri.event + .listen("self-test-source-request", () => { + selfTestSourceStateSubscribed = true; + publishSelfTestSourceState(inputSourceController.getSnapshot()); + }) + .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; @@ -903,4 +1064,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.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 adba5b3..ad96511 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

@@ -20,9 +28,15 @@

Choose what to test

+

Loading layouts…

-
Activate the selected layer and release all keys before starting. This test observes global HID events from any attached keyboard. Modifier chords pass only after the trigger and every modifier are released. It cannot diagnose the ZMK matrix, combos, macros, or hold-tap timing.
- +
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.
+
+ + +
+

Checking global combos…

+

The global combo test is separate from layer testing. It uses firmware BLE events and includes only layout combos with a non-empty output code.