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