Skip to content
Closed
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
64 changes: 64 additions & 0 deletions .github/workflows/rust-client.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Rust client

on:
pull_request:
paths:
- "ahakey-desktop/**"
- ".github/workflows/rust-client.yml"
push:
paths:
- "ahakey-desktop/**"
- ".github/workflows/rust-client.yml"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: rust-client-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
windows:
name: Windows Rust client · tests and build
runs-on: windows-latest
defaults:
run:
working-directory: ahakey-desktop
shell: pwsh
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.12.3
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm
cache-dependency-path: ahakey-desktop/pnpm-lock.yaml
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Prepare pinned native speech runtime (no model download)
run: |
$runtime = & ./crates/speech/scripts/prepare-runtime.ps1
"SHERPA_ONNX_LIB_DIR=$runtime" | Out-File $env:GITHUB_ENV -Append -Encoding utf8
$runtime | Out-File $env:GITHUB_PATH -Append -Encoding utf8
- name: Frontend tests and production build
run: |
pnpm install --frozen-lockfile
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm test
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm build
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Rust formatting, tests and lint
run: |
cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
cargo test --locked --manifest-path src-tauri/Cargo.toml -p ahakey-desktop -p ahakey-ble
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Compile Windows client
run: cargo build --locked --release --manifest-path src-tauri/Cargo.toml --features custom-protocol
14 changes: 14 additions & 0 deletions ahakey-desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
node_modules/
dist/
src-tauri/target/
src-tauri/gen/
.cache/
*.log
*.local
.playwright-cli/
output/
src-tauri/icons/*
!src-tauri/icons/icon.svg
!src-tauri/icons/icon.ico
!src-tauri/icons/icon.png
!src-tauri/icons/icon.icns
85 changes: 85 additions & 0 deletions ahakey-desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# AhaKey Studio: Rust client

Rust + Tauri 2 + React client, version 1.1.0. The client is a separate subproject;
it does not replace the Java, Swift or bridge source trees.
Windows x64 is the tested distribution target. macOS/Linux adapters exist but
their native packaging, permissions and hardware behavior still need validation.

## Features

- Four editable profiles for Claude Code, Claude Desktop, Codex CLI and ChatGPT
App, with four physical key assignments and firmware-dependent lighting.
- Windows USB auto-detection and read-only battery/profile/brightness information,
independent of Bluetooth connection management. Information comes from one
confirmed transport at a time; disconnect clears stale USB values.
- Native Bluetooth using WinRT/CoreBluetooth/BlueZ. Connect requires a valid
device response; Windows commands request an encrypted link. Complete system
pairing before using protected firmware services.
- Capability-gated USB/BLE routing configuration for compatible firmware:
choose two targets from USB, BLE A and BLE B, or preserve the approval lever.
Unsupported firmware does not receive fabricated successful-save feedback.
The client does not implement or guarantee multi-link device firmware.
- Voice-key listening defaults on without recording. Explicitly disabling it
is remembered; corrupt settings do not trigger automatic listener startup.
Press/hold and toggle modes are supported.
- WeChat and Windows dictation use their external shortcuts. They do not expose
reliable recording-state readback; end an out-of-sync input-method popup
manually before resuming. The client does not invent timing-based state.
- Local SenseVoice Small INT8 via sherpa-onnx. Runtime is bundled on Windows;
weights download/import is opt-in. Local audio is not uploaded.
- Optional Doubao streaming recognition with user-provided API credentials.
Windows local/cloud captions follow the target window's monitor/work area.
- Image crop/fit/RGB565 preview and export, plus extensible quota cards for
MiniMax, GLM, Kimi, Codex and custom HTTPS data. Provider credentials remain on
the host. On-device image/card upload is not implemented in this release.
- Tray provider/profile/caption actions; an optional loopback Hook-event
receiver. Hooks never auto-approve requests or edit external harness settings.

USB input needs no BLE pairing or companion app, but the keyboard must select
USB as its input target. Voice recognition still needs the receiving computer's
listener/input method. Full profile/light writes currently use BLE; USB supports
information and routing controls. Firmware status 1.0 may be a compatibility
field, not its actual release version.

## Build and test on Windows

Install Node.js 22+, pnpm 10, stable Rust, MSVC Build Tools, Windows SDK and
WebView2. From this subproject:

```powershell
pnpm install --frozen-lockfile
$env:SHERPA_ONNX_LIB_DIR = & .\crates\speech\scripts\prepare-runtime.ps1
pnpm test
pnpm build
cargo test --locked --manifest-path src-tauri/Cargo.toml
cargo test --locked --manifest-path crates/ble/Cargo.toml
cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings
.\scripts\build-windows.ps1
```

Run the executable inside the complete output directory; all four native ASR
DLLs and license notices must remain beside it. The build script never installs,
signs, publishes, or stops another app. No firmware source or HEX is needed to
build this client. Firmware-specific features remain capability-gated.

Windows distribution may name the launcher AhaKeyStudio.exe. Do not run it
alongside the Java client or another preview's voice listener.
The production application identity is ai.ahakey.studio. On first launch only,
validated settings from the known previous preview identities are copied if no
production settings exist. Preview files and explicit listener-off choices are
preserved. Credentials are not copied between identities; reconfigure those
explicitly. Import existing models explicitly rather than overwriting preview data.

## Verification boundary

Automated checks cover protocol frames, USB and BLE source selection, settings,
caption placement, provider parsers and key-edge state. A separate known-answer
ASR test accepts explicit local fixtures and never opens a microphone or
downloads weights. USB information has been exercised on hardware on Windows;
three-host concurrency, all host sleep/wake cases and non-Windows acceptance
are not implied by unit tests or a successful build.

See the [BLE](crates/ble/README.md), [speech](crates/speech/README.md) and
[cloud](crates/cloud/README.md) module guides. Mutating IPC is restricted to the
main window; the renderer allows local assets and IPC only. Sensitive credentials
do not belong in configuration exports, source control or device payloads.
2 changes: 2 additions & 0 deletions ahakey-desktop/crates/ble/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/target/
/Cargo.lock
18 changes: 18 additions & 0 deletions ahakey-desktop/crates/ble/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "ahakey-ble"
version = "0.1.0"
edition = "2021"
rust-version = "1.85"

[dependencies]
btleplug = "0.13.0"
futures = "0.3"
serde = { version = "1", features = ["derive"] }
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] }
tokio-util = "0.7"
uuid = "1"

[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = ["Devices_Enumeration", "Devices_Bluetooth", "Devices_Bluetooth_GenericAttributeProfile", "Devices_HumanInterfaceDevice", "Foundation", "Foundation_Collections", "Storage", "Storage_Streams"] }
windows-future = "0.3.2"
42 changes: 42 additions & 0 deletions ahakey-desktop/crates/ble/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Native BLE transport

`ahakey-ble` uses btleplug's native WinRT, CoreBluetooth and BlueZ implementations.
It requires no separate bridge, TCP listener or driver application. The Windows
crate has been compiled and unit-tested; macOS/Linux and physical-device acceptance
must be tested separately. macOS applications require a Bluetooth usage description
and permission; Linux requires a running BlueZ service and D-Bus access.

Create one `BleClient::new().await` handle per application, subscribe to its
broadcast events, then expose explicit scan, connect, disconnect and configuration
actions in the UI. `scan(Duration)` only discovers advertisements. Save the chosen
`DeviceInfo.id` in the application's local settings and use `reconnect(id)` on the
next launch; IDs are OS-local and cannot be transferred between platforms. If no
adapter is available, show the initialization error and offer retry.

`Ready` requires all three firmware characteristics (7341 data, 7343 commands,
7344 notifications), a successful subscription and a valid 13-byte status response.
The service checks connectivity every three seconds and requests fresh status about
every fifteen seconds; no valid response for 45 seconds clears stale telemetry and
reports an error. A dropped link never becomes a fake zero-percent battery.
Reconnect is bounded to three attempts. No adapter reset, pairing removal or
unbounded background reconnection is performed.

All writes use acknowledged GATT requests. Config batches and periodic status
queries share a write gate. `save_profiles` validates all four profiles before
writing the 39-frame batch; each has four raw-HID key mappings and nine AI-state
light effects, indexed by firmware state 0..8. Typical voice usages are F17=0x6C
and F18=0x6D, Enter=0x28, Escape=0x29 and Backspace=0x2A. Modifier usages E0..E7
precede the base key usage. Descriptions are printable ASCII, capped at 20 bytes.
Save completion proves acknowledged writes, not persistence after power cycling.
Brightness, mode, light-effect and IDE-state methods are nonpersistent until a
save command is included in a profile batch.

Await `disconnect()` during application shutdown. It cancels pending connection
and write operations, joins the notification worker, unsubscribes and detaches with
timeouts. A monotonic generation blocks notifications and retries from obsolete
sessions. Dropping the handle cancels its worker as a fallback, but does not replace
the explicit asynchronous shutdown path.

Run `cargo test` and `cargo clippy --all-targets -- -D warnings` from this crate.
Tests exercise byte-for-byte protocol vectors, strict status parsing, generation
isolation and cancellation without scanning or writing a real device.
45 changes: 45 additions & 0 deletions ahakey-desktop/crates/ble/examples/probe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//! macOS hardware probe: scan -> connect -> status query -> disconnect.
//! Run: cargo run --example probe --release
use ahakey_ble::BleClient;
use std::time::Duration;

#[tokio::main]
async fn main() -> ahakey_ble::Result<()> {
let client = BleClient::new().await?;
println!("[probe] scanning 5s ...");
let secs: u64 = std::env::args()
.nth(1)
.and_then(|a| a.parse().ok())
.unwrap_or(5)
.clamp(1, 15);
println!("[probe] scan window: {}s", secs);
let devices = client.scan(Duration::from_secs(secs)).await?;
for d in &devices {
println!(
"[scan] name={:?} rssi={:?} candidate={} id={}",
d.name, d.rssi, d.is_candidate, d.id
);
}
let Some(target) = devices.iter().find(|d| d.is_candidate).or(devices.first()) else {
println!("[probe] no AhaKey device found");
return Ok(());
};
println!("[probe] connecting to {:?} ...", target.name);
client.connect(&target.id).await?;
println!("[probe] connected, querying status ...");
client.query_status().await?;
tokio::time::sleep(Duration::from_millis(600)).await;
let snap = client.status();
println!("[probe] phase={:?}", snap.phase);
match snap.status {
Some(s) => println!(
"[status] battery={}% signal={} fw={}.{} mode={} light_mode={} switch_state={} brightness={}",
s.battery_level, s.signal, s.firmware_main, s.firmware_sub,
s.work_mode, s.light_mode, s.switch_state, s.light_brightness
),
None => println!("[status] <none>"),
}
client.disconnect().await?;
println!("[probe] disconnected cleanly");
Ok(())
}
35 changes: 35 additions & 0 deletions ahakey-desktop/crates/ble/examples/usb_routing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#[cfg(windows)]
#[tokio::main]
async fn main() -> Result<(), String> {
use ahakey_ble::{routing::Config, usb_routing::UsbRouting};
let args: Vec<_> = std::env::args().skip(1).collect();
if !args.is_empty() && args != ["--use-usb"] {
return Err(
"Use no arguments to read; --use-usb explicitly saves up=USB, down=BLE A".into(),
);
}
tokio::time::timeout(std::time::Duration::from_secs(15), async {
let mut port = UsbRouting::open().await?;
println!("USB device information: {:?}", port.device_status().await?);
println!("USB routing before: {:?}", port.read().await?);
if !args.is_empty() {
println!(
"USB routing save ACK: {:?}",
port.apply(&Config {
mode: 1,
up: 2,
down: 0
})
.await?
);
println!("USB routing readback: {:?}", port.read().await?);
}
Ok(())
})
.await
.map_err(|_| "USB operation timed out".to_owned())?
}
#[cfg(not(windows))]
fn main() {
eprintln!("Windows vendor HID example only");
}
Loading