Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -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
142 changes: 63 additions & 79 deletions .github/workflows/tauri-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading