From bf7f748a05eaf9f0fef4e22cccd721081acd37dc Mon Sep 17 00:00:00 2001 From: Lihao Date: Mon, 14 Sep 2026 02:58:51 -0700 Subject: [PATCH 01/21] feat(transport): add native BLE and USB configuration library Introduce a standalone library and its focused tests. --- ahakey-desktop/crates/ble/.gitignore | 2 + ahakey-desktop/crates/ble/Cargo.toml | 18 + ahakey-desktop/crates/ble/README.md | 42 + ahakey-desktop/crates/ble/examples/probe.rs | 45 + .../crates/ble/examples/usb_routing.rs | 35 + ahakey-desktop/crates/ble/src/host_info.rs | 186 ++++ ahakey-desktop/crates/ble/src/lib.rs | 861 ++++++++++++++++++ .../crates/ble/src/native_windows.rs | 316 +++++++ ahakey-desktop/crates/ble/src/protocol.rs | 178 ++++ ahakey-desktop/crates/ble/src/reset.rs | 102 +++ ahakey-desktop/crates/ble/src/routing.rs | 350 +++++++ ahakey-desktop/crates/ble/src/usb_routing.rs | 295 ++++++ 12 files changed, 2430 insertions(+) create mode 100644 ahakey-desktop/crates/ble/.gitignore create mode 100644 ahakey-desktop/crates/ble/Cargo.toml create mode 100644 ahakey-desktop/crates/ble/README.md create mode 100644 ahakey-desktop/crates/ble/examples/probe.rs create mode 100644 ahakey-desktop/crates/ble/examples/usb_routing.rs create mode 100644 ahakey-desktop/crates/ble/src/host_info.rs create mode 100644 ahakey-desktop/crates/ble/src/lib.rs create mode 100644 ahakey-desktop/crates/ble/src/native_windows.rs create mode 100644 ahakey-desktop/crates/ble/src/protocol.rs create mode 100644 ahakey-desktop/crates/ble/src/reset.rs create mode 100644 ahakey-desktop/crates/ble/src/routing.rs create mode 100644 ahakey-desktop/crates/ble/src/usb_routing.rs diff --git a/ahakey-desktop/crates/ble/.gitignore b/ahakey-desktop/crates/ble/.gitignore new file mode 100644 index 00000000..e9e21997 --- /dev/null +++ b/ahakey-desktop/crates/ble/.gitignore @@ -0,0 +1,2 @@ +/target/ +/Cargo.lock diff --git a/ahakey-desktop/crates/ble/Cargo.toml b/ahakey-desktop/crates/ble/Cargo.toml new file mode 100644 index 00000000..bd6efa35 --- /dev/null +++ b/ahakey-desktop/crates/ble/Cargo.toml @@ -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" diff --git a/ahakey-desktop/crates/ble/README.md b/ahakey-desktop/crates/ble/README.md new file mode 100644 index 00000000..6d8b4e3c --- /dev/null +++ b/ahakey-desktop/crates/ble/README.md @@ -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. diff --git a/ahakey-desktop/crates/ble/examples/probe.rs b/ahakey-desktop/crates/ble/examples/probe.rs new file mode 100644 index 00000000..9bec7566 --- /dev/null +++ b/ahakey-desktop/crates/ble/examples/probe.rs @@ -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] "), + } + client.disconnect().await?; + println!("[probe] disconnected cleanly"); + Ok(()) +} diff --git a/ahakey-desktop/crates/ble/examples/usb_routing.rs b/ahakey-desktop/crates/ble/examples/usb_routing.rs new file mode 100644 index 00000000..a8886abb --- /dev/null +++ b/ahakey-desktop/crates/ble/examples/usb_routing.rs @@ -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"); +} diff --git a/ahakey-desktop/crates/ble/src/host_info.rs b/ahakey-desktop/crates/ble/src/host_info.rs new file mode 100644 index 00000000..4ffaaeef --- /dev/null +++ b/ahakey-desktop/crates/ble/src/host_info.rs @@ -0,0 +1,186 @@ +//! Session-only, peer-owned host metadata. Never infer a host from the input target. +use crate::{protocol::frame, BleError, Result}; +use serde::Serialize; + +pub const NAME_BYTES: usize = 24; +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HostInfo { + pub name: Option, + pub system: Option, +} +fn unsafe_char(c: char) -> bool { + c.is_control() + || matches!(c, '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') +} +pub fn bounded_name(name: &str) -> String { + let mut result = String::new(); + for c in name.trim().chars().filter(|c| !unsafe_char(*c)) { + if result.len() + c.len_utf8() > NAME_BYTES { + break; + } + result.push(c); + } + result +} +pub fn query(slot: u8, part: u8) -> Result> { + if slot > 1 || part > 2 { + return Err(BleError::Invalid( + "Invalid host information slot/part".into(), + )); + } + Ok(frame(0xa8, &[slot, part])) +} +pub fn registration(name: &str, system: u8, id: u16) -> Result<[Vec; 3]> { + if !(1..=3).contains(&system) || id > 0x3fff { + return Err(BleError::Invalid( + "Invalid host information system/request".into(), + )); + } + let name = bounded_name(name); + let mut padded = [0; NAME_BYTES]; + padded[..name.len()].copy_from_slice(name.as_bytes()); + Ok(std::array::from_fn(|part| { + let mut data = vec![ + (id & 127) as u8, + (id >> 7) as u8, + part as u8, + system, + name.len() as u8, + ]; + data.extend_from_slice(&padded[part * 8..part * 8 + 8]); + frame(0xa9, &data) + })) +} +pub fn confirm(bytes: &[u8], id: u16, part: u8) -> Result { + if bytes.len() != 10 + || bytes[..3] != [0xaa, 0xbb, 0xa9] + || bytes[3] != 0 + || bytes[4] != (id & 127) as u8 + || bytes[5] != (id >> 7) as u8 + || bytes[6] != part + || bytes[7] > 1 + || bytes[8..] != [0xcc, 0xdd] + { + return Err(BleError::Invalid( + "设备未确认本机名称上报;输入连接不受影响".into(), + )); + } + Ok(bytes[7]) +} +pub fn decode(slot: u8, parts: &[Vec; 3]) -> Result { + let invalid = || { + BleError::Invalid("设备名称未提供或读取期间发生变化,请重新读取(需固件 0.1.8+)".into()) + }; + let mut name = Vec::with_capacity(NAME_BYTES); + for (part, b) in parts.iter().enumerate() { + if slot > 1 + || b.len() != 20 + || b[..4] != [0xaa, 0xbb, 0xa8, 0] + || b[4] != slot + || b[5] != part as u8 + || b[6] > 127 + || b[7] > 127 + || b[8] > 3 + || b[9] > 24 + || b[18..] != [0xcc, 0xdd] + || (part > 0 && b[6..10] != parts[0][6..10]) + { + return Err(invalid()); + } + name.extend_from_slice(&b[10..18]); + } + let len = parts[0][9] as usize; + if name[len..].iter().any(|b| *b != 0) || (parts[0][8] == 0 && len != 0) { + return Err(invalid()); + } + let name = std::str::from_utf8(&name[..len]).map_err(|_| invalid())?; + if name.chars().any(unsafe_char) { + return Err(invalid()); + } + Ok(HostInfo { + name: (!name.is_empty()).then(|| name.to_owned()), + system: match parts[0][8] { + 1 => Some("Windows"), + 2 => Some("macOS"), + 3 => Some("Linux"), + _ => None, + } + .map(str::to_owned), + }) +} +#[cfg(test)] +mod tests { + use super::*; + fn replies(name: &str) -> [Vec; 3] { + let mut padded = [0; 24]; + padded[..name.len()].copy_from_slice(name.as_bytes()); + std::array::from_fn(|part| { + let mut b = vec![ + 0xaa, + 0xbb, + 0xa8, + 0, + 1, + part as u8, + 12, + 1, + 2, + name.len() as u8, + ]; + b.extend_from_slice(&padded[part * 8..part * 8 + 8]); + b.extend_from_slice(&[0xcc, 0xdd]); + b + }) + } + #[test] + fn unicode_names_and_default_mtu_roundtrip() { + let name = bounded_name(" 我的 MacBook 名称比较长\n\u{202e} "); + assert!(name.len() <= 24); + assert!(!name.contains('\n')); + let frames = registration(&name, 2, 0x1234).unwrap(); + assert!(frames.iter().all(|f| f.len() == 18)); + let parsed = decode(1, &replies(&name)).unwrap(); + assert_eq!(parsed.name.as_deref(), Some(name.as_str())); + assert_eq!(parsed.system.as_deref(), Some("macOS")); + assert_eq!( + confirm( + &[0xaa, 0xbb, 0xa9, 0, 0x34, 0x24, 2, 1, 0xcc, 0xdd], + 0x1234, + 2 + ) + .unwrap(), + 1 + ); + } + #[test] + fn rejects_wrong_slot_mixed_revisions_controls_and_invalid_utf8() { + let good = replies("Mac"); + assert!(decode(0, &good).is_err()); + for part in 0..3 { + for len in 0..20 { + let mut b = good.clone(); + b[part].truncate(len); + assert!(decode(1, &b).is_err()); + } + } + let mut b = good.clone(); + b[1][6] += 1; + assert!(decode(1, &b).is_err()); + let mut b = good.clone(); + b[0][10] = 0xff; + assert!(decode(1, &b).is_err()); + let mut b = good.clone(); + b[0][10] = 10; + assert!(decode(1, &b).is_err()); + let mut b = good; + for p in &mut b { + p[8] = 0; + } + assert!(decode(1, &b).is_err()); + assert!(confirm(&[0xaa, 0xbb, 0xa9, 0, 1, 0, 0, 0, 0xcc, 0xdd], 2, 0).is_err()); + assert!(registration("ok", 4, 1).is_err()); + assert!(query(2, 0).is_err()); + assert_eq!(decode(1, &replies("")).unwrap().name, None); + } +} diff --git a/ahakey-desktop/crates/ble/src/lib.rs b/ahakey-desktop/crates/ble/src/lib.rs new file mode 100644 index 00000000..439a8c7b --- /dev/null +++ b/ahakey-desktop/crates/ble/src/lib.rs @@ -0,0 +1,861 @@ +//! In-process native GATT transport: WinRT on Windows, CoreBluetooth on macOS, +//! and BlueZ on Linux (provided by btleplug). No helper executable or TCP bridge. +pub mod host_info; +pub mod protocol; +pub mod reset; +pub mod routing; +#[cfg(windows)] +pub mod usb_routing; +#[cfg(not(windows))] +use btleplug::platform::Peripheral; +use btleplug::{ + api::{ + Central, CharPropFlags, Characteristic, Manager as _, Peripheral as _, ScanFilter, + WriteType, + }, + platform::{Adapter, Manager}, +}; +#[cfg(windows)] +mod native_windows; +#[cfg(not(windows))] +use btleplug::api::RetrievePeripheralsOptions; +use futures::StreamExt; +#[cfg(windows)] +use native_windows::Peripheral; +pub use protocol::{DeviceStatus, KeyConfig, ProfileConfig}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + future::Future, + sync::{Arc, Mutex as StdMutex}, + time::Duration, +}; +use tokio::{ + sync::{broadcast, Mutex}, + task::JoinHandle, + time::{sleep, timeout}, +}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +pub type Result = std::result::Result; +#[derive(Debug, thiserror::Error)] +pub enum BleError { + #[error("Bluetooth: {0}")] + Native(String), + #[error("Bluetooth operation timed out: {0}")] + Timeout(&'static str), + #[error("Bluetooth operation cancelled")] + Cancelled, + #[error("Device is not ready")] + NotConnected, + #[error("{0}")] + Invalid(String), +} +impl From for BleError { + fn from(e: btleplug::Error) -> Self { + Self::Native(e.to_string()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ConnectionPhase { + Disconnected, + Connecting, + Ready, + Error, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceInfo { + pub id: String, + pub name: String, + pub rssi: Option, + pub is_candidate: bool, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BleSnapshot { + pub generation: u64, + pub phase: ConnectionPhase, + pub device: Option, + pub status: Option, + pub error: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "camelCase")] +pub enum BleEvent { + State(BleSnapshot), + Devices(Vec), +} + +struct Shared { + state: StdMutex, + cancel: StdMutex, + events: broadcast::Sender, +} +impl Shared { + fn publish(&self, generation: u64, update: impl FnOnce(&mut BleSnapshot)) { + let mut state = self.state.lock().unwrap(); + if state.generation != generation { + return; + } + update(&mut state); + let _ = self.events.send(BleEvent::State(state.clone())); + } + fn next(&self) -> (u64, CancellationToken) { + self.next_if(None).expect("unconditional generation") + } + fn next_if(&self, expected: Option) -> Result<(u64, CancellationToken)> { + let mut old = self.cancel.lock().unwrap(); + let mut state = self.state.lock().unwrap(); + if expected.is_some_and(|expected| expected != state.generation) { + return Err(BleError::Cancelled); + } + old.cancel(); + *old = CancellationToken::new(); + state.generation += 1; + Ok((state.generation, old.clone())) + } +} +struct Session { + generation: u64, + peripheral: Peripheral, + command: Characteristic, + notify: Characteristic, + cancel: CancellationToken, + worker: JoinHandle<()>, + writes: Arc>, + replies: broadcast::Sender>, +} +/// Keep this shared handle for the application's lifetime. Call disconnect on quit. +pub struct BleClient { + adapters: Vec, + shared: Arc, + session: Mutex>, + devices: StdMutex>, +} +impl Drop for BleClient { + fn drop(&mut self) { + self.shared.cancel.lock().unwrap().cancel(); + } +} + +const OP_TIMEOUT: Duration = Duration::from_secs(10); +async fn operation( + cancel: &CancellationToken, + label: &'static str, + f: impl Future>, +) -> Result { + tokio::select! { biased; _=cancel.cancelled()=>Err(BleError::Cancelled), r=timeout(OP_TIMEOUT,f)=>r.map_err(|_|BleError::Timeout(label))?.map_err(|e| { + let detail = e.to_string(); + if detail.to_ascii_uppercase().contains("800704C7") { + BleError::Native(format!("{label}: Windows 取消了蓝牙操作(0x800704C7),不一定是手动取消。请确认蓝牙已开启并唤醒键盘。")) + } else { BleError::Native(format!("{label}: {detail}")) } + }) } +} +fn characteristic_uuid(short: u16) -> Uuid { + Uuid::from_u128(((short as u128) << 96) | 0x0000_1000_8000_0080_5f9b_34fb) +} +fn candidate_name(name: &str) -> bool { + name.trim().to_ascii_lowercase().starts_with("ahakey") +} +async fn detach(session: Session) { + session.cancel.cancel(); + let mut worker = session.worker; + if timeout(Duration::from_secs(2), &mut worker).await.is_err() { + worker.abort(); + let _ = worker.await; + } + let _ = timeout( + Duration::from_secs(3), + session.peripheral.unsubscribe(&session.notify), + ) + .await; + let _ = timeout(Duration::from_secs(3), session.peripheral.disconnect()).await; +} + +impl BleClient { + pub async fn new() -> Result> { + let manager = timeout(OP_TIMEOUT, Manager::new()) + .await + .map_err(|_| BleError::Timeout("create adapter manager"))??; + let adapters = timeout(OP_TIMEOUT, manager.adapters()) + .await + .map_err(|_| BleError::Timeout("list adapters"))??; + if adapters.is_empty() { + return Err(BleError::Native("No Bluetooth adapter is available".into())); + } + let (events, _) = broadcast::channel(64); + Ok(Arc::new(Self { + adapters, + shared: Arc::new(Shared { + state: StdMutex::new(BleSnapshot { + generation: 0, + phase: ConnectionPhase::Disconnected, + device: None, + status: None, + error: None, + }), + cancel: StdMutex::new(CancellationToken::new()), + events, + }), + session: Mutex::new(None), + devices: StdMutex::new(HashMap::new()), + })) + } + pub fn subscribe(&self) -> broadcast::Receiver { + self.shared.events.subscribe() + } + pub fn status(&self) -> BleSnapshot { + self.shared.state.lock().unwrap().clone() + } + + /// Bounded discovery only. Advertising a matching name is not readiness proof. + pub async fn scan(&self, duration: Duration) -> Result> { + let _gate = self.session.lock().await; + let cancel = { + let mut token = self.shared.cancel.lock().unwrap(); + if token.is_cancelled() { + *token = CancellationToken::new(); + } + token.clone() + }; + let duration = duration.clamp(Duration::from_millis(100), Duration::from_secs(15)); + let mut discovered: Vec = Vec::new(); + #[cfg(windows)] + for peripheral in operation( + &cancel, + "list registered AhaKey devices", + native_windows::registered(), + ) + .await? + { + let properties = peripheral.properties().await?.unwrap_or_default(); + let id = peripheral.id(); + discovered.push(DeviceInfo { + id: id.clone(), + name: properties.local_name.unwrap_or_default(), + rssi: None, + is_candidate: true, + }); + self.devices.lock().unwrap().insert(id, peripheral); + } + #[cfg(not(windows))] + for adapter in &self.adapters { + // Match the Windows "registered devices" semantics: peripherals that are + // already paired or connected at the system level stop advertising, so + // merge the backend's known-device source into bounded discovery. + let known = operation( + &cancel, + "retrieve known peripherals", + adapter.retrieve_peripherals(RetrievePeripheralsOptions { + identifiers: None, + services: Some(vec![ + characteristic_uuid(0x7340), + characteristic_uuid(0x1812), + ]), + }), + ) + .await; + let known = match known { + Ok(known) => known, + Err(BleError::Cancelled) => return Err(BleError::Cancelled), + Err(_) => continue, // backend without a retrieval source: advertise-only + }; + for peripheral in known { + let Some(properties) = + operation(&cancel, "read known peripheral", peripheral.properties()).await? + else { + continue; + }; + let id = peripheral.id().to_string(); + let name = properties + .local_name + .unwrap_or_else(|| "Unnamed Bluetooth device".into()); + let is_candidate = candidate_name(&name) + || properties + .services + .iter() + .any(|u| *u == characteristic_uuid(0x7340)); + if !is_candidate || discovered.iter().any(|d| d.id == id) { + continue; + } + discovered.push(DeviceInfo { + id: id.clone(), + name, + rssi: properties.rssi, + is_candidate, + }); + self.devices.lock().unwrap().insert(id, peripheral); + } + } + for adapter in &self.adapters { + operation( + &cancel, + "start scan", + adapter.start_scan(ScanFilter::default()), + ) + .await?; + let cancelled = tokio::select! {_=cancel.cancelled()=>true,_=sleep(duration)=>false}; + let stop = timeout(OP_TIMEOUT, adapter.stop_scan()).await; + if cancelled { + return Err(BleError::Cancelled); + } + stop.map_err(|_| BleError::Timeout("stop scan"))??; + for peripheral in operation(&cancel, "list peripherals", adapter.peripherals()).await? { + let Some(properties) = + operation(&cancel, "read advertisement", peripheral.properties()).await? + else { + continue; + }; + let id = peripheral.id().to_string(); + let name = properties + .local_name + .unwrap_or_else(|| "Unnamed Bluetooth device".into()); + let is_candidate = candidate_name(&name) + || properties + .services + .iter() + .any(|u| *u == characteristic_uuid(0x7340)); + if !is_candidate { + continue; + } + if discovered.iter().any(|d| d.id == id) { + continue; + } + #[cfg(windows)] + let peripheral = Peripheral::new(properties.address, name.clone(), properties.rssi); + discovered.push(DeviceInfo { + id: id.clone(), + name, + rssi: properties.rssi, + is_candidate, + }); + self.devices.lock().unwrap().insert(id, peripheral); + } + } + discovered.sort_by(|a, b| { + b.is_candidate + .cmp(&a.is_candidate) + .then_with(|| a.name.cmp(&b.name)) + }); + let _ = self + .shared + .events + .send(BleEvent::Devices(discovered.clone())); + Ok(discovered) + } + + pub async fn connect(&self, id: &str) -> Result<()> { + self.connect_if(id, None).await + } + async fn connect_if(&self, id: &str, expected: Option) -> Result<()> { + let (generation, cancel) = self.shared.next_if(expected)?; + let mut session = self.session.lock().await; + if cancel.is_cancelled() { + return Err(BleError::Cancelled); + } + if let Some(old) = session.take() { + detach(old).await; + } + self.shared.publish(generation, |s| { + s.phase = ConnectionPhase::Connecting; + s.device = None; + s.status = None; + s.error = None; + }); + let peripheral = self.devices.lock().unwrap().get(id).cloned(); + let Some(peripheral) = peripheral else { + let error = BleError::Invalid( + "Saved device not discovered; scan with the keyboard awake first".into(), + ); + self.shared.publish(generation, |s| { + s.phase = ConnectionPhase::Error; + s.error = Some(error.to_string()); + }); + return Err(error); + }; + let properties = match operation(&cancel, "read device", peripheral.properties()).await { + Ok(properties) => properties, + Err(error) => { + self.shared.publish(generation, |s| { + s.phase = ConnectionPhase::Error; + s.error = Some(error.to_string()); + }); + return Err(error); + } + }; + let device = DeviceInfo { + id: id.into(), + name: properties + .as_ref() + .and_then(|p| p.local_name.clone()) + .unwrap_or_default(), + rssi: properties.and_then(|p| p.rssi), + is_candidate: true, + }; + self.shared.publish(generation, |s| { + s.phase = ConnectionPhase::Connecting; + s.device = Some(device); + s.status = None; + s.error = None; + }); + let result = self.attach(peripheral.clone(), generation, &cancel).await; + match result { + Ok(ready) => { + *session = Some(ready); + Ok(()) + } + Err(error) => { + cancel.cancel(); + let _ = timeout(Duration::from_secs(3), peripheral.disconnect()).await; + self.shared.publish(generation, |s| { + s.phase = ConnectionPhase::Error; + s.status = None; + s.error = Some(error.to_string()); + }); + Err(error) + } + } + } + + async fn attach( + &self, + peripheral: Peripheral, + generation: u64, + cancel: &CancellationToken, + ) -> Result { + operation(cancel, "connect", peripheral.connect()).await?; + operation(cancel, "discover services", peripheral.discover_services()).await?; + let chars = peripheral.characteristics(); + let find = |short| { + chars + .iter() + .find(|c| c.uuid == characteristic_uuid(short)) + .cloned() + .ok_or_else(|| { + BleError::Invalid(format!( + "Required AhaKey characteristic {short:04x} is missing" + )) + }) + }; + let _data = find(0x7341)?; + let command = find(0x7343)?; + let notify = find(0x7344)?; + if !command.properties.contains(CharPropFlags::WRITE) { + return Err(BleError::Invalid( + "7343 does not support acknowledged writes".into(), + )); + } + if !notify + .properties + .intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE) + { + return Err(BleError::Invalid( + "7344 does not support notifications".into(), + )); + } + let mut stream = operation( + cancel, + "create notification stream", + peripheral.notifications(), + ) + .await?; + operation( + cancel, + "enable notifications", + peripheral.subscribe(¬ify), + ) + .await?; + operation( + cancel, + "query status", + peripheral.write(&command, &protocol::QUERY_STATUS, WriteType::WithResponse), + ) + .await?; + let first = tokio::select! {biased;_=cancel.cancelled()=>Err(BleError::Cancelled), r=timeout(OP_TIMEOUT,async{ + while let Some(n)=stream.next().await{if n.uuid==notify.uuid{if let Some(s)=protocol::parse_status(&n.value){return Ok(s)}}} + Err(BleError::Native("Notification stream closed before status response".into())) + })=>r.map_err(|_|BleError::Timeout("await actual device status"))?}?; + self.shared.publish(generation, |s| { + s.phase = ConnectionPhase::Ready; + s.status = Some(first); + s.error = None; + }); + let shared = self.shared.clone(); + let worker_cancel = cancel.clone(); + let worker_device = peripheral.clone(); + let notify_id = notify.uuid; + let writes = Arc::new(Mutex::new(())); + let worker_writes = writes.clone(); + let worker_command = command.clone(); + let (replies, _) = broadcast::channel(32); + let worker_replies = replies.clone(); + let worker = tokio::spawn(async move { + let mut check = tokio::time::interval(Duration::from_secs(3)); + let mut polls = 0; + let mut last_status = tokio::time::Instant::now(); + let reason = loop { + tokio::select! {biased; + _=worker_cancel.cancelled()=>break None, + n=stream.next()=>match n{Some(n)=>{if n.uuid==notify_id{let _=worker_replies.send(n.value.clone());if let Some(status)=protocol::parse_status(&n.value){last_status=tokio::time::Instant::now();shared.publish(generation,|s|s.status=Some(status));}}},None=>break Some("Device notification stream closed".to_owned())}, + _=check.tick()=>{ + if last_status.elapsed()>Duration::from_secs(45){break Some("Device stopped responding to status queries".into());} + match operation(&worker_cancel,"connection check",worker_device.is_connected()).await{Ok(true)=>{},Ok(false)=>break Some("Device disconnected".into()),Err(BleError::Cancelled)=>break None,Err(e)=>break Some(e.to_string())} + polls+=1;if polls%5==0 { + // try_lock avoids blocking notification consumption while a profile batch is writing. + if let Ok(_guard)=worker_writes.try_lock(){ + match operation(&worker_cancel,"refresh status",worker_device.write(&worker_command,&protocol::QUERY_STATUS,WriteType::WithResponse)).await{Ok(())=>{},Err(BleError::Cancelled)=>break None,Err(e)=>break Some(e.to_string())} + } + } + } + } + }; + if let Some(reason) = reason { + worker_cancel.cancel(); + shared.publish(generation, |s| { + s.phase = ConnectionPhase::Error; + s.status = None; + s.error = Some(reason) + }); + } + }); + Ok(Session { + generation, + peripheral, + command, + notify, + cancel: cancel.clone(), + worker, + writes, + replies, + }) + } + + /// Invalidate in-flight work before waiting for the application's connection gate. + /// A stale attempt must not connect after a manual disconnect or device switch. + pub fn cancel_pending(&self) { + self.shared.next(); + } + + /// One bounded attempt for the application's backoff supervisor. The generation + /// is captured with user intent so a later disconnect also cancels queued work. + pub async fn reconnect_once_if(&self, id: &str, generation: u64) -> Result<()> { + if self.status().generation != generation { + return Err(BleError::Cancelled); + } + self.scan(Duration::from_secs(3)).await?; + self.connect_if(id, Some(generation)).await + } + + /// Saved IDs are local OS identities. At most three attempts, with fresh scan. + pub async fn reconnect(&self, id: &str) -> Result<()> { + let mut generation = self.status().generation; + self.scan(Duration::from_secs(3)).await?; + let mut last = BleError::NotConnected; + for attempt in 0..3 { + match self.connect_if(id, Some(generation)).await { + Ok(()) => return Ok(()), + Err(BleError::Cancelled) => return Err(BleError::Cancelled), + Err(e) => last = e, + } + // A failed attach cancels its worker token; generation is the external cancellation guard. + generation += 1; + if attempt < 2 { + sleep(Duration::from_millis(400 * (attempt + 1))).await; + } + if self.status().generation != generation { + return Err(BleError::Cancelled); + } + } + Err(last) + } + pub async fn disconnect(&self) -> Result<()> { + let (generation, _) = self.shared.next(); + let mut session = self.session.lock().await; + if let Some(old) = session.take() { + detach(old).await; + } + self.shared.publish(generation, |s| { + s.phase = ConnectionPhase::Disconnected; + s.device = None; + s.status = None; + s.error = None; + }); + Ok(()) + } + async fn write_batch(&self, frames: Vec>) -> Result<()> { + let session = self.session.lock().await; + let s = session.as_ref().ok_or(BleError::NotConnected)?; + if self.status().phase != ConnectionPhase::Ready { + return Err(BleError::NotConnected); + } + let _write_guard = s.writes.lock().await; + for frame in frames { + let result = operation( + &s.cancel, + "write command", + s.peripheral + .write(&s.command, &frame, WriteType::WithResponse), + ) + .await; + // Rejected config on an inactive host must not tear down its link. + result?; + tokio::select! {biased;_=s.cancel.cancelled()=>return Err(BleError::Cancelled),_=sleep(Duration::from_millis(50))=>{}} + } + Ok(()) + } + pub async fn query_status(&self) -> Result<()> { + self.write_batch(vec![protocol::QUERY_STATUS.to_vec()]) + .await + } + async fn routing_request( + &self, + config: Option<&routing::Config>, + generation: u64, + ) -> Result { + use std::sync::atomic::{AtomicU16, Ordering}; + static NEXT_REQUEST: AtomicU16 = AtomicU16::new(1); + let session = self.session.lock().await; + let s = session.as_ref().ok_or(BleError::NotConnected)?; + if generation != s.generation + || self.status().phase != ConnectionPhase::Ready + || self.status().generation != s.generation + { + return Err(BleError::NotConnected); + } + let _write_guard = s.writes.lock().await; + let request = config.map(|_| NEXT_REQUEST.fetch_add(1, Ordering::Relaxed) & 0x3fff); + let frame = match (config, request) { + (Some(c), Some(id)) => c.frame(id)?, + _ => routing::QUERY.to_vec(), + }; + let mut replies = s.replies.subscribe(); + operation( + &s.cancel, + "routing command", + s.peripheral + .write(&s.command, &frame, WriteType::WithResponse), + ) + .await?; + let response = async { + loop { + let bytes = replies + .recv() + .await + .map_err(|e| BleError::Native(e.to_string()))?; + if let Some(reply) = routing::parse(&bytes) { + if reply.request == request { + return routing::confirmed(reply, request, config); + } + } + } + }; + tokio::select! { biased; + _=s.cancel.cancelled()=>Err(BleError::Cancelled), + result=timeout(Duration::from_secs(4),response)=>result.map_err(|_|BleError::Timeout("routing readback unavailable; firmware may not support it, no saved state confirmed"))?, + } + } + pub async fn read_routing(&self) -> Result { + self.routing_request(None, self.status().generation).await + } + async fn pairing_request( + &self, + frame: &[u8], + request: Option, + generation: u64, + ) -> Result> { + let session = self.session.lock().await; + let s = session.as_ref().ok_or(BleError::NotConnected)?; + if s.generation != generation + || self.status().generation != generation + || self.status().phase != ConnectionPhase::Ready + { + return Err(BleError::NotConnected); + } + let _guard = s.writes.lock().await; + let mut replies = s.replies.subscribe(); + operation( + &s.cancel, + "pairing control", + s.peripheral + .write(&s.command, frame, WriteType::WithResponse), + ) + .await?; + let response = async { + loop { + let b = replies + .recv() + .await + .map_err(|e| BleError::Native(e.to_string()))?; + if b.starts_with(&[0xaa, 0xbb, frame[2]]) + && request.is_none_or(|id| { + b.len() == 6 + || (b.get(4) == Some(&((id & 127) as u8)) + && b.get(5) == Some(&(((id >> 7) & 127) as u8))) + }) + { + return Ok(b); + } + } + }; + tokio::select! {biased;_=s.cancel.cancelled()=>Err(BleError::Cancelled),r=timeout(Duration::from_secs(4),response)=>r.map_err(|_|BleError::Timeout("pairing control reply"))?} + } + pub async fn read_host_info(&self) -> Result<[host_info::HostInfo; 2]> { + let generation = self.status().generation; + let mut hosts = Vec::new(); + for slot in 0..2 { + let mut parts: [Vec; 3] = Default::default(); + for part in 0..3 { + parts[part as usize] = self + .pairing_request(&host_info::query(slot, part)?, None, generation) + .await?; + } + hosts.push(host_info::decode(slot, &parts)?); + } + Ok([hosts.remove(0), hosts.remove(0)]) + } + pub async fn register_host(&self, name: &str, system: u8) -> Result<()> { + let generation = self.status().generation; + // Read-only capability probe before any registration on older firmware. + let probe = self + .pairing_request(&host_info::query(0, 0)?, None, generation) + .await?; + if probe.len() != 20 || probe[..4] != [0xaa, 0xbb, 0xa8, 0] { + return Err(BleError::Invalid("固件不支持名称上报".into())); + } + let id = routing::next_management_request(); + let frames = host_info::registration(name, system, id)?; + let mut slot = None; + for (part, frame) in frames.iter().enumerate() { + let b = self.pairing_request(frame, Some(id), generation).await?; + let actual = host_info::confirm(&b, id, part as u8)?; + if slot.is_some_and(|s| s != actual) { + return Err(BleError::Invalid("上报期间槽位已互换,请重新上报".into())); + } + slot = Some(actual); + } + Ok(()) + } + pub async fn read_pairing(&self) -> Result { + let b = self + .pairing_request(&routing::DETAILS_QUERY, None, self.status().generation) + .await?; + routing::parse_details(&b) + } + pub async fn read_policy(&self) -> Result { + let b = self + .pairing_request(&reset::QUERY, None, self.status().generation) + .await?; + reset::parse(&b) + } + pub async fn manage_pairing(&self, action: routing::ManagementAction) -> Result<()> { + let generation = self.status().generation; + let capability = self + .pairing_request(&routing::DETAILS_QUERY, None, generation) + .await?; + routing::parse_details(&capability)?; + let id = routing::next_management_request(); + let frame = action.frame(id); + let ack = self.pairing_request(&frame, Some(id), generation).await?; + routing::management_confirmed(&ack, action, id) + } + pub async fn set_routing(&self, config: &routing::Config) -> Result { + config.validate()?; + // A fresh versioned readback is the capability gate. No legacy version guessing. + let generation = self.status().generation; + self.routing_request(None, generation).await?; + self.routing_request(Some(config), generation).await + } + /// Completion means acknowledged GATT writes, not proof of persistent flash readback. + pub async fn save_profiles( + &self, + profiles: &[ProfileConfig; 4], + active_mode: u8, + brightness: u8, + ) -> Result<()> { + self.write_batch(protocol::profile_frames(profiles, active_mode, brightness)?) + .await + } + pub async fn save_keys(&self, mode: u8, keys: &[KeyConfig; 4]) -> Result<()> { + self.write_batch(protocol::key_frames(mode, keys)?).await + } + pub async fn set_light_effect(&self, effect: u8) -> Result<()> { + self.write_batch(vec![protocol::frame(0x91, &[effect])]) + .await + } + pub async fn set_ide_state(&self, state: u8) -> Result<()> { + if state > 8 { + return Err(BleError::Invalid("IDE state must be 0..8".into())); + } + self.write_batch(vec![protocol::frame(0x90, &[state])]) + .await + } + pub async fn set_work_mode(&self, mode: u8) -> Result<()> { + if mode > 3 { + return Err(BleError::Invalid("mode must be 0..3".into())); + } + self.write_batch(vec![protocol::frame(0x92, &[mode])]).await + } + pub async fn set_light_brightness(&self, brightness: u8) -> Result<()> { + if !(1..=100).contains(&brightness) { + return Err(BleError::Invalid("brightness must be 1..100".into())); + } + self.write_batch(vec![protocol::frame(0x85, &[brightness])]) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn candidate_filter_rejects_unrelated_and_unnamed_devices() { + assert!(candidate_name("AhaKey Test")); + assert!(candidate_name(" ahakey keyboard ")); + assert!(!candidate_name("Unnamed Bluetooth device")); + assert!(!candidate_name("Mahakala speaker")); + } + #[test] + fn bluetooth_base_uuid_uses_network_order() { + assert_eq!( + characteristic_uuid(0x7343).to_string(), + "00007343-0000-1000-8000-00805f9b34fb" + ); + } + #[test] + fn stale_generation_cannot_republish_ready() { + let (events, _) = broadcast::channel(4); + let s = Shared { + state: StdMutex::new(BleSnapshot { + generation: 0, + phase: ConnectionPhase::Disconnected, + device: None, + status: None, + error: None, + }), + cancel: StdMutex::new(CancellationToken::new()), + events, + }; + let (old, cancel) = s.next(); + let (current, _) = s.next(); + assert!(cancel.is_cancelled()); + assert!(matches!(s.next_if(Some(old)), Err(BleError::Cancelled))); + assert_eq!(s.state.lock().unwrap().generation, current); + s.publish(old, |s| s.phase = ConnectionPhase::Ready); + assert_eq!(s.state.lock().unwrap().phase, ConnectionPhase::Disconnected); + s.publish(current, |s| s.phase = ConnectionPhase::Connecting); + assert_eq!(s.state.lock().unwrap().phase, ConnectionPhase::Connecting); + } + #[tokio::test] + async fn cancellation_preempts_native_result() { + let c = CancellationToken::new(); + c.cancel(); + assert!(matches!( + operation(&c, "test", async { Ok::<_, btleplug::Error>(()) }).await, + Err(BleError::Cancelled) + )); + } +} diff --git a/ahakey-desktop/crates/ble/src/native_windows.rs b/ahakey-desktop/crates/ble/src/native_windows.rs new file mode 100644 index 00000000..2ab904ec --- /dev/null +++ b/ahakey-desktop/crates/ble/src/native_windows.rs @@ -0,0 +1,316 @@ +//! Windows can retain an HID/GATT connection while the keyboard stops advertising. +//! Enumerate registered AhaKey devices and own their WinRT GATT handles directly. +use btleplug::{ + api::{ + BDAddr, CharPropFlags, Characteristic, PeripheralProperties, ValueNotification, WriteType, + }, + Error, Result, +}; +use futures::{stream, Stream}; +use std::{ + collections::{BTreeSet, HashMap}, + future::IntoFuture, + pin::Pin, + sync::{Arc, Mutex}, +}; +use tokio::sync::broadcast; +use uuid::Uuid; +use windows::{ + core::Ref, + Devices::{ + Bluetooth::{ + BluetoothCacheMode, BluetoothConnectionStatus, BluetoothLEDevice, + GenericAttributeProfile::*, + }, + Enumeration::DeviceInformation, + }, + Foundation::TypedEventHandler, + Storage::Streams::{DataReader, DataWriter}, +}; + +#[derive(Clone)] +pub struct Peripheral { + shared: Arc, +} +struct Shared { + address: BDAddr, + name: String, + rssi: Option, + state: Mutex, + notifications: broadcast::Sender, +} +#[derive(Default)] +struct State { + device: Option, + services: Vec, + chars: HashMap, + tokens: HashMap, +} +impl Drop for State { + fn drop(&mut self) { + for (uuid, token) in self.tokens.drain() { + if let Some((_, c)) = self.chars.get(&uuid) { + let _ = c.RemoveValueChanged(token); + } + } + self.chars.clear(); + for service in self.services.drain(..) { + let _ = service.Close(); + } + if let Some(device) = self.device.take() { + let _ = device.Close(); + } + } +} +fn status(value: GattCommunicationStatus, operation: &str) -> Result<()> { + if value == GattCommunicationStatus::Success { + Ok(()) + } else { + Err(Error::Other(format!("{operation}: {value:?}").into())) + } +} +impl Peripheral { + pub fn new(address: BDAddr, name: String, rssi: Option) -> Self { + let (notifications, _) = broadcast::channel(64); + Self { + shared: Arc::new(Shared { + address, + name, + rssi, + state: Mutex::new(State::default()), + notifications, + }), + } + } + pub fn id(&self) -> String { + self.shared.address.to_string() + } + pub async fn properties(&self) -> Result> { + Ok(Some(PeripheralProperties { + address: self.shared.address, + local_name: Some(self.shared.name.clone()), + rssi: self.shared.rssi, + ..Default::default() + })) + } + pub async fn connect(&self) -> Result<()> { + let device = BluetoothLEDevice::FromBluetoothAddressAsync(self.shared.address.into())? + .into_future() + .await?; + self.shared.state.lock().unwrap().device = Some(device); + Ok(()) + } + fn device(&self) -> Result { + self.shared + .state + .lock() + .unwrap() + .device + .clone() + .ok_or(Error::NotConnected) + } + pub async fn is_connected(&self) -> Result { + Ok(self.device()?.ConnectionStatus()? == BluetoothConnectionStatus::Connected) + } + pub async fn discover_services(&self) -> Result<()> { + let result = self + .device()? + .GetGattServicesWithCacheModeAsync(BluetoothCacheMode::Uncached)? + .into_future() + .await?; + status(result.Status()?, "discover services")?; + // Store handles before await so cancellation/error cleanup closes every opened service. + let services: Vec<_> = result.Services()?.into_iter().collect(); + self.shared.state.lock().unwrap().services = services.clone(); + for service in services { + let service_uuid = Uuid::from_u128(service.Uuid()?.to_u128()); + if service_uuid != super::characteristic_uuid(0x7340) { + continue; + } + let result = service + .GetCharacteristicsWithCacheModeAsync(BluetoothCacheMode::Uncached)? + .into_future() + .await?; + status(result.Status()?, "discover characteristics")?; + for native in result.Characteristics()? { + let uuid = Uuid::from_u128(native.Uuid()?.to_u128()); + let properties = + CharPropFlags::from_bits_truncate(native.CharacteristicProperties()?.0 as u8); + let descriptor = Characteristic { + uuid, + service_uuid, + properties, + descriptors: BTreeSet::new(), + }; + self.shared + .state + .lock() + .unwrap() + .chars + .insert(uuid, (descriptor, native)); + } + } + Ok(()) + } + pub fn characteristics(&self) -> BTreeSet { + self.shared + .state + .lock() + .unwrap() + .chars + .values() + .map(|(c, _)| c.clone()) + .collect() + } + fn characteristic(&self, c: &Characteristic) -> Result { + self.shared + .state + .lock() + .unwrap() + .chars + .get(&c.uuid) + .map(|(_, c)| c.clone()) + .ok_or(Error::DeviceNotFound) + } + pub async fn notifications( + &self, + ) -> Result + Send>>> { + Ok(Box::pin(stream::unfold( + self.shared.notifications.subscribe(), + |mut rx| async move { + loop { + match rx.recv().await { + Ok(value) => return Some((value, rx)), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => return None, + } + } + }, + ))) + } + pub async fn subscribe(&self, c: &Characteristic) -> Result<()> { + let native = self.characteristic(c)?; + let uuid = c.uuid; + let service_uuid = c.service_uuid; + let sender = self.shared.notifications.clone(); + let token = native.ValueChanged(&TypedEventHandler::new( + move |_: Ref, args: Ref| { + if let Ok(args) = args.ok() { + let buffer = args.CharacteristicValue()?; + if buffer.Length()? > 4096 { + return Ok(()); + } + let reader = DataReader::FromBuffer(&buffer)?; + let mut value = vec![0; reader.UnconsumedBufferLength()? as usize]; + reader.ReadBytes(&mut value)?; + let _ = sender.send(ValueNotification { + uuid, + service_uuid, + value, + }); + } + Ok(()) + }, + ))?; + self.shared + .state + .lock() + .unwrap() + .tokens + .insert(c.uuid, token); + let mode = if c.properties.contains(CharPropFlags::NOTIFY) { + GattClientCharacteristicConfigurationDescriptorValue::Notify + } else { + GattClientCharacteristicConfigurationDescriptorValue::Indicate + }; + let result = native + .WriteClientCharacteristicConfigurationDescriptorAsync(mode)? + .into_future() + .await?; + status(result, "enable notifications") + } + pub async fn unsubscribe(&self, c: &Characteristic) -> Result<()> { + let native = self.characteristic(c)?; + if let Some(token) = self.shared.state.lock().unwrap().tokens.remove(&c.uuid) { + native.RemoveValueChanged(token)?; + } + let result = native + .WriteClientCharacteristicConfigurationDescriptorAsync( + GattClientCharacteristicConfigurationDescriptorValue::None, + )? + .into_future() + .await?; + status(result, "disable notifications") + } + pub async fn write(&self, c: &Characteristic, bytes: &[u8], mode: WriteType) -> Result<()> { + let writer = DataWriter::new()?; + writer.WriteBytes(bytes)?; + let option = match mode { + WriteType::WithResponse => GattWriteOption::WriteWithResponse, + WriteType::WithoutResponse => GattWriteOption::WriteWithoutResponse, + }; + let native = self.characteristic(c)?; + // Request link encryption from Windows before writing protected firmware + // commands. Opening a GATT handle is not evidence of an encrypted link. + native.SetProtectionLevel(GattProtectionLevel::EncryptionRequired)?; + let operation = native.WriteValueWithOptionAsync(&writer.DetachBuffer()?, option)?; + let result = operation.into_future().await.map_err(|error| { + if matches!(error.code().0 as u32, 0x8065000f | 0x80650005) { + Error::Other("蓝牙链路未通过加密认证。请先在 Windows 蓝牙设置中完成 AhaKey 配对;刷写新固件后可能需要移除旧配对再重新添加。USB 配置与输入不依赖这条蓝牙连接。".into()) + } else { + error.into() + } + })?; + status(result, "write command") + } + pub async fn disconnect(&self) -> Result<()> { + *self.shared.state.lock().unwrap() = State::default(); + Ok(()) + } +} + +pub async fn registered() -> Result> { + let selector = BluetoothLEDevice::GetDeviceSelector()?; + let devices = DeviceInformation::FindAllAsyncAqsFilter(&selector)? + .into_future() + .await?; + let mut result = Vec::new(); + let devices: Vec<_> = devices.into_iter().collect(); + for info in devices { + let name = info.Name()?.to_string(); + if !super::candidate_name(&name) { + continue; + } + // Discovery must not open/Close aliases of a device used by an active + // GATT session. The Windows BLE interface ID includes the remote address. + let Some(address) = registered_address(&info.Id()?.to_string()) else { + continue; + }; + result.push(Peripheral::new(address, name, None)); + } + Ok(result) +} + +fn registered_address(id: &str) -> Option { + if !id.starts_with("BluetoothLE#BluetoothLE") { + return None; + } + id.rsplit_once('-')?.1.parse().ok() +} + +#[cfg(test)] +mod tests { + #[test] + fn reads_remote_address_without_opening_device_handles() { + assert_eq!( + super::registered_address("BluetoothLE#BluetoothLE00:11:22:33:44:55-aa:bb:cc:dd:ee:ff") + .unwrap() + .to_string(), + "AA:BB:CC:DD:EE:FF" + ); + assert!( + super::registered_address("BluetoothLE#BluetoothLE00:11:22:33:44:55-invalid").is_none() + ); + assert!(super::registered_address("other-aa:bb:cc:dd:ee:ff").is_none()); + } +} diff --git a/ahakey-desktop/crates/ble/src/protocol.rs b/ahakey-desktop/crates/ble/src/protocol.rs new file mode 100644 index 00000000..73fbeadd --- /dev/null +++ b/ahakey-desktop/crates/ble/src/protocol.rs @@ -0,0 +1,178 @@ +//! AhaKey GATT payloads, identical to the existing Java/Swift firmware protocol. +use crate::{BleError, Result}; +use serde::{Deserialize, Serialize}; + +pub const F17: u8 = 0x6c; +pub const F18: u8 = 0x6d; +pub const QUERY_STATUS: [u8; 5] = [0xaa, 0xbb, 0x00, 0xcc, 0xdd]; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeviceStatus { + pub battery_level: u8, + pub signal: i8, + pub firmware_main: u8, + pub firmware_sub: u8, + pub work_mode: u8, + pub light_mode: u8, + pub switch_state: u8, + pub light_brightness: u8, +} + +pub fn parse_status(bytes: &[u8]) -> Option { + if bytes.len() != 13 || bytes[..3] != [0xaa, 0xbb, 0] || bytes[11..] != [0xcc, 0xdd] { + return None; + } + Some(DeviceStatus { + battery_level: bytes[3], + signal: bytes[4] as i8, + firmware_main: bytes[5], + firmware_sub: bytes[6], + work_mode: bytes[7], + light_mode: bytes[8], + switch_state: bytes[9], + light_brightness: bytes[10], + }) +} + +pub fn frame(command: u8, payload: &[u8]) -> Vec { + let mut out = vec![0xaa, 0xbb, command]; + out.extend_from_slice(payload); + out.extend_from_slice(&[0xcc, 0xdd]); + out +} + +/// Raw HID usage list (modifiers are usages E0..E7, not a modifier bitmap). +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyConfig { + pub hid_codes: Vec, + pub description: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileConfig { + pub keys: [KeyConfig; 4], + pub light_effects: Vec, +} + +/// Only one mode's four keys; do not overwrite other modes or lighting. +pub fn key_frames(mode: u8, keys: &[KeyConfig; 4]) -> Result>> { + if mode > 3 || keys.iter().any(|k| k.hid_codes.len() > 9) { + return Err(BleError::Invalid("Invalid mode or HID sequence".into())); + } + let mut frames = vec![]; + for (index, key) in keys.iter().enumerate() { + let mut payload = vec![0x73, mode, index as u8]; + payload.extend_from_slice(&key.hid_codes); + frames.push(frame(0x73, &payload)); + let mut label = vec![0x75, mode, index as u8]; + label.extend( + key.description + .bytes() + .filter(|b| (0x20..=0x7e).contains(b)) + .take(20), + ); + frames.push(frame(0x73, &label)); + } + frames.push(frame(0x04, &[])); + Ok(frames) +} + +/// Device key indices are 0..3. A batch is fully validated before any writes. +pub fn profile_frames( + profiles: &[ProfileConfig; 4], + active_mode: u8, + brightness: u8, +) -> Result>> { + if active_mode > 3 || !(1..=100).contains(&brightness) { + return Err(BleError::Invalid( + "mode must be 0..3; brightness must be 1..100".into(), + )); + } + let mut out = Vec::new(); + for (mode, profile) in profiles.iter().enumerate() { + if profile.light_effects.len() != 9 { + return Err(BleError::Invalid( + "exactly nine AI light effects are required (firmware states 0..8)".into(), + )); + } + for (key, config) in profile.keys.iter().enumerate() { + if config.hid_codes.len() > 9 { + return Err(BleError::Invalid("at most nine HID usages per key".into())); + } + let mut payload = vec![0x73, mode as u8, key as u8]; + payload.extend_from_slice(&config.hid_codes); + out.push(frame(0x73, &payload)); + let mut payload = vec![0x75, mode as u8, key as u8]; + payload.extend( + config + .description + .bytes() + .filter(|b| (0x20..=0x7e).contains(b)) + .take(20), + ); + out.push(frame(0x73, &payload)); + } + let mut payload = vec![mode as u8]; + payload.extend_from_slice(&profile.light_effects); + out.push(frame(0x84, &payload)); + } + out.push(frame(0x85, &[brightness])); + out.push(frame(0x92, &[active_mode])); + out.push(frame(0x04, &[])); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn four_key_write_does_not_touch_other_modes_or_lights() { + let keys = profiles()[0].keys.clone(); + let frames = key_frames(3, &keys).unwrap(); + assert_eq!(frames.len(), 9); + for frame in &frames[..8] { + assert_eq!(frame[2], 0x73); + assert_eq!(frame[4], 3); + } + assert_eq!(frames[8], vec![0xaa, 0xbb, 0x04, 0xcc, 0xdd]); + assert!(key_frames(4, &keys).is_err()); + } + #[test] + fn known_status_preserves_signed_rssi() { + let s = parse_status(&[0xaa, 0xbb, 0, 65, 0xc4, 1, 3, 2, 4, 1, 80, 0xcc, 0xdd]).unwrap(); + assert_eq!( + (s.battery_level, s.signal, s.work_mode, s.light_brightness), + (65, -60, 2, 80) + ); + assert!(parse_status(&[0xaa, 0xbb, 0x90, 1, 0xcc, 0xdd]).is_none()); + assert!(parse_status(&[0; 13]).is_none()); + } + fn profiles() -> [ProfileConfig; 4] { + std::array::from_fn(|_| ProfileConfig { + keys: std::array::from_fn(|_| KeyConfig { + hid_codes: vec![F18], + description: "Voice".into(), + }), + light_effects: vec![0, 1, 2, 3, 4, 5, 6, 7, 8], + }) + } + #[test] + fn known_profile_bytes_and_save_order() { + let f = profile_frames(&profiles(), 2, 70).unwrap(); + assert_eq!(f.len(), 39); + assert_eq!(f[0], vec![0xaa, 0xbb, 0x73, 0x73, 0, 0, 0x6d, 0xcc, 0xdd]); + assert_eq!(f[36], vec![0xaa, 0xbb, 0x85, 70, 0xcc, 0xdd]); + assert_eq!(f[37], vec![0xaa, 0xbb, 0x92, 2, 0xcc, 0xdd]); + assert_eq!(f[38], vec![0xaa, 0xbb, 4, 0xcc, 0xdd]); + } + #[test] + fn validate_entire_batch() { + let mut p = profiles(); + p[3].keys[3].hid_codes = vec![1; 10]; + assert!(profile_frames(&p, 0, 50).is_err()); + assert!(profile_frames(&profiles(), 4, 50).is_err()); + } +} diff --git a/ahakey-desktop/crates/ble/src/reset.rs b/ahakey-desktop/crates/ble/src/reset.rs new file mode 100644 index 00000000..3299b0c5 --- /dev/null +++ b/ahakey-desktop/crates/ble/src/reset.rs @@ -0,0 +1,102 @@ +//! Read-only policy/status and explicitly USB-only reset wire contract. +use crate::{protocol::frame, BleError, Result}; +use serde::Serialize; +pub const QUERY: [u8; 5] = [0xaa, 0xbb, 0xab, 0xcc, 0xdd]; +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Policy { + pub fixed_upper_usb: bool, + pub state: u8, + pub target: Option, + pub error: u8, + pub request: u16, + pub paired: u8, +} +pub fn parse(b: &[u8]) -> Result { + if b.len() != 14 + || b[..6] != [0xaa, 0xbb, 0xab, 0, 1, 1] + || b[6] > 3 + || ![0, 1, 2, 255].contains(&b[7]) + || b[9] > 127 + || b[10] > 127 + || b[11] > 3 + || b[12..] != [0xcc, 0xdd] + { + return Err(BleError::Invalid( + "固件不支持固定上端 USB / 安全重置,请升级至 0.1.9+".into(), + )); + } + Ok(Policy { + fixed_upper_usb: true, + state: b[6], + target: (b[7] < 3).then_some(b[7]), + error: b[8], + request: u16::from(b[9]) | (u16::from(b[10]) << 7), + paired: b[11], + }) +} +pub fn request(target: u8, id: u16) -> Result> { + if target > 2 || id > 0x3fff { + return Err(BleError::Invalid("无效重置目标".into())); + } + Ok(frame(0xac, &[target, (id & 127) as u8, (id >> 7) as u8])) +} +pub fn accepted(b: &[u8], target: u8, id: u16) -> Result<()> { + if b.len() != 9 + || b[..3] != [0xaa, 0xbb, 0xac] + || b[4] != target + || b[5] != (id & 127) as u8 + || b[6] != (id >> 7) as u8 + || b[7..] != [0xcc, 0xdd] + { + return Err(BleError::Invalid( + "未取得匹配的重置确认;请读取状态,勿重复提交".into(), + )); + } + if b[3] != 0 { + return Err(BleError::Invalid(format!( + "设备拒绝重置({});请松开按键并确认绑定记录可识别", + b[3] + ))); + } + Ok(()) +} +pub fn completed(p: &Policy, target: u8, id: u16) -> Result { + if p.target != Some(target) || p.request != id { + return Err(BleError::Invalid("重置状态不匹配;勿自动重复操作".into())); + } + if p.state == 3 { + return Err(BleError::Invalid(format!( + "重置未确认完成(错误 {});可能已断开目标,请先重新读取", + p.error + ))); + } + Ok(p.state == 2) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_exact_capability_and_reset_confirmation_are_accepted() { + let b = [0xaa, 0xbb, 0xab, 0, 1, 1, 2, 1, 0, 12, 0, 1, 0xcc, 0xdd]; + let p = parse(&b).unwrap(); + assert!(completed(&p, 1, 12).unwrap()); + assert!(completed(&p, 0, 12).is_err()); + for len in 0..14 { + assert!(parse(&b[..len]).is_err()); + } + for index in [3, 4, 5, 6, 7, 9, 10, 11, 13] { + let mut bad = b; + bad[index] = 254; + assert!(parse(&bad).is_err()); + } + assert!(accepted(&[0xaa, 0xbb, 0xac, 0, 1, 12, 0, 0xcc, 0xdd], 1, 12).is_ok()); + assert!(accepted(&[0xaa, 0xbb, 0xac, 1, 1, 12, 0, 0xcc, 0xdd], 1, 12).is_err()); + assert!(request(3, 1).is_err()); + let mut pending = p.clone(); + pending.state = 1; + assert!(!completed(&pending, 1, 12).unwrap()); + pending.state = 3; + assert!(completed(&pending, 1, 12).is_err()); + } +} diff --git a/ahakey-desktop/crates/ble/src/routing.rs b/ahakey-desktop/crates/ble/src/routing.rs new file mode 100644 index 00000000..0f0badf5 --- /dev/null +++ b/ahakey-desktop/crates/ble/src/routing.rs @@ -0,0 +1,350 @@ +//! Device-owned three-link routing. Never infer support from the legacy "1.0" status. +use crate::{protocol::frame, BleError, Result}; +use serde::{Deserialize, Serialize}; + +pub const QUERY: [u8; 5] = [0xaa, 0xbb, 0xa2, 0xcc, 0xdd]; +pub const DETAILS_QUERY: [u8; 5] = [0xaa, 0xbb, 0xa5, 0xcc, 0xdd]; +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ManagementAction { + SwapSlots, + Retry, +} +impl ManagementAction { + pub fn command(self) -> u8 { + match self { + Self::SwapSlots => 0xa6, + Self::Retry => 0xa7, + } + } + pub fn frame(self, request: u16) -> Vec { + frame( + self.command(), + &[(request & 127) as u8, ((request >> 7) & 127) as u8], + ) + } +} +pub fn next_management_request() -> u16 { + use std::sync::{ + atomic::{AtomicU16, Ordering}, + Once, + }; + static INIT: Once = Once::new(); + static NEXT: AtomicU16 = AtomicU16::new(0); + INIT.call_once(|| { + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() + ^ std::process::id(); + NEXT.store(seed as u16, Ordering::Relaxed); + }); + NEXT.fetch_add(1, Ordering::Relaxed) & 0x3fff +} +pub fn management_confirmed(bytes: &[u8], action: ManagementAction, request: u16) -> Result<()> { + if bytes.len() != 8 + || bytes[..3] != [0xaa, 0xbb, action.command()] + || bytes[6..] != [0xcc, 0xdd] + || bytes[4] != ((request & 127) as u8) + || bytes[5] != (((request >> 7) & 127) as u8) + { + return Err(BleError::Invalid( + "未取得匹配的设备确认,请重新读取;不要自动重复操作".into(), + )); + } + if bytes[3] != 0 { + return Err(BleError::Invalid(format!( + "设备拒绝操作({}),请松开按键并等待配对结束后重试", + bytes[3] + ))); + } + Ok(()) +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Details { + pub paired: u8, + pub connected: u8, + pub ready: u8, + pub effective_up: u8, + pub effective_down: u8, + pub wired: bool, + pub radio_state: u8, + pub pairing_slot: Option, + pub remaining_seconds: u8, + pub bond_count: u8, + pub last_reason: u8, + pub last_hci: u8, + pub pending: bool, + pub selected: u8, + pub raw_links: u8, +} +pub fn parse_details(b: &[u8]) -> Result
{ + if b.len() != 20 || b[..5] != [0xaa, 0xbb, 0xa5, 0, 1] || b[18..] != [0xcc, 0xdd] { + return Err(BleError::Invalid( + "固件未提供有效的配对详情,需要支持此功能的固件".into(), + )); + } + if b[5] > 3 + || b[6] > 7 + || b[7] > 7 + || b[7] & !b[6] != 0 + || b[8] > 2 + || b[9] > 2 + || b[8] == b[9] + || b[10] > 1 + || b[11] > 3 + || ![0, 1, 255].contains(&b[12]) + || b[13] > 60 + || b[14] > 2 + || b[17] & 0xe0 != 0 + || (b[17] >> 1) & 3 > 2 + || (b[17] >> 3) & 3 > 2 + { + return Err(BleError::Invalid("设备配对详情不一致,请重新读取".into())); + } + Ok(Details { + paired: b[5], + connected: b[6], + ready: b[7], + effective_up: b[8], + effective_down: b[9], + wired: b[10] != 0, + radio_state: b[11], + pairing_slot: (b[12] < 2).then_some(b[12]), + remaining_seconds: b[13], + bond_count: b[14], + last_reason: b[15], + last_hci: b[16], + pending: b[17] & 1 != 0, + selected: (b[17] >> 1) & 3, + raw_links: (b[17] >> 3) & 3, + }) +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Config { + pub mode: u8, + pub up: u8, + pub down: u8, +} +impl Config { + pub fn validate(&self) -> Result<()> { + if self.mode > 1 || self.up > 2 || self.down > 2 || self.up == self.down { + return Err(BleError::Invalid( + "Choose two different targets from BLE A, BLE B and USB".into(), + )); + } + Ok(()) + } + pub fn frame(&self, request: u16) -> Result> { + self.validate()?; + // Seven-bit bytes cannot contain the legacy CC DD frame terminator. + Ok(frame( + 0xa3, + &[ + 1, + self.mode, + self.up, + self.down, + (request & 127) as u8, + ((request >> 7) & 127) as u8, + ], + )) + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Status { + pub config: Config, + pub selected: u8, + pub connected: u8, + pub ready: u8, + pub lever: u8, + pub routing_error: u8, +} +#[derive(Debug)] +pub struct Reply { + pub result: u8, + pub request: Option, + pub status: Status, +} +pub fn parse(bytes: &[u8]) -> Option { + let offset = match (bytes.len(), bytes.get(2)) { + (15, Some(0xa2)) => 4, + (17, Some(0xa3)) => 6, + _ => return None, + }; + if bytes[..2] != [0xaa, 0xbb] || bytes[bytes.len() - 2..] != [0xcc, 0xdd] { + return None; + } + let p = &bytes[offset..offset + 9]; + let config = Config { + mode: p[1], + up: p[2], + down: p[3], + }; + if p[0] != 1 + || config.validate().is_err() + || p[4] > 2 + || p[5] > 7 + || p[6] > 7 + || p[6] & !p[5] != 0 + || p[7] > 2 + { + return None; + } + let request = if offset == 6 { + if bytes[4] > 127 || bytes[5] > 127 { + return None; + } + Some(u16::from(bytes[4]) | (u16::from(bytes[5]) << 7)) + } else { + None + }; + Some(Reply { + result: bytes[3], + request, + status: Status { + config, + selected: p[4], + connected: p[5], + ready: p[6], + lever: p[7], + routing_error: p[8], + }, + }) +} +pub fn confirmed(reply: Reply, request: Option, expected: Option<&Config>) -> Result { + if reply.request != request || expected.is_some_and(|v| v != &reply.status.config) { + return Err(BleError::Invalid( + "Routing reply does not match this request; read device settings again".into(), + )); + } + if reply.result != 0 { + return Err(BleError::Invalid(format!( + "Device rejected routing settings (code {}); not confirmed saved", + reply.result + ))); + } + Ok(reply.status) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn details_distinguish_pairing_from_live_links_and_validate_bounds() { + let b = [ + 0xaa, 0xbb, 0xa5, 0, 1, 2, 6, 6, 2, 1, 1, 3, 0xff, 0, 2, 6, 8, 4, 0xcc, 0xdd, + ]; + let d = parse_details(&b).unwrap(); + assert_eq!((d.paired, d.connected, d.ready, d.selected), (2, 6, 6, 2)); + assert_eq!(d.pairing_slot, None); + assert!(!d.pending); + for i in 0..b.len() { + assert!(parse_details(&b[..i]).is_err()); + } + for (i, v) in [ + (3, 1), + (4, 2), + (5, 4), + (6, 8), + (7, 8), + (8, 3), + (9, 2), + (10, 2), + (11, 4), + (12, 2), + (13, 61), + (14, 3), + (17, 6), + (19, 0), + ] { + let mut bad = b; + bad[i] = v; + assert!(parse_details(&bad).is_err(), "accepted invalid {i}"); + } + } + #[test] + fn management_requires_exact_action_nonce_and_success_without_retry() { + for action in [ManagementAction::SwapSlots, ManagementAction::Retry] { + for id in 0..16384 { + let f = action.frame(id); + assert_eq!(f.len(), 7); + assert!(!f[2..5].windows(2).any(|w| w == [0xcc, 0xdd])); + } + let id = 0x1234; + let mut b = [ + 0xaa, + 0xbb, + action.command(), + 0, + (id & 127) as u8, + ((id >> 7) & 127) as u8, + 0xcc, + 0xdd, + ]; + assert!(management_confirmed(&b, action, id).is_ok()); + assert!(management_confirmed(&b, action, id + 1).is_err()); + b[3] = 1; + assert!(management_confirmed(&b, action, id).is_err()); + } + } + #[test] + fn all_pairs_and_delimiter_safe_requests() { + for up in 0..3 { + for down in 0..3 { + let c = Config { mode: 1, up, down }; + assert_eq!(c.validate().is_ok(), up != down); + } + } + for request in 0..16384 { + let f = Config { + mode: 1, + up: 2, + down: 0, + } + .frame(request) + .unwrap(); + assert!(!f[2..f.len() - 2].windows(2).any(|p| p == [0xcc, 0xdd])); + } + } + #[test] + fn strict_readback_and_acknowledgment() { + let query = [0xaa, 0xbb, 0xa2, 0, 1, 1, 2, 0, 2, 7, 7, 0, 0, 0xcc, 0xdd]; + let state = confirmed(parse(&query).unwrap(), None, None).unwrap(); + assert_eq!(state.config.up, 2); + let ack = [ + 0xaa, 0xbb, 0xa3, 0, 0x32, 0x54, 1, 1, 2, 0, 2, 7, 7, 0, 0, 0xcc, 0xdd, + ]; + assert!(confirmed( + parse(&ack).unwrap(), + Some(0x32 | (0x54 << 7)), + Some(&state.config) + ) + .is_ok()); + assert!(confirmed(parse(&ack).unwrap(), Some(1), None).is_err()); + let mut bad = ack; + bad[3] = 2; + assert!(confirmed(parse(&bad).unwrap(), Some(0x32 | (0x54 << 7)), None).is_err()); + assert!(parse(&[0xaa, 0xbb, 0xa2, 0, 0xcc, 0xdd]).is_none()); // old firmware generic ACK + for len in 0..query.len() { + assert!(parse(&query[..len]).is_none()); + } + for (index, value) in [ + (4, 2), + (5, 2), + (6, 3), + (7, 2), + (8, 3), + (9, 8), + (10, 8), + (11, 3), + (14, 0), + ] { + let mut bad = query; + bad[index] = value; + assert!(parse(&bad).is_none()); + } + } +} diff --git a/ahakey-desktop/crates/ble/src/usb_routing.rs b/ahakey-desktop/crates/ble/src/usb_routing.rs new file mode 100644 index 00000000..74ebe838 --- /dev/null +++ b/ahakey-desktop/crates/ble/src/usb_routing.rs @@ -0,0 +1,295 @@ +//! Explicit vendor-interface control, independent of BLE pairing and input selection. +//! Never opens the keyboard interface or sends keyboard input reports. +use crate::{ + protocol::{self, DeviceStatus}, + routing::{self, Config, Status}, +}; +use std::{future::IntoFuture, time::Duration}; +use tokio::{sync::mpsc, time::timeout}; +use windows::{ + core::Ref, + Devices::{ + Enumeration::DeviceInformation, + HumanInterfaceDevice::{HidDevice, HidInputReportReceivedEventArgs}, + }, + Foundation::TypedEventHandler, + Storage::{ + FileAccessMode, + Streams::{DataReader, DataWriter}, + }, +}; +type Result = std::result::Result; +fn native(result: windows::core::Result) -> Result { + result.map_err(|e| e.to_string()) +} +pub struct UsbRouting { + device: HidDevice, + token: Option, + input: mpsc::Receiver>, +} +impl Drop for UsbRouting { + fn drop(&mut self) { + if let Some(token) = self.token { + let _ = self.device.RemoveInputReportReceived(token); + } + let _ = self.device.Close(); + } +} +fn output_packet(frame: &[u8]) -> Result<[u8; 65]> { + if frame.len() > 62 { + return Err("USB command exceeds vendor report capacity".into()); + } + let mut packet = [0; 65]; + packet[1] = 0xa1; + packet[2] = frame.len() as u8; + packet[3..3 + frame.len()].copy_from_slice(frame); + Ok(packet) +} +fn input_frame(packet: &[u8]) -> Option<&[u8]> { + // Windows HID prefixes a zero Report ID; no fallback to an arbitrary interface. + if packet.len() != 65 || packet[0] != 0 || packet[1..3] != [0xaa, 0xbb] { + return None; + } + let length = match packet[3] { + 0 => 13, + 0xa2 => 15, + 0xa3 => 17, + 0xa5 => 20, + 0xa8 => 20, + 0xab => 14, + 0xac => 9, + 0xa6 | 0xa7 => 8, + _ => return None, + }; + Some(&packet[1..1 + length]) +} +impl UsbRouting { + pub async fn open() -> Result { + Self::try_open() + .await? + .ok_or_else(|| "未检测到 AhaKey USB 配置接口,请检查数据线".into()) + } + pub async fn try_open() -> Result> { + let selector = native(HidDevice::GetDeviceSelectorVidPid( + 0xff00, 1, 0x413c, 0x2107, + ))?; + let found = native( + native(DeviceInformation::FindAllAsyncAqsFilter(&selector))? + .into_future() + .await, + )?; + match native(found.Size())? { + 0 => return Ok(None), + 1 => {} + _ => return Err("检测到多个 AhaKey USB 配置接口,请只连接一块键盘".into()), + } + let info = native(found.GetAt(0))?; + let id = native(info.Id())?; + let device = native( + native(HidDevice::FromIdAsync(&id, FileAccessMode::ReadWrite))? + .into_future() + .await, + )?; + let (sender, input) = mpsc::channel(16); + let mut port = Self { + device, + token: None, + input, + }; + if native(port.device.VendorId())? != 0x413c || native(port.device.ProductId())? != 0x2107 { + return Err("USB device identity mismatch".into()); + } + let token = native(port.device.InputReportReceived(&TypedEventHandler::new( + move |_: Ref, args: Ref| { + let report = args.ok()?.Report()?; + let buffer = report.Data()?; + if buffer.Length()? != 65 { + return Ok(()); + } + let reader = DataReader::FromBuffer(&buffer)?; + let mut bytes = vec![0; 65]; + reader.ReadBytes(&mut bytes)?; + let _ = sender.try_send(bytes); + Ok(()) + }, + )))?; + port.token = Some(token); + Ok(Some(port)) + } + async fn exchange( + &mut self, + frame: &[u8], + request: Option, + config: Option<&Config>, + ) -> Result { + let bytes = self + .roundtrip(frame, |bytes| { + routing::parse(bytes).is_some_and(|r| r.request == request) + }) + .await?; + routing::confirmed( + routing::parse(&bytes).ok_or("Invalid routing response")?, + request, + config, + ) + .map_err(|e| e.to_string()) + } + async fn roundtrip(&mut self, frame: &[u8], accept: impl Fn(&[u8]) -> bool) -> Result> { + while self.input.try_recv().is_ok() {} + let report = native(self.device.CreateOutputReport())?; + if native(report.Id())? != 0 || native(native(report.Data())?.Length())? != 65 { + return Err("Unexpected vendor output report layout".into()); + } + let packet = output_packet(frame)?; + let writer = native(DataWriter::new())?; + native(writer.WriteBytes(&packet))?; + native(report.SetData(&native(writer.DetachBuffer())?))?; + let sent = native( + native(self.device.SendOutputReportAsync(&report))? + .into_future() + .await, + )?; + if sent != 65 { + return Err(format!("Short USB command write: {sent}/65")); + } + timeout(Duration::from_secs(4), async { + while let Some(packet) = self.input.recv().await { + if let Some(frame) = input_frame(&packet) { + if accept(frame) { + return Ok(frame.to_vec()); + } + } + } + Err("USB input stream ended".into()) + }) + .await + .map_err(|_| "USB 设备回读超时;未确认操作结果".to_owned())? + } + /// The same read-only status payload used by BLE: no pairing or Flash write. + pub async fn device_status(&mut self) -> Result { + let bytes = self + .roundtrip(&protocol::QUERY_STATUS, |b| { + protocol::parse_status(b).is_some() + }) + .await?; + protocol::parse_status(&bytes).ok_or_else(|| "无效 USB 设备状态".into()) + } + pub async fn read(&mut self) -> Result { + self.exchange(&routing::QUERY, None, None).await + } + pub async fn read_pairing(&mut self) -> Result { + let b = self + .roundtrip(&routing::DETAILS_QUERY, |b| b.get(2) == Some(&0xa5)) + .await?; + routing::parse_details(&b).map_err(|e| e.to_string()) + } + pub async fn read_policy(&mut self) -> Result { + let b = self + .roundtrip(&crate::reset::QUERY, |b| b.get(2) == Some(&0xab)) + .await?; + crate::reset::parse(&b).map_err(|e| e.to_string()) + } + /// No BLE equivalent. Capability, mutation and read-only completion use one USB handle. + pub async fn reset_pairing(&mut self, target: u8) -> Result { + let policy = self.read_policy().await?; + if policy.state == 1 { + return Err("设备已有重置正在执行,请等待".into()); + } + let id = routing::next_management_request(); + let frame = crate::reset::request(target, id).map_err(|e| e.to_string())?; + let b = self.roundtrip(&frame, |b| b.get(2) == Some(&0xac)).await?; + crate::reset::accepted(&b, target, id).map_err(|e| e.to_string())?; + timeout(Duration::from_secs(12), async { + loop { + let p = self.read_policy().await?; + if crate::reset::completed(&p, target, id).map_err(|e| e.to_string())? { + return Ok(p); + } + tokio::time::sleep(Duration::from_millis(150)).await; + } + }) + .await + .map_err(|_| "重置结果未确认;请重新读取状态,勿重复提交".to_owned())? + } + pub async fn read_host_info(&mut self) -> Result<[crate::host_info::HostInfo; 2]> { + let mut hosts = Vec::new(); + for slot in 0..2 { + let mut parts: [Vec; 3] = Default::default(); + for part in 0..3 { + let q = crate::host_info::query(slot, part).map_err(|e| e.to_string())?; + parts[part as usize] = self.roundtrip(&q, |b| b.get(2) == Some(&0xa8)).await?; + } + hosts.push(crate::host_info::decode(slot, &parts).map_err(|e| e.to_string())?); + } + Ok([hosts.remove(0), hosts.remove(0)]) + } + pub async fn manage_pairing(&mut self, action: routing::ManagementAction) -> Result<()> { + self.read_pairing().await?; + let id = routing::next_management_request(); + let frame = action.frame(id); + let b = self + .roundtrip(&frame, |b| { + b.get(2) == Some(&action.command()) + && b.get(4) == Some(&((id & 127) as u8)) + && b.get(5) == Some(&(((id >> 7) & 127) as u8)) + }) + .await?; + routing::management_confirmed(&b, action, id).map_err(|e| e.to_string()) + } + pub async fn apply(&mut self, config: &Config) -> Result { + use std::sync::atomic::{AtomicU16, Ordering}; + static NEXT: AtomicU16 = AtomicU16::new(1); + config.validate().map_err(|e| e.to_string())?; + self.read().await?; // Capability gate, on this same open USB device. + let request = NEXT.fetch_add(1, Ordering::Relaxed) & 0x3fff; + let frame = config.frame(request).map_err(|e| e.to_string())?; + self.exchange(&frame, Some(request), Some(config)).await + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn pairing_details_and_management_use_bounded_vendor_frames() { + let mut packet = [0u8; 65]; + packet[1..21].copy_from_slice(&[ + 0xaa, 0xbb, 0xa5, 0, 1, 2, 6, 6, 2, 1, 1, 3, 0xff, 0, 2, 6, 8, 4, 0xcc, 0xdd, + ]); + assert_eq!( + routing::parse_details(input_frame(&packet).unwrap()) + .unwrap() + .paired, + 2 + ); + packet[1..9].copy_from_slice(&[0xaa, 0xbb, 0xa6, 0, 1, 0, 0xcc, 0xdd]); + assert!(routing::management_confirmed( + input_frame(&packet).unwrap(), + routing::ManagementAction::SwapSlots, + 1 + ) + .is_ok()); + } + #[test] + fn decodes_device_telemetry_without_accepting_routing_as_telemetry() { + let mut packet = [0; 65]; + packet[1..14].copy_from_slice(&[0xaa, 0xbb, 0, 76, 50, 1, 0, 3, 5, 1, 35, 0xcc, 0xdd]); + let status = protocol::parse_status(input_frame(&packet).unwrap()).unwrap(); + assert_eq!(status.battery_level, 76); + assert_eq!(status.work_mode, 3); + assert_eq!(status.light_brightness, 35); + packet[13] = 0; + assert!(protocol::parse_status(input_frame(&packet).unwrap()).is_none()); + } + #[test] + fn uses_vendor_command_report_not_keyboard_report() { + let p = output_packet(&routing::QUERY).unwrap(); + assert_eq!(&p[..8], &[0, 0xa1, 5, 0xaa, 0xbb, 0xa2, 0xcc, 0xdd]); + assert!(output_packet(&[0; 63]).is_err()); + let mut input = [0; 65]; + input[1..16].copy_from_slice(&[0xaa, 0xbb, 0xa2, 0, 1, 1, 0, 1, 0, 4, 4, 0, 0, 0xcc, 0xdd]); + assert!(routing::parse(input_frame(&input).unwrap()).is_some()); + input[0] = 1; + assert!(input_frame(&input).is_none()); + assert!(input_frame(&[0; 8]).is_none()); + } +} From ce3d3b592e7eea09c498b213256571ee1f8bb3f0 Mon Sep 17 00:00:00 2001 From: Lihao Date: Mon, 14 Sep 2026 02:58:51 -0700 Subject: [PATCH 02/21] feat(speech): add offline SenseVoice capture and recognition Introduce a standalone library and its focused tests. --- ahakey-desktop/crates/speech/.gitignore | 3 + ahakey-desktop/crates/speech/Cargo.toml | 18 + ahakey-desktop/crates/speech/README.md | 155 +++++++ ahakey-desktop/crates/speech/build.rs | 25 ++ .../speech/licenses/onnxruntime-LICENSE.txt | 21 + .../speech/licenses/sherpa-onnx-LICENSE.txt | 202 +++++++++ .../crates/speech/scripts/prepare-runtime.ps1 | 44 ++ ahakey-desktop/crates/speech/src/audio.rs | 276 +++++++++++++ ahakey-desktop/crates/speech/src/lib.rs | 16 + ahakey-desktop/crates/speech/src/model.rs | 254 ++++++++++++ .../crates/speech/src/recognizer.rs | 70 ++++ ahakey-desktop/crates/speech/src/session.rs | 388 ++++++++++++++++++ .../crates/speech/tests/known_answer.rs | 77 ++++ 13 files changed, 1549 insertions(+) create mode 100644 ahakey-desktop/crates/speech/.gitignore create mode 100644 ahakey-desktop/crates/speech/Cargo.toml create mode 100644 ahakey-desktop/crates/speech/README.md create mode 100644 ahakey-desktop/crates/speech/build.rs create mode 100644 ahakey-desktop/crates/speech/licenses/onnxruntime-LICENSE.txt create mode 100644 ahakey-desktop/crates/speech/licenses/sherpa-onnx-LICENSE.txt create mode 100644 ahakey-desktop/crates/speech/scripts/prepare-runtime.ps1 create mode 100644 ahakey-desktop/crates/speech/src/audio.rs create mode 100644 ahakey-desktop/crates/speech/src/lib.rs create mode 100644 ahakey-desktop/crates/speech/src/model.rs create mode 100644 ahakey-desktop/crates/speech/src/recognizer.rs create mode 100644 ahakey-desktop/crates/speech/src/session.rs create mode 100644 ahakey-desktop/crates/speech/tests/known_answer.rs diff --git a/ahakey-desktop/crates/speech/.gitignore b/ahakey-desktop/crates/speech/.gitignore new file mode 100644 index 00000000..a5d6c12a --- /dev/null +++ b/ahakey-desktop/crates/speech/.gitignore @@ -0,0 +1,3 @@ +/target/ +/runtime/ +/Cargo.lock diff --git a/ahakey-desktop/crates/speech/Cargo.toml b/ahakey-desktop/crates/speech/Cargo.toml new file mode 100644 index 00000000..64e3e7b5 --- /dev/null +++ b/ahakey-desktop/crates/speech/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ahakey-speech" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Native microphone capture and offline SenseVoice speech for AhaKey" + +[dependencies] +anyhow = "1" +cpal = "0.15.3" +sherpa-onnx = { version = "=1.13.7", default-features = false, features = ["shared"] } +sha2 = "0.10" +tempfile = "3" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +tokio = { version = "1", features = ["rt", "time", "macros"] } + +[dev-dependencies] +hound = "3.5" diff --git a/ahakey-desktop/crates/speech/README.md b/ahakey-desktop/crates/speech/README.md new file mode 100644 index 00000000..3f5ed8f8 --- /dev/null +++ b/ahakey-desktop/crates/speech/README.md @@ -0,0 +1,155 @@ +# Native speech for AhaKey + +`ahakey-speech` supplies microphone capture, a SenseVoice recognizer, local +provisional subtitles, and explicit model download/import. It runs in the +application process through CPAL and the **official** `sherpa-onnx` 1.13.7 Rust +wrapper/C API. No Java, C# driver, Python service, or ASR subprocess is used. +The library never starts capture or downloads weights on application startup. + +## Application integration + +Add `ahakey-speech = { path = "../crates/speech" }` to the Tauri Rust manifest. +All methods returning `Result` use `anyhow::Error`. + +```rust,no_run +use ahakey_speech::{SessionConfig, SpeechEvent, SpeechSession}; +use std::path::PathBuf; + +let config = SessionConfig::new(PathBuf::from("app-data/models/sensevoice-int8-2024-07-17")); +let session = SpeechSession::start(config, |event| { + // Enqueue only. The app associates this callback with its session generation. + match event { + SpeechEvent::Partial(text) => { /* update non-activating subtitle window */ } + SpeechEvent::Final(text) => { /* gate by active generation/target before insertion */ } + _ => {} + } +})?; +// On key release, retain the handle until Final/Error/Cancelled: +session.finish(); +# Ok::<(), anyhow::Error>(()) +``` + +- `SessionConfig::new(model_dir)` defaults to the default microphone, four CPU + threads, 800 ms preview spacing, and a 120 second recording cap. Set + `device_name` to one of `input_devices()` or `None` for the OS default. +- Events are `Loading`, `Recording`, `Partial(String)`, `Recognizing`, + `Final(String)`, `Error(String)`, and `Cancelled`. The callback runs on a worker + and must enqueue quickly. It must not directly change UI widgets. +- `finish()` closes the microphone and requests finalization; `cancel()` or + dropping an active handle closes capture and discards pending decode output. + `is_finished()` indicates worker completion. Native inference in progress is + allowed to finish; cancellation never inserts its result. +- The UI **must** associate callbacks with a monotonically increasing session + generation, ignore all events from superseded sessions, and recheck that + generation and the intended target before inserting final text. This closes + cancellation races between worker callbacks and queued UI events. Partial + text must never be injected. This library itself never injects text. +- Capture starts before a cold model load so the first words are retained. One + verified model is retained for subsequent sessions, avoiding per-press reloads. +- `MicrophoneCapture::start(device_name, on_audio, on_error)` supplies 16 kHz mono + `Vec` chunks for other backends (including cloud); `.stop(&mut self)` joins + its owner thread. Callbacks must use bounded, nonblocking queues. Native + hardware streams are created/dropped on their owning thread, including on + platforms where `cpal::Stream` cannot move across threads. +- `Recognizer::new(RecognizerConfig { model_dir, threads })` verifies both files; + `.decode(&samples, 16000)` is a synchronous native call for at most 30 seconds. +- `ModelStore::new(directory)` exposes `directory()`, cheap `is_installed()`, + full `verify()`, `download(&cancel, progress)`, and + `import_from(source, &cancel, progress)`. The latter two run on a blocking + worker (`spawn_blocking` in async applications), and return the model path. + The progress callback receives a fraction from 0.0 to 1.0. +- `CancellationToken::new()` is cloneable and exposes `cancel()` and + `is_cancelled()`. Model transfers use HTTPS, a pinned source revision, exact + size and SHA256, temporary sibling files, and atomic publication. Cancelled + transfers remove their own temporary files, retaining installed files. + +## Subtitle behavior and limits + +SenseVoice INT8 understands Chinese, English, Japanese, Korean, and Cantonese. +It is an offline sentence recognizer. The provisional display is produced by +periodically decoding a rolling **12 second** window, then correcting the final +utterance on release. It is not a stateful streaming model. The leading `…` +means the preview shows the recent window, not the entire utterance. + +Recording memory is capped (120 seconds by default, configurable from 1 to 300). +Final decoding covers all captured samples in at most 20 second segments and +prefers quiet boundaries. Continuous speech without pauses at such boundaries +can split a word; the final transcript can differ from provisional text. +The recognizer is CPU-only, with one to eight threads. A 64-tap anti-alias filter +normalizes microphone rates; stopping can discard its roughly 1 ms filter tail. + +## Verified Windows native runtime + +From this crate directory, in the same PowerShell process as Cargo: + +```powershell +$env:SHERPA_ONNX_LIB_DIR = & .\scripts\prepare-runtime.ps1 +cargo test --all-targets +cargo clippy --all-targets -- -D warnings +``` + +Preparation downloads only the engine and notices, **not** model weights. It +verifies the official ASR-only Windows x64 archive before every extraction: + +``` +sherpa-onnx-v1.13.7-win-x64-shared-MT-Release-no-tts-lib.tar.bz2 +SHA256 ebbcb8e6ef5ba4fb2444810fb7cc8dc0154e66f84a2101bf7c5cbcc16ce497a9 +https://github.com/k2-fsa/sherpa-onnx/releases/tag/v1.13.7 +``` + +**Bundle every DLL from the returned `lib` directory beside the actual app +executable.** This includes `onnxruntime.dll`, `onnxruntime_providers_shared.dll`, +and `sherpa-onnx-c-api.dll` (plus any C++ API DLL supplied by the archive). +Bundle the generated `runtime/licenses/` directory as third-party notices. +Never ship `runtime/` wholesale: it also contains import libraries and archives. +Tauri `resources/` or PATH alone does not ensure correct DLL resolution: recent +Windows ships an older `System32/onnxruntime.dll`, which is incompatible with +this engine. This crate's `build.rs` also places DLLs beside Cargo test binaries. + +The official crate can download its own archive if `SHERPA_ONNX_LIB_DIR` is +absent. Production builds must use the preparation step above to enforce the +pinned checksum and ASR-only artifact. + +The C ABI structs and calls come from the same upstream release's maintained +`sherpa-onnx-sys` crate, not hand-authored struct layouts. Runtime 1.13.7 uses +ONNX Runtime 1.27.1; ship them as a matching set. + +## Known-answer gate + +Set these environment variables to an existing official bundle and WAV, then +run the explicit gate. It never opens a microphone and never downloads weights. + +```powershell +$env:AHAKEY_TEST_MODEL_DIR = 'path-to-official-model-directory' +$env:AHAKEY_TEST_ZH_WAV = 'path-to-official-test_wavs\zh.wav' +cargo test --release --test known_answer -- --ignored --nocapture +``` + +The test requires the exact Chinese sentence `开饭时间早上9点至下午5点。`, measures +native load/inference time, and checks multiple changing provisional results. +Default unit tests cover resampling continuity, alias suppression, long-speech +memory/segment bounds, cancellation after decode, and rejected model imports. + +The model pin matches the Windows transition client: model file 239,233,841 +bytes, tokens 315,894 bytes; hashes are in `src/model.rs`. Weights are optional +application data. An import reuses this exact existing bundle after verification. + +## Other desktop platforms + +CPAL and the official bindings have macOS/Linux backends. On those hosts, prepare +the corresponding official **1.13.7** shared library archive, verify its GitHub +release SHA256, and set `SHERPA_ONNX_LIB_DIR` to its `lib` directory before Cargo. +Linux additionally needs ALSA development packages; macOS needs microphone usage +description/permission and signed embedded dylibs in the application bundle. +The upstream binding adds native runtime search paths, but the final installed +app must be tested on each OS. This crate's acceptance currently covers Windows +x64 only; shared source is not evidence of a tested macOS/Linux release. + +## Attribution + +sherpa-onnx is Apache-2.0, ONNX Runtime is MIT. Exact upstream license copies are +under `licenses/`. Preparation also retrieves the version-pinned, hash-verified +ONNX Runtime third-party notices for the distributable bundle. The selected +ASR-only binary omits unneeded text-to-speech components. SenseVoice weights are +obtained from the official converted model repository pinned in `src/model.rs`; +see [SenseVoice](https://github.com/FunAudioLLM/SenseVoice) and its model license. diff --git a/ahakey-desktop/crates/speech/build.rs b/ahakey-desktop/crates/speech/build.rs new file mode 100644 index 00000000..a497329b --- /dev/null +++ b/ahakey-desktop/crates/speech/build.rs @@ -0,0 +1,25 @@ +use std::{env, fs, path::PathBuf}; + +fn main() { + println!("cargo:rerun-if-env-changed=SHERPA_ONNX_LIB_DIR"); + if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let Some(directory) = env::var_os("SHERPA_ONNX_LIB_DIR") else { + return; + }; + let out = PathBuf::from(env::var_os("OUT_DIR").expect("Cargo OUT_DIR")); + let profile = out.ancestors().nth(3).expect("Cargo profile directory"); + // Official sherpa-onnx-sys copies DLLs next to normal binaries but not + // test binaries. System32 contains an older ORT on recent Windows; PATH + // cannot override that. Tests need the same beside-exe bundle as releases. + let tests = profile.join("deps"); + fs::create_dir_all(&tests).expect("Create test output directory"); + for entry in fs::read_dir(directory).expect("Read prepared speech runtime") { + let entry = entry.expect("Read runtime file"); + if entry.path().extension().and_then(|ext| ext.to_str()) == Some("dll") { + fs::copy(entry.path(), tests.join(entry.file_name())) + .expect("Bundle native runtime beside tests"); + } + } +} diff --git a/ahakey-desktop/crates/speech/licenses/onnxruntime-LICENSE.txt b/ahakey-desktop/crates/speech/licenses/onnxruntime-LICENSE.txt new file mode 100644 index 00000000..48bc6bb4 --- /dev/null +++ b/ahakey-desktop/crates/speech/licenses/onnxruntime-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ahakey-desktop/crates/speech/licenses/sherpa-onnx-LICENSE.txt b/ahakey-desktop/crates/speech/licenses/sherpa-onnx-LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/ahakey-desktop/crates/speech/licenses/sherpa-onnx-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ahakey-desktop/crates/speech/scripts/prepare-runtime.ps1 b/ahakey-desktop/crates/speech/scripts/prepare-runtime.ps1 new file mode 100644 index 00000000..55e8d74e --- /dev/null +++ b/ahakey-desktop/crates/speech/scripts/prepare-runtime.ps1 @@ -0,0 +1,44 @@ +[CmdletBinding()] +param([string]$RuntimeDirectory = (Join-Path $PSScriptRoot '..\runtime')) +$ErrorActionPreference = 'Stop' +if (-not [System.Environment]::Is64BitProcess -or $env:PROCESSOR_ARCHITECTURE -ne 'AMD64') { + throw 'This preparation script supports Windows x64. See README for other platforms.' +} +$runtimeRoot = [System.IO.Path]::GetFullPath($RuntimeDirectory) +[System.IO.Directory]::CreateDirectory($runtimeRoot) | Out-Null +$stem = 'sherpa-onnx-v1.13.7-win-x64-shared-MT-Release-no-tts-lib' +$expected = 'ebbcb8e6ef5ba4fb2444810fb7cc8dc0154e66f84a2101bf7c5cbcc16ce497a9' +$archive = Join-Path $runtimeRoot "$stem.tar.bz2" +if (-not (Test-Path -LiteralPath $archive)) { + $temporary = Join-Path $runtimeRoot ([guid]::NewGuid().ToString() + '.part') + try { + Invoke-WebRequest -Uri "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.7/$stem.tar.bz2" -OutFile $temporary + if ((Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected) { throw 'Native runtime archive SHA256 mismatch' } + Move-Item -LiteralPath $temporary -Destination $archive + } finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary } } +} +if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected) { throw 'Cached native runtime archive SHA256 mismatch' } +# Always restore files from the verified archive: a directory-existing check +# alone would allow a modified DLL from a prior local extraction to be bundled. +& tar -xf $archive -C $runtimeRoot +if ($LASTEXITCODE -ne 0) { throw 'Cannot extract verified native runtime archive' } +$lib = Join-Path $runtimeRoot ($stem + '\lib') +if (-not (Test-Path -LiteralPath (Join-Path $lib 'sherpa-onnx-c-api.lib'))) { throw "Runtime import library missing: $lib" } +$notices = Join-Path $runtimeRoot 'licenses' +[System.IO.Directory]::CreateDirectory($notices) | Out-Null +Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot '..\licenses') -File | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $notices -Force +} +$ortNotices = Join-Path $notices 'onnxruntime-ThirdPartyNotices.txt' +$noticeHash = '0e07b95f3a8d6230037707c5c4a2b554d12c4cb67369669ac255635528ffcee2' +if (-not (Test-Path -LiteralPath $ortNotices)) { + $noticeTemporary = Join-Path $notices ([guid]::NewGuid().ToString() + '.part') + try { + Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/microsoft/onnxruntime/v1.27.1/ThirdPartyNotices.txt' -OutFile $noticeTemporary + if ((Get-FileHash -LiteralPath $noticeTemporary -Algorithm SHA256).Hash.ToLowerInvariant() -ne $noticeHash) { throw 'ONNX Runtime notices SHA256 mismatch' } + Move-Item -LiteralPath $noticeTemporary -Destination $ortNotices + } finally { if (Test-Path -LiteralPath $noticeTemporary) { Remove-Item -LiteralPath $noticeTemporary } } +} +if ((Get-FileHash -LiteralPath $ortNotices -Algorithm SHA256).Hash.ToLowerInvariant() -ne $noticeHash) { throw 'Cached ONNX Runtime notices SHA256 mismatch' } +# Return a path, not an environment mutation. The caller scopes this to its build. +Write-Output $lib diff --git a/ahakey-desktop/crates/speech/src/audio.rs b/ahakey-desktop/crates/speech/src/audio.rs new file mode 100644 index 00000000..f43a5e89 --- /dev/null +++ b/ahakey-desktop/crates/speech/src/audio.rs @@ -0,0 +1,276 @@ +use crate::{Result, SAMPLE_RATE}; +use anyhow::{anyhow, bail, Context}; +use cpal::{ + traits::{DeviceTrait, HostTrait, StreamTrait}, + FromSample, SampleFormat, SizedSample, +}; +use std::{ + collections::VecDeque, + sync::{mpsc, Arc}, + thread::{self, JoinHandle}, + time::Duration, +}; + +pub fn input_devices() -> Result> { + Ok(cpal::default_host() + .input_devices()? + .filter_map(|device| device.name().ok()) + .collect()) +} + +/// Native audio stream owned entirely by its capture thread (also on platforms +/// where cpal::Stream is !Send). Callbacks must be quick, bounded and nonblocking. +/// Dropping/stopping the handle releases the microphone without a helper process. +pub struct MicrophoneCapture { + stop: mpsc::Sender<()>, + worker: Option>, +} + +impl MicrophoneCapture { + pub fn start( + device_name: Option<&str>, + on_audio: impl Fn(Vec) + Send + Sync + 'static, + on_error: impl Fn(String) + Send + Sync + 'static, + ) -> Result { + let name = device_name.map(str::to_owned); + let (stop, stop_rx) = mpsc::channel(); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let worker = thread::Builder::new() + .name("ahakey-microphone".into()) + .spawn(move || { + let result = open_stream(name.as_deref(), Arc::new(on_audio), Arc::new(on_error)); + match result { + Ok(stream) => { + if ready_tx.send(Ok(())).is_ok() { + let _ = stop_rx.recv(); + } + let _ = stream.pause(); + drop(stream); + } + Err(error) => { + let _ = ready_tx.send(Err(format!("{error:#}"))); + } + } + })?; + match ready_rx.recv_timeout(Duration::from_secs(10)) { + Ok(Ok(())) => Ok(Self { + stop, + worker: Some(worker), + }), + result => { + let _ = stop.send(()); + // A platform API may be stuck opening a device: detach instead + // of hanging the UI. Its next successful open will close itself. + bail!( + "{}", + match result { + Ok(Err(message)) => message, + _ => "Microphone initialization timed out".into(), + } + ) + } + } + } + pub fn stop(&mut self) { + let _ = self.stop.send(()); + if let Some(worker) = self.worker.take() { + if worker.thread().id() != thread::current().id() { + let _ = worker.join(); + } + } + } +} +impl Drop for MicrophoneCapture { + fn drop(&mut self) { + self.stop(); + } +} + +type AudioCallback = Arc) + Send + Sync>; +type ErrorCallback = Arc; +fn open_stream( + name: Option<&str>, + audio: AudioCallback, + error: ErrorCallback, +) -> Result { + let host = cpal::default_host(); + let device = if let Some(name) = name { + host.input_devices()? + .find(|device| device.name().is_ok_and(|value| value == name)) + .context("Selected microphone is not available")? + } else { + host.default_input_device() + .context("No default microphone is available")? + }; + let supported = device + .default_input_config() + .context("Microphone has no supported input configuration")?; + let config: cpal::StreamConfig = supported.clone().into(); + let stream = match supported.sample_format() { + SampleFormat::F32 => build_stream::(&device, &config, audio, error), + SampleFormat::F64 => build_stream::(&device, &config, audio, error), + SampleFormat::I8 => build_stream::(&device, &config, audio, error), + SampleFormat::I16 => build_stream::(&device, &config, audio, error), + SampleFormat::I32 => build_stream::(&device, &config, audio, error), + SampleFormat::I64 => build_stream::(&device, &config, audio, error), + SampleFormat::U8 => build_stream::(&device, &config, audio, error), + SampleFormat::U16 => build_stream::(&device, &config, audio, error), + SampleFormat::U32 => build_stream::(&device, &config, audio, error), + SampleFormat::U64 => build_stream::(&device, &config, audio, error), + format => Err(anyhow!("Unsupported microphone sample format {format:?}")), + }?; + stream + .play() + .context("Cannot start microphone; check system microphone permissions")?; + Ok(stream) +} +fn build_stream( + device: &cpal::Device, + config: &cpal::StreamConfig, + audio: AudioCallback, + error: ErrorCallback, +) -> Result +where + T: SizedSample, + f32: FromSample, +{ + let mut normalizer = MonoResampler::new(config.sample_rate.0, config.channels as usize)?; + Ok(device.build_input_stream( + config, + move |data: &[T], _: &cpal::InputCallbackInfo| { + let samples = normalizer.push(data.iter().map(|sample| sample.to_sample::())); + if !samples.is_empty() { + audio(samples); + } + }, + move |err| error(format!("Microphone stream failed: {err}")), + None, + )?) +} + +/// Streaming 64-tap Hann-windowed sinc, normalized for DC gain. Downmixing and +/// fractional resampling state survive callback boundaries. The anti-alias +/// cutoff scales for downsampling (e.g. 48 kHz -> 16 kHz); latency is 32 input +/// samples. Stop may discard this sub-millisecond filter tail. +struct MonoResampler { + input_rate: u32, + channels: usize, + partial_sum: f32, + partial_channels: usize, + buffer: VecDeque, + position: f64, +} +impl MonoResampler { + fn new(input_rate: u32, channels: usize) -> Result { + if !(8_000..=384_000).contains(&input_rate) || !(1..=32).contains(&channels) { + bail!("Unsupported microphone sample rate or channel count"); + } + Ok(Self { + input_rate, + channels, + partial_sum: 0., + partial_channels: 0, + buffer: std::iter::repeat_n(0., 32).collect(), + position: 32., + }) + } + fn push(&mut self, samples: impl Iterator) -> Vec { + let mut output = Vec::new(); + for sample in samples { + self.partial_sum += if sample.is_finite() { + sample.clamp(-1., 1.) + } else { + 0. + }; + self.partial_channels += 1; + if self.partial_channels == self.channels { + let mono = self.partial_sum / self.channels as f32; + if self.input_rate == SAMPLE_RATE { + output.push(mono); + } else { + self.buffer.push_back(mono); + } + self.partial_channels = 0; + self.partial_sum = 0.; + } + } + if self.input_rate == SAMPLE_RATE { + return output; + } + let cutoff = (SAMPLE_RATE as f64 / self.input_rate as f64).min(1.) * 0.94; + while self.position.floor() as usize + 32 < self.buffer.len() { + let center = self.position.floor() as usize; + let mut weighted = 0.; + let mut gain = 0.; + for index in center - 31..=center + 32 { + let distance = index as f64 - self.position; + let angle = std::f64::consts::PI * distance * cutoff; + let sinc = if angle.abs() < 1e-10 { + 1. + } else { + angle.sin() / angle + }; + let window = 0.5 + 0.5 * (std::f64::consts::PI * distance / 32.).cos(); + let weight = sinc * window * cutoff; + weighted += self.buffer[index] as f64 * weight; + gain += weight; + } + output.push((weighted / gain).clamp(-1., 1.) as f32); + self.position += self.input_rate as f64 / SAMPLE_RATE as f64; + } + let discard = (self.position.floor() as usize) + .saturating_sub(32) + .min(self.buffer.len()); + self.buffer.drain(..discard); + self.position -= discard as f64; + output + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn stereo_downmix_and_partial_frames_are_preserved() { + let mut resampler = MonoResampler::new(16000, 2).unwrap(); + assert!(resampler.push([1.].into_iter()).is_empty()); + assert_eq!( + resampler.push([-1., 0.8, 0.2, f32::NAN, 0.].into_iter()), + vec![0., 0.5, 0.] + ); + } + #[test] + fn chunk_boundaries_do_not_change_resampled_audio() { + let input: Vec = (0..88200).map(|i| ((i / 2) as f32 * 0.1).sin()).collect(); + let expected = MonoResampler::new(44100, 2) + .unwrap() + .push(input.iter().copied()); + let mut resampler = MonoResampler::new(44100, 2).unwrap(); + let actual: Vec = input + .chunks(317) + .flat_map(|chunk| resampler.push(chunk.iter().copied())) + .collect(); + assert!((15980..=16000).contains(&actual.len())); + assert_eq!(actual.len(), expected.len()); + let max_error = actual + .iter() + .zip(&expected) + .map(|(a, b)| (a - b).abs()) + .fold(0., f32::max); + assert!(max_error < 0.00001, "{max_error}"); + assert!(resampler.buffer.len() < 100); + } + #[test] + fn resampling_rejects_out_of_band_tone() { + let energy = |frequency: f32| { + let signal = (0..48000) + .map(|i| (2. * std::f32::consts::PI * frequency * i as f32 / 48000.).sin()); + let output = MonoResampler::new(48000, 1).unwrap().push(signal); + output[100..].iter().map(|x| x * x).sum::() / (output.len() - 100) as f32 + }; + let voice = energy(1000.); + let alias = energy(12000.); + assert!(voice > 0.45); + assert!(alias < 0.001, "Aliased energy: {alias}"); + } +} diff --git a/ahakey-desktop/crates/speech/src/lib.rs b/ahakey-desktop/crates/speech/src/lib.rs new file mode 100644 index 00000000..5b62f740 --- /dev/null +++ b/ahakey-desktop/crates/speech/src/lib.rs @@ -0,0 +1,16 @@ +//! Native, explicit opt-in audio capture and local speech recognition. +//! No microphone, model download, or helper process is started on library load. + +mod audio; +mod model; +mod recognizer; +mod session; + +pub use audio::{input_devices, MicrophoneCapture}; +pub use model::{ + CancellationToken, ModelStore, MODEL_BYTES, MODEL_SHA256, TOKENS_BYTES, TOKENS_SHA256, +}; +pub use recognizer::{Recognizer, RecognizerConfig}; +pub use session::{SessionConfig, SpeechEvent, SpeechSession}; +pub type Result = anyhow::Result; +pub const SAMPLE_RATE: u32 = 16_000; diff --git a/ahakey-desktop/crates/speech/src/model.rs b/ahakey-desktop/crates/speech/src/model.rs new file mode 100644 index 00000000..28ac4bd6 --- /dev/null +++ b/ahakey-desktop/crates/speech/src/model.rs @@ -0,0 +1,254 @@ +use crate::Result; +use anyhow::{bail, Context}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File}, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, +}; + +pub const MODEL_SHA256: &str = "c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51"; +pub const TOKENS_SHA256: &str = "f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc"; +pub const MODEL_BYTES: u64 = 239_233_841; +pub const TOKENS_BYTES: u64 = 315_894; +const BASE_URL: &str = "https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/resolve/2365baeacb507f821a0c8120fcee3d484dba7a07/"; +const FILES: [(&str, &str, u64); 2] = [ + ("model.int8.onnx", MODEL_SHA256, MODEL_BYTES), + ("tokens.txt", TOKENS_SHA256, TOKENS_BYTES), +]; + +#[derive(Clone, Default)] +pub struct CancellationToken(Arc); +impl CancellationToken { + pub fn new() -> Self { + Self::default() + } + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) + } + pub(crate) fn check(&self) -> Result<()> { + if self.is_cancelled() { + bail!("Operation cancelled"); + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub struct ModelStore { + directory: PathBuf, +} +impl ModelStore { + pub fn new(directory: PathBuf) -> Self { + Self { directory } + } + pub fn directory(&self) -> &Path { + &self.directory + } + /// Fast settings-page readiness hint. Initialization still verifies hashes. + pub fn is_installed(&self) -> bool { + FILES.iter().all(|(name, _, size)| { + fs::metadata(self.directory.join(name)).is_ok_and(|m| m.is_file() && m.len() == *size) + }) + } + pub fn verify(&self) -> Result<()> { + for (name, hash, size) in FILES { + validate( + &self.directory.join(name), + hash, + size, + &CancellationToken::new(), + )?; + } + Ok(()) + } + /// Caller runs this on a worker only after an explicit download action. + /// Files use pinned revision + length + SHA256 and atomic per-file publish. + /// Cancellation is polled even while waiting for network data. + pub fn download(&self, cancel: &CancellationToken, progress: impl Fn(f64)) -> Result { + cancel.check()?; + fs::create_dir_all(&self.directory)?; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async { + let client = reqwest::Client::builder() + .https_only(true) + .connect_timeout(Duration::from_secs(20)) + .timeout(Duration::from_secs(900)) + .build()?; + let mut completed = 0; + for (name, hash, size) in FILES { + cancel.check()?; + let target = self.directory.join(name); + if validate(&target, hash, size, cancel).is_ok() { + completed += size; + progress(completed as f64 / total() as f64); + continue; + } + cancel.check()?; + let request = client.get(format!("{BASE_URL}{name}")).send(); + tokio::pin!(request); + let mut response = loop { + tokio::select! { + result = &mut request => break result?.error_for_status()?, + _ = tokio::time::sleep(Duration::from_millis(100)) => cancel.check()?, + } + }; + let mut temp = tempfile::NamedTempFile::new_in(&self.directory)?; + let mut count = 0u64; + let mut digest = Sha256::new(); + loop { + cancel.check()?; + let chunk = tokio::select! { + chunk = response.chunk() => chunk?, + _ = tokio::time::sleep(Duration::from_millis(100)) => continue, + }; + let Some(chunk) = chunk else { + break; + }; + count += chunk.len() as u64; + if count > size { + bail!("Model download exceeds pinned size"); + } + digest.update(&chunk); + temp.write_all(&chunk)?; + progress((completed + count) as f64 / total() as f64); + } + check_digest(count, digest, hash, size)?; + cancel.check()?; + temp.as_file().sync_all()?; + temp.persist(&target).map_err(|error| error.error)?; + completed += size; + } + Ok(self.directory.clone()) + }) + } + pub fn import_from( + &self, + source: &Path, + cancel: &CancellationToken, + progress: impl Fn(f64), + ) -> Result { + cancel.check()?; + // Validate both sources before replacing any destination file. + for (name, hash, size) in FILES { + validate(&source.join(name), hash, size, cancel)?; + } + fs::create_dir_all(&self.directory)?; + let mut completed = 0; + for (name, hash, size) in FILES { + cancel.check()?; + let target = self.directory.join(name); + if validate(&target, hash, size, cancel).is_ok() { + completed += size; + progress(completed as f64 / total() as f64); + continue; + } + let mut input = File::open(source.join(name))?; + let mut temp = tempfile::NamedTempFile::new_in(&self.directory)?; + let mut count = 0; + let mut digest = Sha256::new(); + let mut buffer = [0; 65536]; + loop { + cancel.check()?; + let length = input.read(&mut buffer)?; + if length == 0 { + break; + } + count += length as u64; + if count > size { + bail!("Imported model exceeds pinned size"); + } + digest.update(&buffer[..length]); + temp.write_all(&buffer[..length])?; + progress((completed + count) as f64 / total() as f64); + } + check_digest(count, digest, hash, size)?; + cancel.check()?; + temp.as_file().sync_all()?; + temp.persist(&target).map_err(|error| error.error)?; + completed += size; + } + Ok(self.directory.clone()) + } +} +fn total() -> u64 { + MODEL_BYTES + TOKENS_BYTES +} +fn check_digest(count: u64, digest: Sha256, hash: &str, size: u64) -> Result<()> { + if count != size { + bail!("Model file size does not match pinned version"); + } + if format!("{:x}", digest.finalize()) != hash { + bail!("Model SHA256 does not match pinned version"); + } + Ok(()) +} +fn validate(path: &Path, hash: &str, size: u64, cancel: &CancellationToken) -> Result<()> { + cancel.check()?; + let mut file = + File::open(path).context("SenseVoice model missing; download or import it in Settings")?; + if file.metadata()?.len() != size { + bail!("Model size does not match pinned version"); + } + let mut digest = Sha256::new(); + let mut count = 0; + let mut buffer = [0; 65536]; + loop { + cancel.check()?; + let length = file.read(&mut buffer)?; + if length == 0 { + break; + } + count += length as u64; + digest.update(&buffer[..length]); + } + check_digest(count, digest, hash, size) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn invalid_import_preserves_existing_files() { + let source = tempfile::tempdir().unwrap(); + let dest = tempfile::tempdir().unwrap(); + fs::write(dest.path().join("tokens.txt"), b"keep existing").unwrap(); + fs::write(source.path().join("model.int8.onnx"), b"invalid").unwrap(); + assert!(ModelStore::new(dest.path().into()) + .import_from(source.path(), &CancellationToken::new(), |_| {}) + .is_err()); + assert_eq!( + fs::read(dest.path().join("tokens.txt")).unwrap(), + b"keep existing" + ); + assert_eq!(fs::read_dir(dest.path()).unwrap().count(), 1); + } + #[test] + fn cancellation_prevents_any_download_or_directory_creation() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("unused"); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert!(ModelStore::new(path.clone()) + .download(&cancel, |_| panic!("No progress expected")) + .is_err()); + assert!(!path.exists()); + } + #[test] + fn hash_is_required_even_for_matching_size() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("small"); + fs::write(&path, b"wrong").unwrap(); + assert!(validate(&path, "bad", 5, &CancellationToken::new()).is_err()); + } +} diff --git a/ahakey-desktop/crates/speech/src/recognizer.rs b/ahakey-desktop/crates/speech/src/recognizer.rs new file mode 100644 index 00000000..c775e590 --- /dev/null +++ b/ahakey-desktop/crates/speech/src/recognizer.rs @@ -0,0 +1,70 @@ +use crate::{ModelStore, Result, SAMPLE_RATE}; +use anyhow::{bail, Context}; +use sherpa_onnx::{OfflineRecognizer, OfflineRecognizerConfig, OfflineSenseVoiceModelConfig}; +use std::path::PathBuf; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecognizerConfig { + pub model_dir: PathBuf, + pub threads: usize, +} + +/// Owns an in-process sherpa-onnx C API recognizer via its official Rust wrapper. +/// Calls are synchronous; keep this on a recognition worker, not the UI thread. +pub struct Recognizer { + inner: OfflineRecognizer, +} + +impl Recognizer { + pub fn new(config: RecognizerConfig) -> Result { + ModelStore::new(config.model_dir.clone()).verify()?; + let path = |file: &str| -> Result { + let path = config.model_dir.join(file); + let text = path.to_str().context("Model path must be valid UTF-8")?; + if text.contains('\0') { + bail!("Model path contains a NUL character"); + } + Ok(text.to_owned()) + }; + let mut settings = OfflineRecognizerConfig::default(); + settings.feat_config.sample_rate = SAMPLE_RATE as i32; + settings.feat_config.feature_dim = 80; + settings.model_config.sense_voice = OfflineSenseVoiceModelConfig { + model: Some(path("model.int8.onnx")?), + language: Some("auto".into()), + use_itn: true, + }; + settings.model_config.tokens = Some(path("tokens.txt")?); + settings.model_config.num_threads = config.threads.clamp(1, 8) as i32; + settings.model_config.provider = Some("cpu".into()); + settings.model_config.debug = false; + Ok(Self { + inner: OfflineRecognizer::create(&settings) + .context("Cannot initialize native SenseVoice")?, + }) + } + + /// Decode at most 30 seconds. Session recording uses bounded segments for + /// long utterances rather than feeding an unbounded tensor to the engine. + pub fn decode(&self, samples: &[f32], sample_rate: u32) -> Result { + if sample_rate != SAMPLE_RATE { + bail!("Recognizer requires 16000 Hz mono audio"); + } + if samples.len() > SAMPLE_RATE as usize * 30 { + bail!("Decode segment exceeds 30 seconds"); + } + if samples.iter().any(|sample| !sample.is_finite()) { + bail!("Audio contains non-finite samples"); + } + if samples.len() < 800 || samples.iter().all(|sample| sample.abs() < 0.00001) { + return Ok(String::new()); + } + let stream = self.inner.create_stream(); + stream.accept_waveform(sample_rate as i32, samples); + self.inner.decode(&stream); + let result = stream + .get_result() + .context("SenseVoice returned no recognition result")?; + Ok(result.text.trim().to_string()) + } +} diff --git a/ahakey-desktop/crates/speech/src/session.rs b/ahakey-desktop/crates/speech/src/session.rs new file mode 100644 index 00000000..d508764c --- /dev/null +++ b/ahakey-desktop/crates/speech/src/session.rs @@ -0,0 +1,388 @@ +use crate::{ + CancellationToken, MicrophoneCapture, Recognizer, RecognizerConfig, Result, SAMPLE_RATE, +}; +use anyhow::{bail, Context}; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, + }, + thread, + time::{Duration, Instant}, +}; + +#[derive(Clone, Debug)] +pub struct SessionConfig { + pub recognizer: RecognizerConfig, + pub device_name: Option, + pub preview_interval: Duration, + pub max_duration: Duration, +} +impl SessionConfig { + pub fn new(model_dir: PathBuf) -> Self { + Self { + recognizer: RecognizerConfig { + model_dir, + threads: 4, + }, + device_name: None, + preview_interval: Duration::from_millis(800), + max_duration: Duration::from_secs(120), + } + } +} + +#[derive(Clone, Debug)] +pub enum SpeechEvent { + Loading, + Recording, + /// Provisional rolling recognition; may be corrected by later updates. + Partial(String), + Recognizing, + /// Only this event is eligible for insertion, after checking the UI's + /// active-session generation and target window. This crate never injects. + Final(String), + Error(String), + Cancelled, +} + +struct Control { + finish: AtomicBool, + cancel: CancellationToken, + done: AtomicBool, + capture: Mutex>, +} +impl Control { + fn stop_capture(&self) { + let capture = self.capture.lock().ok().and_then(|mut slot| slot.take()); + if let Some(mut capture) = capture { + capture.stop(); + } + } +} + +/// A single press/hold or toggle utterance. Its worker owns the recognizer. +/// `finish()` releases capture immediately, then finalizes off the UI thread. +/// `cancel()` suppresses pending recognition and releases capture immediately; +/// native inference already in flight may finish, but its result is discarded. +pub struct SpeechSession { + control: Arc, +} +impl SpeechSession { + pub fn start( + config: SessionConfig, + callback: impl Fn(SpeechEvent) + Send + Sync + 'static, + ) -> Result { + if config.max_duration < Duration::from_secs(1) + || config.max_duration > Duration::from_secs(300) + { + bail!("Speech duration must be between 1 and 300 seconds"); + } + if config.preview_interval < Duration::from_millis(300) { + bail!("Preview interval must be at least 300 ms"); + } + let control = Arc::new(Control { + finish: AtomicBool::new(false), + cancel: CancellationToken::new(), + done: AtomicBool::new(false), + capture: Mutex::new(None), + }); + let worker_control = Arc::clone(&control); + thread::Builder::new() + .name("ahakey-local-speech".into()) + .spawn(move || { + let emit = |event| { + if !worker_control.cancel.is_cancelled() { + callback(event); + } + }; + let outcome = run_session(config, &worker_control, &emit); + worker_control.stop_capture(); + if worker_control.cancel.is_cancelled() { + callback(SpeechEvent::Cancelled); + } else { + match outcome { + Ok(text) => emit(SpeechEvent::Final(text)), + Err(error) => emit(SpeechEvent::Error(format!("{error:#}"))), + } + } + worker_control.done.store(true, Ordering::Release); + })?; + Ok(Self { control }) + } + pub fn finish(&self) { + self.control.finish.store(true, Ordering::Release); + self.control.stop_capture(); + } + pub fn cancel(&self) { + self.control.cancel.cancel(); + self.finish(); + } + pub fn is_finished(&self) -> bool { + self.control.done.load(Ordering::Acquire) + } +} +impl Drop for SpeechSession { + fn drop(&mut self) { + if !self.is_finished() { + self.cancel(); + } + } +} + +fn run_session( + config: SessionConfig, + control: &Arc, + emit: &dyn Fn(SpeechEvent), +) -> Result { + control.cancel.check()?; + if control.finish.load(Ordering::Acquire) { + return Ok(String::new()); + } + emit(SpeechEvent::Loading); + let limit = (config.max_duration.as_secs_f64() * SAMPLE_RATE as f64) as usize; + let audio = Arc::new(Mutex::new(RecordingBuffer { + samples: Vec::new(), + limit, + })); + let input_error = Arc::new(Mutex::new(None)); + let audio_sink = Arc::clone(&audio); + let error_sink = Arc::clone(&input_error); + let audio_control = Arc::clone(control); + let mut capture = MicrophoneCapture::start( + config.device_name.as_deref(), + move |chunk| { + if audio_control.finish.load(Ordering::Acquire) || audio_control.cancel.is_cancelled() { + return; + } + if let Ok(mut buffer) = audio_sink.lock() { + if buffer.append(&chunk) { + audio_control.finish.store(true, Ordering::Release); + } + } + }, + move |error| { + if let Ok(mut slot) = error_sink.lock() { + *slot = Some(error); + } + }, + )?; + { + let mut slot = control + .capture + .lock() + .map_err(|_| anyhow::anyhow!("Capture state poisoned"))?; + if control.finish.load(Ordering::Acquire) || control.cancel.is_cancelled() { + capture.stop(); + return Ok(String::new()); + } + *slot = Some(capture); + } + emit(SpeechEvent::Recording); + // Capture begins before model initialization, so a cold load does not lose + // the first words. finish/cancel can close the independent capture owner. + let recognizer = cached_recognizer(config.recognizer)?; + control.cancel.check()?; + let mut previous = String::new(); + let mut last_preview = Instant::now() + .checked_sub(config.preview_interval) + .unwrap_or_else(Instant::now); + while !control.finish.load(Ordering::Acquire) { + control.cancel.check()?; + if let Some(error) = input_error + .lock() + .map_err(|_| anyhow::anyhow!("Capture error state poisoned"))? + .take() + { + bail!("{error}"); + } + if last_preview.elapsed() >= config.preview_interval { + let (window, rolling) = { + let buffer = audio + .lock() + .map_err(|_| anyhow::anyhow!("Audio buffer poisoned"))?; + let start = buffer + .samples + .len() + .saturating_sub(12 * SAMPLE_RATE as usize); + (buffer.samples[start..].to_vec(), start > 0) + }; + if window.len() >= SAMPLE_RATE as usize / 2 { + let text = recognizer.decode(&window, SAMPLE_RATE)?; + control.cancel.check()?; + if !control.finish.load(Ordering::Acquire) && !text.is_empty() && text != previous { + previous = text.clone(); + emit(SpeechEvent::Partial(if rolling { + format!("…{text}") + } else { + text + })); + } + } + last_preview = Instant::now(); + } + thread::sleep(Duration::from_millis(20)); + } + control.stop_capture(); + control.cancel.check()?; + if let Some(error) = input_error + .lock() + .map_err(|_| anyhow::anyhow!("Capture error state poisoned"))? + .take() + { + bail!("{error}"); + } + emit(SpeechEvent::Recognizing); + let samples = { + let mut buffer = audio + .lock() + .map_err(|_| anyhow::anyhow!("Audio buffer poisoned"))?; + std::mem::take(&mut buffer.samples) + }; + decode_final(&samples, &control.cancel, |chunk| { + recognizer.decode(chunk, SAMPLE_RATE) + }) +} + +// Retain one verified model after first use. Loading/hash verification per key +// press creates multi-second latency and repeated large allocations. Replacing +// the configuration replaces the cache, while an in-flight session owns its Arc. +type CachedRecognizer = Option<(RecognizerConfig, Arc)>; +static MODEL_CACHE: OnceLock> = OnceLock::new(); +fn cached_recognizer(config: RecognizerConfig) -> Result> { + let mut cache = MODEL_CACHE + .get_or_init(|| Mutex::new(None)) + .lock() + .map_err(|_| anyhow::anyhow!("Local model cache poisoned"))?; + if let Some((existing, recognizer)) = cache.as_ref() { + if existing == &config { + return Ok(Arc::clone(recognizer)); + } + } + let recognizer = Arc::new(Recognizer::new(config.clone())?); + *cache = Some((config, Arc::clone(&recognizer))); + Ok(recognizer) +} + +struct RecordingBuffer { + samples: Vec, + limit: usize, +} +impl RecordingBuffer { + fn append(&mut self, chunk: &[f32]) -> bool { + let count = chunk + .len() + .min(self.limit.saturating_sub(self.samples.len())); + self.samples.extend_from_slice(&chunk[..count]); + self.samples.len() == self.limit + } +} + +// Limit final inference tensors to about 20 seconds. Prefer a quiet 100 ms +// boundary in the last three seconds, preserving every recorded sample. Longer +// continuous speech can still split a word, a documented offline-model limit. +fn final_boundary(samples: &[f32]) -> usize { + let maximum = 20 * SAMPLE_RATE as usize; + if samples.len() <= maximum { + return samples.len(); + } + let window = SAMPLE_RATE as usize / 10; + let mut best = maximum; + let mut energy = f32::MAX; + for start in ((maximum - 3 * SAMPLE_RATE as usize)..maximum).step_by(window) { + let current = samples[start..start + window] + .iter() + .map(|sample| sample * sample) + .sum::(); + if current <= energy { + energy = current; + best = start + window / 2; + } + } + if energy / (window as f32) < 0.0004 { + best + } else { + maximum + } +} +fn decode_final( + samples: &[f32], + cancel: &CancellationToken, + mut decode: impl FnMut(&[f32]) -> Result, +) -> Result { + let mut remaining = samples; + let mut result = String::new(); + while !remaining.is_empty() { + cancel.check()?; + let boundary = final_boundary(remaining); + let text = decode(&remaining[..boundary]).context("Final local transcription failed")?; + cancel.check()?; + if !result.is_empty() + && !text.is_empty() + && result + .chars() + .last() + .is_some_and(|c| c.is_ascii_alphanumeric()) + && text + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric()) + { + result.push(' '); + } + result.push_str(&text); + remaining = &remaining[boundary..]; + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn recording_is_bounded_without_truncating_prior_audio() { + let mut buffer = RecordingBuffer { + samples: vec![], + limit: 5, + }; + assert!(!buffer.append(&[1., 2., 3.])); + assert!(buffer.append(&[4., 5., 6.])); + assert!(buffer.append(&[7.])); + assert_eq!(buffer.samples, [1., 2., 3., 4., 5.]); + } + #[test] + fn final_segments_are_bounded_and_cover_every_sample() { + let samples = vec![0.2; SAMPLE_RATE as usize * 65]; + let mut lengths = vec![]; + let text = decode_final(&samples, &CancellationToken::new(), |chunk| { + lengths.push(chunk.len()); + Ok("word".into()) + }) + .unwrap(); + assert_eq!(lengths.iter().sum::(), samples.len()); + assert!(lengths + .iter() + .all(|length| *length <= SAMPLE_RATE as usize * 20)); + assert_eq!(text, "word word word word"); + } + #[test] + fn cancellation_during_native_decode_discards_result_and_stops_followups() { + let cancel = CancellationToken::new(); + let mut calls = 0; + let result = decode_final(&vec![0.1; 40 * SAMPLE_RATE as usize], &cancel, |_| { + calls += 1; + cancel.cancel(); + Ok("must not be inserted".into()) + }); + assert!(result.is_err()); + assert_eq!(calls, 1); + } + #[test] + fn final_segmentation_prefers_silence() { + let mut samples = vec![0.2; SAMPLE_RATE as usize * 25]; + let start = 18 * SAMPLE_RATE as usize; + samples[start..start + SAMPLE_RATE as usize / 10].fill(0.); + assert_eq!(final_boundary(&samples), start + SAMPLE_RATE as usize / 20); + } +} diff --git a/ahakey-desktop/crates/speech/tests/known_answer.rs b/ahakey-desktop/crates/speech/tests/known_answer.rs new file mode 100644 index 00000000..3acbf526 --- /dev/null +++ b/ahakey-desktop/crates/speech/tests/known_answer.rs @@ -0,0 +1,77 @@ +use ahakey_speech::{Recognizer, RecognizerConfig, SAMPLE_RATE}; +use std::{path::PathBuf, time::Instant}; + +/// Real weights and an official fixed WAV are supplied explicitly. No test +/// downloads models or opens a microphone. Run this gate before packaging. +#[test] +#[ignore = "Requires AHAKEY_TEST_MODEL_DIR and AHAKEY_TEST_ZH_WAV (official pinned SenseVoice fixture)"] +fn native_sensevoice_known_answer() { + let model = PathBuf::from( + std::env::var_os("AHAKEY_TEST_MODEL_DIR").expect("Set AHAKEY_TEST_MODEL_DIR"), + ); + let wav = std::env::var_os("AHAKEY_TEST_ZH_WAV").expect("Set AHAKEY_TEST_ZH_WAV"); + let mut reader = hound::WavReader::open(wav).unwrap(); + assert_eq!(reader.spec().sample_rate, SAMPLE_RATE); + assert_eq!(reader.spec().channels, 1); + let samples: Vec = reader + .samples::() + .map(|sample| sample.unwrap() as f32 / 32768.) + .collect(); + let start = Instant::now(); + let recognizer = Recognizer::new(RecognizerConfig { + model_dir: model, + threads: 4, + }) + .unwrap(); + eprintln!("Native SenseVoice load+verify: {:?}", start.elapsed()); + let start = Instant::now(); + let result = recognizer.decode(&samples, SAMPLE_RATE).unwrap(); + eprintln!( + "Native SenseVoice {:.2}s audio: {:?}; transcript: {}", + samples.len() as f64 / SAMPLE_RATE as f64, + start.elapsed(), + result + ); + assert_eq!(result, "开饭时间早上9点至下午5点。"); + let mut partials = Vec::new(); + for seconds in [1, 2, 3, 4] { + let length = (seconds * SAMPLE_RATE as usize).min(samples.len()); + let partial = recognizer.decode(&samples[..length], SAMPLE_RATE).unwrap(); + if !partial.is_empty() && partials.last() != Some(&partial) { + partials.push(partial); + } + } + eprintln!("Provisional updates: {}", partials.len()); + assert!( + partials.len() >= 2, + "Real audio should produce changing provisional text" + ); + assert!(recognizer + .decode(&[0.; 1600], SAMPLE_RATE) + .unwrap() + .is_empty()); + assert!(recognizer.decode(&samples, 48000).is_err()); +} + +#[test] +#[ignore = "Requires AHAKEY_TEST_MODEL_DIR; imports the existing pinned model without downloading"] +fn verified_model_import() { + use ahakey_speech::{CancellationToken, ModelStore}; + use std::cell::Cell; + let source = PathBuf::from( + std::env::var_os("AHAKEY_TEST_MODEL_DIR").expect("Set AHAKEY_TEST_MODEL_DIR"), + ); + let target = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"); + let directory = tempfile::tempdir_in(target).unwrap(); + let store = ModelStore::new(directory.path().join("model")); + let last_progress = Cell::new(0.); + store + .import_from(&source, &CancellationToken::new(), |progress| { + assert!(progress >= last_progress.get() && progress <= 1.); + last_progress.set(progress); + }) + .unwrap(); + assert_eq!(last_progress.get(), 1.); + assert!(store.is_installed()); + store.verify().unwrap(); +} From 4e18380ea8bf27ac88c36317323325d0e823eb3d Mon Sep 17 00:00:00 2001 From: Lihao Date: Mon, 14 Sep 2026 02:58:51 -0700 Subject: [PATCH 03/21] feat(cloud): add Doubao streaming recognition and credential storage Introduce a standalone library and its focused tests. --- ahakey-desktop/crates/cloud/.gitignore | 2 + ahakey-desktop/crates/cloud/Cargo.toml | 28 + ahakey-desktop/crates/cloud/README.md | 59 ++ .../crates/cloud/src/credentials.rs | 249 +++++++ ahakey-desktop/crates/cloud/src/lib.rs | 36 + ahakey-desktop/crates/cloud/src/protocol.rs | 198 ++++++ ahakey-desktop/crates/cloud/src/session.rs | 649 ++++++++++++++++++ 7 files changed, 1221 insertions(+) create mode 100644 ahakey-desktop/crates/cloud/.gitignore create mode 100644 ahakey-desktop/crates/cloud/Cargo.toml create mode 100644 ahakey-desktop/crates/cloud/README.md create mode 100644 ahakey-desktop/crates/cloud/src/credentials.rs create mode 100644 ahakey-desktop/crates/cloud/src/lib.rs create mode 100644 ahakey-desktop/crates/cloud/src/protocol.rs create mode 100644 ahakey-desktop/crates/cloud/src/session.rs diff --git a/ahakey-desktop/crates/cloud/.gitignore b/ahakey-desktop/crates/cloud/.gitignore new file mode 100644 index 00000000..e9e21997 --- /dev/null +++ b/ahakey-desktop/crates/cloud/.gitignore @@ -0,0 +1,2 @@ +/target/ +/Cargo.lock diff --git a/ahakey-desktop/crates/cloud/Cargo.toml b/ahakey-desktop/crates/cloud/Cargo.toml new file mode 100644 index 00000000..e1d38d04 --- /dev/null +++ b/ahakey-desktop/crates/cloud/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ahakey-cloud" +version = "0.1.0" +edition = "2021" +rust-version = "1.85" + +[dependencies] +flate2 = "1" +futures-util = { version = "0.3", features = ["sink"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["rt", "sync", "time", "net", "macros"] } +tokio-util = "0.7" +tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] } +uuid = { version = "1", features = ["v4"] } +zeroize = "1" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security_Cryptography"] } + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3", features = ["apple-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] } + +[dev-dependencies] +tempfile = "3" diff --git a/ahakey-desktop/crates/cloud/README.md b/ahakey-desktop/crates/cloud/README.md new file mode 100644 index 00000000..b2e1deb1 --- /dev/null +++ b/ahakey-desktop/crates/cloud/README.md @@ -0,0 +1,59 @@ +# Native cloud speech + +`ahakey-cloud` connects to Doubao v3 ASR only when the caller explicitly starts +a cloud session. It has no microphone capture, subprocess, automatic provider +fallback, Tauri dependency, or settings serialization. + +## Integration + +1. Save the user's token through `CredentialStore::new(app_data_directory).save(&token)`. + Keep `load()` inside the native backend and expose only `has_token()` to the UI. +2. From a Tokio runtime, call `CloudSession::start(CloudConfig { app_id, + resource_id }, store.load()?, callback)` after the user selects cloud speech + and starts recording. +3. Feed 16 kHz mono signed PCM16 little-endian samples through + `try_send_pcm16(&samples)`. Each chunk contains 1 to 3200 samples. Split + larger capture buffers; do not drop data when the queue reports an error. +4. On release, stop capture and await `finish()`. The session emits partial + text, then exactly one final result on success, or one sanitized error on + failure. Silence can produce an empty final; a failed session does not emit + a fake final transcript. `wait(self)` joins task completion. +5. `cancel()` interrupts all network waits and suppresses future callbacks. + Dropping the session cancels it. Callbacks should enqueue quickly; a final + handler may safely cancel or drop its own session. + +The endpoint is fixed to +`wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async`. Request headers, +configuration, gzip framing, sequence flags and audio encoding follow the +[official v3 protocol](https://www.volcengine.com/docs/6561/1354869), matching +the repository's existing Java protocol implementation. + +Limits: 80 queued packets, 3200 samples per packet, five minutes per recording, +1 MiB compressed WebSocket message/frame and decompressed JSON body, 15 seconds +to connect, 10 seconds per send, and 20 seconds for a final response after the +last audio frame. Credentials and raw server error bodies are never included +in library errors. Account, network and resource authorization still require +testing with a user-configured account. + +## Native credential storage + +- Windows: current-user DPAPI, encrypted file under the supplied app data + directory, atomic replacement. No plaintext file is created. +- macOS: Keychain using the maintained `keyring` crate. +- Linux: Secret Service using `keyring`; building requires the system D-Bus + development library and use requires an available unlocked Secret Service. + +There is no plaintext fallback. macOS/Linux use service `AhaKey Studio` and +account `doubao-access-token`, independent of the installed application path. +The Windows path belongs to the caller so isolated tests never touch real +user credentials. macOS/Linux backends require native-host acceptance; Windows +compilation does not establish their runtime availability. + +## Verification + +Run `cargo test --manifest-path Cargo.toml` and +`cargo clippy --manifest-path Cargo.toml --all-targets -- -D warnings`. +Windows tests exercise actual DPAPI with isolated fake tokens. Loopback +WebSocket tests verify headers, wire audio, fragmented frames, duplicate final +suppression, bounded queues, cancellation and connection/final timeouts. +These tests never read existing account credentials or call the cloud API. diff --git a/ahakey-desktop/crates/cloud/src/credentials.rs b/ahakey-desktop/crates/cloud/src/credentials.rs new file mode 100644 index 00000000..c2941c54 --- /dev/null +++ b/ahakey-desktop/crates/cloud/src/credentials.rs @@ -0,0 +1,249 @@ +use crate::CloudError; +use std::path::{Path, PathBuf}; +use zeroize::Zeroizing; + +/// Backend-only secret. Not serializable; Debug intentionally redacts it. +pub struct SecretToken(Zeroizing); +impl std::fmt::Debug for SecretToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SecretToken([REDACTED])") + } +} +impl SecretToken { + pub fn new(value: impl Into) -> Result { + let value = Zeroizing::new(value.into()); + if value.is_empty() || value.len() > 8192 || !value.bytes().all(|b| b.is_ascii_graphic()) { + return Err(CloudError::InvalidConfig); + } + Ok(Self(value)) + } + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} + +/// Windows encrypts a blob with current-user DPAPI; macOS uses Keychain and Linux +/// uses Secret Service. A locked/unavailable native store returns an error. +/// `directory` must be the application's private local data directory, not its executable directory. +#[derive(Clone)] +pub struct CredentialStore { + directory: PathBuf, +} +impl CredentialStore { + pub fn new(directory: impl AsRef) -> Self { + Self { + directory: directory.as_ref().to_owned(), + } + } + pub fn has_token(&self) -> Result { + match self.load() { + Ok(_) => Ok(true), + Err(CloudError::MissingCredential) => Ok(false), + Err(e) => Err(e), + } + } + pub fn save(&self, token: &str) -> Result<(), CloudError> { + let token = SecretToken::new(token.to_owned())?; + platform::save(&self.directory, token.expose()) + } + pub fn load(&self) -> Result { + platform::load(&self.directory) + } + pub fn clear(&self) -> Result<(), CloudError> { + platform::clear(&self.directory) + } +} + +#[cfg(windows)] +mod platform { + use super::*; + use std::{ + fs::{self, OpenOptions}, + io::{Read, Write}, + }; + use windows_sys::Win32::{ + Foundation::LocalFree, + Security::Cryptography::{ + CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, + }, + }; + const FILE: &str = "doubao-token.dpapi"; + const MAX_BLOB: u64 = 65536; + fn crypt(bytes: &[u8], protect: bool) -> Result>, CloudError> { + let input = CRYPT_INTEGER_BLOB { + cbData: bytes.len() as u32, + pbData: bytes.as_ptr() as *mut u8, + }; + let mut output = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + let ok = unsafe { + if protect { + CryptProtectData( + &input, + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + } else { + CryptUnprotectData( + &input, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + } + }; + if ok == 0 { + return Err(CloudError::CredentialStore); + } + // DPAPI owns this allocation. Copy, wipe, then release on every successful call. + let result = unsafe { + let raw = std::slice::from_raw_parts_mut(output.pbData, output.cbData as usize); + let copied = Zeroizing::new(raw.to_vec()); + zeroize::Zeroize::zeroize(raw); + LocalFree(output.pbData as *mut _); + copied + }; + Ok(result) + } + pub fn save(directory: &Path, token: &str) -> Result<(), CloudError> { + let encrypted = crypt(token.as_bytes(), true)?; + fs::create_dir_all(directory).map_err(|_| CloudError::CredentialStore)?; + let temporary = directory.join(format!("doubao-token-{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + let mut f = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|_| CloudError::CredentialStore)?; + f.write_all(&encrypted) + .and_then(|_| f.sync_all()) + .map_err(|_| CloudError::CredentialStore)?; + drop(f); + fs::rename(&temporary, directory.join(FILE)).map_err(|_| CloudError::CredentialStore) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result + } + pub fn load(directory: &Path) -> Result { + let f = fs::File::open(directory.join(FILE)).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + CloudError::MissingCredential + } else { + CloudError::CredentialStore + } + })?; + let mut encrypted = Zeroizing::new(Vec::new()); + f.take(MAX_BLOB + 1) + .read_to_end(&mut encrypted) + .map_err(|_| CloudError::CredentialStore)?; + if encrypted.len() > MAX_BLOB as usize { + return Err(CloudError::CredentialStore); + } + let plaintext = crypt(&encrypted, false)?; + let text = std::str::from_utf8(&plaintext).map_err(|_| CloudError::CredentialStore)?; + SecretToken::new(text.to_owned()).map_err(|_| CloudError::CredentialStore) + } + pub fn clear(directory: &Path) -> Result<(), CloudError> { + match fs::remove_file(directory.join(FILE)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(CloudError::CredentialStore), + } + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +mod platform { + use super::*; + fn entry() -> Result { + keyring::Entry::new("AhaKey Studio", "doubao-access-token") + .map_err(|_| CloudError::CredentialStore) + } + pub fn save(_: &Path, token: &str) -> Result<(), CloudError> { + entry()? + .set_password(token) + .map_err(|_| CloudError::CredentialStore) + } + pub fn load(_: &Path) -> Result { + let value = entry()?.get_password().map_err(|e| match e { + keyring::Error::NoEntry => CloudError::MissingCredential, + _ => CloudError::CredentialStore, + })?; + SecretToken::new(value).map_err(|_| CloudError::CredentialStore) + } + pub fn clear(_: &Path) -> Result<(), CloudError> { + match entry()?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => Err(CloudError::CredentialStore), + } + } +} +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] +mod platform { + use super::*; + pub fn save(_: &Path, _: &str) -> Result<(), CloudError> { + Err(CloudError::CredentialStore) + } + pub fn load(_: &Path) -> Result { + Err(CloudError::CredentialStore) + } + pub fn clear(_: &Path) -> Result<(), CloudError> { + Err(CloudError::CredentialStore) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn secrets_are_redacted_and_headers_validated() { + assert_eq!( + format!("{:?}", SecretToken::new("fake-token").unwrap()), + "SecretToken([REDACTED])" + ); + assert!(SecretToken::new("token\r\nX-Evil: x").is_err()); + } + #[cfg(windows)] + #[test] + fn real_dpapi_roundtrip_replacement_and_clear() { + let dir = tempfile::tempdir().unwrap(); + let store = CredentialStore::new(dir.path()); + assert!(!store.has_token().unwrap()); + store.save("fake-isolated-test-token").unwrap(); + assert!(store.has_token().unwrap()); + assert_eq!(store.load().unwrap().expose(), "fake-isolated-test-token"); + let bytes = std::fs::read(dir.path().join("doubao-token.dpapi")).unwrap(); + assert!(!bytes + .windows(b"fake-isolated-test-token".len()) + .any(|w| w == b"fake-isolated-test-token")); + store.save("replacement-fake-token").unwrap(); + assert_eq!(store.load().unwrap().expose(), "replacement-fake-token"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + store.clear().unwrap(); + store.clear().unwrap(); + assert!(!store.has_token().unwrap()); + } + #[cfg(windows)] + #[test] + fn corrupt_blob_does_not_fall_back_to_plaintext() { + let dir = tempfile::tempdir().unwrap(); + let store = CredentialStore::new(dir.path()); + std::fs::write( + dir.path().join("doubao-token.dpapi"), + b"fake-plaintext-token", + ) + .unwrap(); + assert_eq!(store.load().unwrap_err(), CloudError::CredentialStore); + } +} diff --git a/ahakey-desktop/crates/cloud/src/lib.rs b/ahakey-desktop/crates/cloud/src/lib.rs new file mode 100644 index 00000000..6034c309 --- /dev/null +++ b/ahakey-desktop/crates/cloud/src/lib.rs @@ -0,0 +1,36 @@ +//! Explicitly selected cloud ASR. No microphone capture or automatic cloud fallback. +mod credentials; +pub mod protocol; +mod session; +pub use credentials::{CredentialStore, SecretToken}; +pub use session::{CloudConfig, CloudEvent, CloudSession, ENDPOINT}; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CloudError { + #[error("Cloud speech configuration is incomplete or invalid")] + InvalidConfig, + #[error("Cloud connection or authentication failed")] + Connection, + #[error("Cloud speech operation timed out")] + Timeout, + #[error("Cloud speech response is invalid")] + Protocol, + #[error("Cloud speech response exceeds the size limit")] + TooLarge, + #[error("Cloud speech service returned code {0}")] + Service(u32), + #[error("Cloud audio queue is full; start a new recording")] + QueueFull, + #[error("Cloud speech session has ended")] + Ended, + #[error("Cloud speech session was cancelled")] + Cancelled, + #[error("Cloud audio must contain 1 to 3200 samples of 16 kHz mono PCM")] + InvalidAudio, + #[error("Cloud recording exceeds the five minute limit")] + RecordingLimit, + #[error("Secure credential storage is unavailable")] + CredentialStore, + #[error("No cloud speech credential is saved")] + MissingCredential, +} diff --git a/ahakey-desktop/crates/cloud/src/protocol.rs b/ahakey-desktop/crates/cloud/src/protocol.rs new file mode 100644 index 00000000..64042c7a --- /dev/null +++ b/ahakey-desktop/crates/cloud/src/protocol.rs @@ -0,0 +1,198 @@ +//! Volcengine v3 ASR framing: . +use crate::CloudError; +use flate2::{read::GzDecoder, write::GzEncoder, Compression}; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +pub const MAX_FRAME_BYTES: usize = 1024 * 1024; + +#[derive(Debug, PartialEq, Eq)] +pub struct ResultText { + pub text: String, + pub is_final: bool, +} + +pub fn configuration() -> Result, CloudError> { + let request = json!({"user":{"uid":"ahakey-studio"}, + "audio":{"format":"pcm","codec":"raw","rate":16000,"bits":16,"channel":1}, + "request":{"model_name":"bigmodel","enable_itn":true,"enable_punc":true, + "result_type":"full","show_utterances":true,"enable_nonstream":true}}); + frame( + 1, + 0, + 1, + &serde_json::to_vec(&request).map_err(|_| CloudError::Protocol)?, + ) +} +pub fn audio(pcm: &[i16], last: bool) -> Result, CloudError> { + if pcm.len() > 3200 || (pcm.is_empty() && !last) { + return Err(CloudError::InvalidAudio); + } + let bytes: Vec = pcm.iter().flat_map(|v| v.to_le_bytes()).collect(); + frame(2, if last { 2 } else { 0 }, 0, &bytes) +} +fn frame(kind: u8, flags: u8, serialization: u8, payload: &[u8]) -> Result, CloudError> { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(payload) + .map_err(|_| CloudError::Protocol)?; + let compressed = encoder.finish().map_err(|_| CloudError::Protocol)?; + let mut result = vec![0x11, (kind << 4) | flags, (serialization << 4) | 1, 0]; + result.extend_from_slice(&(compressed.len() as u32).to_be_bytes()); + result.extend_from_slice(&compressed); + Ok(result) +} +pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_FRAME_BYTES { + return Err(CloudError::TooLarge); + } + if bytes.len() < 8 { + return Err(CloudError::Protocol); + } + let header = (bytes[0] & 15) as usize * 4; + if bytes[0] >> 4 != 1 || header < 4 || header > bytes.len() - 4 { + return Err(CloudError::Protocol); + } + let kind = bytes[1] >> 4; + let flags = bytes[1] & 15; + let mut cursor = header; + if flags > 3 { + return Err(CloudError::Protocol); + } + let code = if kind == 15 { + Some(read_u32(bytes, &mut cursor)?) + } else { + None + }; + let sequence = if kind != 15 && flags & 1 != 0 { + Some(read_u32(bytes, &mut cursor)? as i32) + } else { + None + }; + let length = read_u32(bytes, &mut cursor)? as usize; + if length != bytes.len() - cursor { + return Err(CloudError::Protocol); + } + let payload = match bytes[2] & 15 { + 0 => bytes[cursor..].to_vec(), + 1 => { + let mut out = Vec::new(); + GzDecoder::new(&bytes[cursor..]) + .take(MAX_FRAME_BYTES as u64 + 1) + .read_to_end(&mut out) + .map_err(|_| CloudError::Protocol)?; + if out.len() > MAX_FRAME_BYTES { + return Err(CloudError::TooLarge); + } + out + } + _ => return Err(CloudError::Protocol), + }; + // Validate even error frames, but never surface an arbitrary server error body. + if let Some(code) = code { + return Err(CloudError::Service(code)); + } + if kind != 9 || bytes[2] >> 4 != 1 { + return Err(CloudError::Protocol); + } + let json: Value = serde_json::from_slice(&payload).map_err(|_| CloudError::Protocol)?; + if !json.is_object() { + return Err(CloudError::Protocol); + } + if let Some(code) = json.get("code") { + let code = code + .as_u64() + .filter(|v| *v <= u32::MAX as u64) + .ok_or(CloudError::Protocol)? as u32; + if code != 0 && code != 20_000_000 { + return Err(CloudError::Service(code)); + } + } + let text = match &json["result"] { + Value::Array(results) => results.iter().filter_map(|v| v["text"].as_str()).collect(), + result => result["text"].as_str().unwrap_or("").to_owned(), + }; + Ok(ResultText { + text, + is_final: flags & 2 != 0 || sequence.is_some_and(|seq| seq < 0), + }) +} +fn read_u32(bytes: &[u8], cursor: &mut usize) -> Result { + let chunk = bytes + .get(*cursor..*cursor + 4) + .ok_or(CloudError::Protocol)?; + *cursor += 4; + Ok(u32::from_be_bytes( + chunk.try_into().map_err(|_| CloudError::Protocol)?, + )) +} +#[cfg(test)] +pub(crate) fn response(text: &str, flags: u8, sequence: i32) -> Vec { + let mut f = frame( + 9, + flags, + 1, + &serde_json::to_vec(&json!({"result":{"text":text}})).unwrap(), + ) + .unwrap(); + if flags & 1 != 0 { + f.splice(4..4, sequence.to_be_bytes()); + } + f +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn final_negative_sequence_and_array() { + assert_eq!( + parse(&response("hello", 3, -2)).unwrap(), + ResultText { + text: "hello".into(), + is_final: true + } + ); + assert!(parse(&response("negative", 1, -1)).unwrap().is_final); + assert!(!parse(&response("partial", 1, 1)).unwrap().is_final); + let f = frame(9, 0, 1, br#"{"result":[{"text":"a"},{"text":"b"}]}"#).unwrap(); + assert_eq!(parse(&f).unwrap().text, "ab"); + } + #[test] + fn rejects_lengths_versions_and_gzip_bombs() { + let mut good = response("ok", 0, 0); + for len in 0..8 { + assert!(parse(&good[..len]).is_err()); + } + good[0] = 0x21; + assert_eq!(parse(&good), Err(CloudError::Protocol)); + good[0] = 0x11; + good.push(0); + assert_eq!(parse(&good), Err(CloudError::Protocol)); + let bomb = frame(9, 0, 1, &vec![b' '; MAX_FRAME_BYTES + 1]).unwrap(); + assert_eq!(parse(&bomb), Err(CloudError::TooLarge)); + } + #[test] + fn error_body_is_bounded_and_never_exposed() { + let mut error = frame(15, 0, 1, b"sensitive echoed token").unwrap(); + error.splice(4..4, 45000001_u32.to_be_bytes()); + assert_eq!(parse(&error), Err(CloudError::Service(45000001))); + assert!(!parse(&error).unwrap_err().to_string().contains("sensitive")); + let mut bomb = frame(15, 0, 1, &vec![b'x'; MAX_FRAME_BYTES + 1]).unwrap(); + bomb.splice(4..4, 45000001_u32.to_be_bytes()); + assert_eq!(parse(&bomb), Err(CloudError::TooLarge)); + } + #[test] + fn configuration_and_pcm_are_wire_compatible() { + let cfg = configuration().unwrap(); + assert_eq!(&cfg[..4], &[0x11, 0x10, 0x11, 0]); + let mut body = String::new(); + GzDecoder::new(&cfg[8..]).read_to_string(&mut body).unwrap(); + let json: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(json["audio"]["rate"], 16000); + assert_eq!(json["request"]["enable_nonstream"], true); + let audio = audio(&[i16::MIN, 1, i16::MAX], true).unwrap(); + assert_eq!(&audio[..4], &[0x11, 0x22, 0x01, 0]); + let mut pcm = Vec::new(); + GzDecoder::new(&audio[8..]).read_to_end(&mut pcm).unwrap(); + assert_eq!(pcm, vec![0, 128, 1, 0, 255, 127]); + } +} diff --git a/ahakey-desktop/crates/cloud/src/session.rs b/ahakey-desktop/crates/cloud/src/session.rs new file mode 100644 index 00000000..b9efe289 --- /dev/null +++ b/ahakey-desktop/crates/cloud/src/session.rs @@ -0,0 +1,649 @@ +use crate::{protocol, CloudError, SecretToken}; +use futures_util::{SinkExt, StreamExt}; +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Condvar, Mutex, + }, + time::Duration, +}; +use tokio::{ + sync::mpsc, + task::JoinHandle, + time::{timeout, Instant}, +}; +use tokio_tungstenite::{ + connect_async_with_config, + tungstenite::{ + client::IntoClientRequest, http::HeaderValue, protocol::WebSocketConfig, Message, + }, +}; +use tokio_util::sync::CancellationToken; + +pub const ENDPOINT: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"; +const QUEUE_PACKETS: usize = 80; // At most sixteen seconds of 200 ms audio, never silently dropped. +const MAX_SAMPLES: usize = 16000 * 300; + +#[derive(Debug, Clone)] +pub struct CloudConfig { + pub app_id: String, + pub resource_id: String, +} +impl Default for CloudConfig { + fn default() -> Self { + Self { + app_id: String::new(), + resource_id: "volc.bigasr.sauc.duration".into(), + } + } +} +impl CloudConfig { + fn validate(&self) -> Result<(), CloudError> { + for value in [&self.app_id, &self.resource_id] { + if value.is_empty() || value.len() > 256 || !value.bytes().all(|b| b.is_ascii_graphic()) + { + return Err(CloudError::InvalidConfig); + } + } + Ok(()) + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CloudEvent { + Partial(String), + Final(String), + Error(CloudError), +} +enum Input { + Audio(Vec), + Finish, +} +type Callback = Arc; +struct Shared { + cancellation: CancellationToken, + callback_gate: Mutex, + callback_idle: Condvar, + done: AtomicBool, + failure: Mutex>, +} +struct CallbackGate { + enabled: bool, + executing: Option, +} +impl Shared { + fn emit(&self, callback: &Callback, event: CloudEvent) { + let mut gate = self.callback_gate.lock().unwrap_or_else(|p| p.into_inner()); + if !gate.enabled { + return; + } + if matches!(&event, CloudEvent::Final(_) | CloudEvent::Error(_)) { + gate.enabled = false; + } + gate.executing = Some(std::thread::current().id()); + drop(gate); + // Do not hold the gate while invoking user code: final handlers can safely + // drop/cancel their own session. Other threads wait for this callback to exit. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(event))); + let mut gate = self.callback_gate.lock().unwrap_or_else(|p| p.into_inner()); + gate.executing = None; + self.callback_idle.notify_all(); + drop(gate); + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } + } + fn cancel(&self) { + let mut gate = self.callback_gate.lock().unwrap_or_else(|p| p.into_inner()); + gate.enabled = false; + self.cancellation.cancel(); + while gate + .executing + .is_some_and(|id| id != std::thread::current().id()) + { + gate = self + .callback_idle + .wait(gate) + .unwrap_or_else(|p| p.into_inner()); + } + } + fn fail(&self, error: CloudError) { + *self.failure.lock().unwrap_or_else(|p| p.into_inner()) = Some(error); + self.cancellation.cancel(); + } + fn cancellation_error(&self) -> CloudError { + self.failure + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + .unwrap_or(CloudError::Cancelled) + } +} + +/// An explicit cloud recording. Construction starts a connection only after validating settings. +/// Caller supplies 16 kHz mono audio; no microphone or credentials are loaded implicitly. +/// Callbacks should enqueue quickly; cancelling or dropping the session from a callback is safe. +pub struct CloudSession { + input: mpsc::Sender, + accepting: Mutex, + shared: Arc, + task: Option>>, +} +impl CloudSession { + /// Requires an entered Tokio runtime. The fixed endpoint cannot be redirected through settings. + pub fn start( + config: CloudConfig, + token: SecretToken, + callback: impl Fn(CloudEvent) + Send + Sync + 'static, + ) -> Result { + Self::start_at( + config, + token, + Arc::new(callback), + ENDPOINT, + Limits::default(), + ) + } + fn start_at( + config: CloudConfig, + token: SecretToken, + callback: Callback, + endpoint: &str, + limits: Limits, + ) -> Result { + config.validate()?; + let runtime = tokio::runtime::Handle::try_current().map_err(|_| CloudError::Connection)?; + let (input, receiver) = mpsc::channel(QUEUE_PACKETS); + let shared = Arc::new(Shared { + cancellation: CancellationToken::new(), + callback_gate: Mutex::new(CallbackGate { + enabled: true, + executing: None, + }), + callback_idle: Condvar::new(), + done: AtomicBool::new(false), + failure: Mutex::new(None), + }); + let worker_shared = shared.clone(); + let endpoint = endpoint.to_owned(); + let task = runtime.spawn(async move { + let result = run( + config, + token, + receiver, + &worker_shared, + &callback, + &endpoint, + limits, + ) + .await; + if let Err(error) = &result { + if *error != CloudError::Cancelled { + worker_shared.emit(&callback, CloudEvent::Error(error.clone())); + } + } + worker_shared.done.store(true, Ordering::Release); + result + }); + Ok(Self { + input, + accepting: Mutex::new(true), + shared, + task: Some(task), + }) + } + /// Nonblocking bounded enqueue suitable for a microphone callback. No audio is silently dropped. + pub fn try_send_pcm16(&self, pcm: &[i16]) -> Result<(), CloudError> { + if pcm.is_empty() || pcm.len() > 3200 { + return Err(CloudError::InvalidAudio); + } + let accepting = self.accepting.lock().unwrap_or_else(|p| p.into_inner()); + if !*accepting + || self.shared.done.load(Ordering::Acquire) + || self.shared.cancellation.is_cancelled() + { + return Err(CloudError::Ended); + } + self.input + .try_send(Input::Audio(pcm.to_vec())) + .map_err(|e| match e { + mpsc::error::TrySendError::Full(_) => { + self.shared.fail(CloudError::QueueFull); + CloudError::QueueFull + } + mpsc::error::TrySendError::Closed(_) => CloudError::Ended, + }) + } + /// Queues end-of-audio after earlier audio. Repeated calls are harmless; no further audio is accepted. + pub async fn finish(&self) -> Result<(), CloudError> { + { + let mut accepting = self.accepting.lock().unwrap_or_else(|p| p.into_inner()); + if !*accepting { + return Ok(()); + } + *accepting = false; + } + tokio::select! {biased; + _=self.shared.cancellation.cancelled()=>Err(self.shared.cancellation_error()), + result=timeout(Duration::from_secs(10),self.input.send(Input::Finish))=>match result { + Ok(Ok(()))=>Ok(()),Ok(Err(_))=>Err(CloudError::Ended),Err(_)=>{self.shared.fail(CloudError::Timeout);Err(CloudError::Timeout)} + } + } + } + /// Suppresses future callbacks and interrupts connect, send, receive and final-result waits. + pub fn cancel(&self) { + self.shared.cancel(); + } + pub fn is_finished(&self) -> bool { + self.shared.done.load(Ordering::Acquire) + } + pub async fn wait(mut self) -> Result<(), CloudError> { + self.task + .take() + .ok_or(CloudError::Ended)? + .await + .map_err(|_| CloudError::Connection)? + } +} +impl Drop for CloudSession { + fn drop(&mut self) { + self.shared.cancel(); + } +} + +#[derive(Clone, Copy)] +struct Limits { + connect: Duration, + send: Duration, + final_result: Duration, + recording: Duration, +} +impl Default for Limits { + fn default() -> Self { + Self { + connect: Duration::from_secs(15), + send: Duration::from_secs(10), + final_result: Duration::from_secs(20), + recording: Duration::from_secs(300), + } + } +} +async fn run( + config: CloudConfig, + token: SecretToken, + mut input: mpsc::Receiver, + shared: &Shared, + callback: &Callback, + endpoint: &str, + limits: Limits, +) -> Result<(), CloudError> { + let mut request = endpoint + .into_client_request() + .map_err(|_| CloudError::InvalidConfig)?; + for (name, value) in [ + ("X-Api-App-Key", config.app_id.as_str()), + ("X-Api-Access-Key", token.expose()), + ("X-Api-Resource-Id", config.resource_id.as_str()), + ] { + let mut header = HeaderValue::from_str(value).map_err(|_| CloudError::InvalidConfig)?; + header.set_sensitive(true); + request.headers_mut().insert(name, header); + } + request.headers_mut().insert( + "X-Api-Connect-Id", + HeaderValue::from_str(&uuid::Uuid::new_v4().to_string()) + .map_err(|_| CloudError::InvalidConfig)?, + ); + let ws_config = WebSocketConfig::default() + .max_message_size(Some(protocol::MAX_FRAME_BYTES)) + .max_frame_size(Some(protocol::MAX_FRAME_BYTES)); + let (mut socket, _) = tokio::select! {biased; + _=shared.cancellation.cancelled()=>return Err(shared.cancellation_error()), + result=timeout(limits.connect,connect_async_with_config(request,Some(ws_config),false))=>result.map_err(|_|CloudError::Timeout)?.map_err(|_|CloudError::Connection)? + }; + drop(token); + tokio::select! {biased; + _=shared.cancellation.cancelled()=>return Err(shared.cancellation_error()), + result=timeout(limits.send,socket.send(Message::Binary(protocol::configuration()?.into())))=>result.map_err(|_|CloudError::Timeout)?.map_err(|_|CloudError::Connection)? + } + let mut deadline = Instant::now() + limits.recording; + let mut finishing = false; + let mut samples = 0; + loop { + tokio::select! {biased; + _=shared.cancellation.cancelled()=>return Err(shared.cancellation_error()), + _=tokio::time::sleep_until(deadline)=>return Err(if finishing {CloudError::Timeout} else {CloudError::RecordingLimit}), + message=socket.next()=>match message { + Some(Ok(Message::Binary(bytes)))=>{ + let result=protocol::parse(&bytes)?; + if result.is_final { + shared.emit(callback,CloudEvent::Final(result.text)); + return Ok(()); + } + if !result.text.is_empty() {shared.emit(callback,CloudEvent::Partial(result.text));} + }, + Some(Ok(Message::Ping(bytes)))=>{ + tokio::select! {biased; + _=shared.cancellation.cancelled()=>return Err(shared.cancellation_error()), + result=timeout(limits.send,socket.send(Message::Pong(bytes)))=>result.map_err(|_|CloudError::Timeout)?.map_err(|_|CloudError::Connection)? + } + }, + Some(Ok(Message::Pong(_)))=>{}, + Some(Ok(Message::Close(_)))|None=>return Err(CloudError::Connection), + Some(Err(_))=>return Err(CloudError::Connection), + _=>return Err(CloudError::Protocol), + }, + item=input.recv(),if !finishing=>{ + let (pcm,last)=match item {Some(Input::Audio(pcm))=>(pcm,false),Some(Input::Finish)|None=>(Vec::new(),true)}; + samples+=pcm.len(); if samples>MAX_SAMPLES {return Err(CloudError::RecordingLimit);} + tokio::select! {biased; + _=shared.cancellation.cancelled()=>return Err(shared.cancellation_error()), + result=timeout(limits.send,socket.send(Message::Binary(protocol::audio(&pcm,last)?.into())))=>result.map_err(|_|CloudError::Timeout)?.map_err(|_|CloudError::Connection)? + } + if last {finishing=true;deadline=Instant::now()+limits.final_result;} + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::net::TcpListener; + use tokio_tungstenite::{ + accept_async, accept_hdr_async, + tungstenite::{ + handshake::server::{Request, Response}, + protocol::frame::{ + coding::{Data, OpCode}, + Frame, + }, + }, + }; + fn config() -> CloudConfig { + CloudConfig { + app_id: "fake-app".into(), + ..Default::default() + } + } + fn token() -> SecretToken { + SecretToken::new("fake-mock-token").unwrap() + } + fn capture() -> (Arc>>, Callback) { + let events = Arc::new(Mutex::new(Vec::new())); + let copy = events.clone(); + ( + events, + Arc::new(move |event| copy.lock().unwrap().push(event)), + ) + } + async fn listener() -> (TcpListener, String) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("ws://{}/asr", listener.local_addr().unwrap()); + (listener, endpoint) + } + #[tokio::test] + #[allow(clippy::result_large_err)] // Third-party handshake callback fixes the error response type. + async fn mock_fragmented_partial_final_headers_and_wire_audio() { + let (listener, endpoint) = listener().await; + let (events, callback) = capture(); + let server = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let mut ws = accept_hdr_async(tcp, |request: &Request, response: Response| { + assert_eq!(request.headers()["X-Api-App-Key"], "fake-app"); + assert_eq!(request.headers()["X-Api-Access-Key"], "fake-mock-token"); + assert_eq!( + request.headers()["X-Api-Resource-Id"], + "volc.bigasr.sauc.duration" + ); + assert!(uuid::Uuid::parse_str( + request.headers()["X-Api-Connect-Id"].to_str().unwrap() + ) + .is_ok()); + Ok(response) + }) + .await + .unwrap(); + assert_eq!( + ws.next().await.unwrap().unwrap(), + Message::Binary(protocol::configuration().unwrap().into()) + ); + assert_eq!( + ws.next().await.unwrap().unwrap(), + Message::Binary(protocol::audio(&[1, -2, 3], false).unwrap().into()) + ); + let partial = protocol::response("hello", 1, 1); + let split = partial.len() / 2; + ws.send(Message::Frame(Frame::message( + partial[..split].to_vec(), + OpCode::Data(Data::Binary), + false, + ))) + .await + .unwrap(); + ws.send(Message::Frame(Frame::message( + partial[split..].to_vec(), + OpCode::Data(Data::Continue), + true, + ))) + .await + .unwrap(); + assert_eq!( + ws.next().await.unwrap().unwrap(), + Message::Binary(protocol::audio(&[], true).unwrap().into()) + ); + ws.feed(Message::Binary( + protocol::response("hello world", 3, -2).into(), + )) + .await + .unwrap(); + ws.feed(Message::Binary( + protocol::response("duplicate final", 3, -3).into(), + )) + .await + .unwrap(); + ws.flush().await.unwrap(); + }); + let session = + CloudSession::start_at(config(), token(), callback, &endpoint, Limits::default()) + .unwrap(); + session.try_send_pcm16(&[1, -2, 3]).unwrap(); + session.finish().await.unwrap(); + session.finish().await.unwrap(); + assert_eq!(session.try_send_pcm16(&[4]), Err(CloudError::Ended)); + timeout(Duration::from_secs(5), session.wait()) + .await + .unwrap() + .unwrap(); + server.await.unwrap(); + assert_eq!( + *events.lock().unwrap(), + vec![ + CloudEvent::Partial("hello".into()), + CloudEvent::Final("hello world".into()) + ] + ); + } + #[tokio::test] + async fn cancellation_interrupts_handshake_and_suppresses_callbacks() { + let (listener, endpoint) = listener().await; + let (events, callback) = capture(); + let session = + CloudSession::start_at(config(), token(), callback, &endpoint, Limits::default()) + .unwrap(); + let (_tcp, _) = listener.accept().await.unwrap(); + session.cancel(); + assert_eq!( + timeout(Duration::from_secs(1), session.wait()) + .await + .unwrap(), + Err(CloudError::Cancelled) + ); + assert!(events.lock().unwrap().is_empty()); + } + #[tokio::test] + async fn stalled_final_response_times_out_once() { + let (listener, endpoint) = listener().await; + let (events, callback) = capture(); + let server = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let mut ws = accept_async(tcp).await.unwrap(); + ws.next().await.unwrap().unwrap(); + ws.next().await.unwrap().unwrap(); + // Wait for client timeout to close the connection, without inventing a final. + let _ = ws.next().await; + }); + let limits = Limits { + final_result: Duration::from_millis(40), + ..Limits::default() + }; + let session = + CloudSession::start_at(config(), token(), callback, &endpoint, limits).unwrap(); + session.finish().await.unwrap(); + assert_eq!( + timeout(Duration::from_secs(2), session.wait()) + .await + .unwrap(), + Err(CloudError::Timeout) + ); + server.await.unwrap(); + assert_eq!( + *events.lock().unwrap(), + vec![CloudEvent::Error(CloudError::Timeout)] + ); + } + #[tokio::test] + async fn stalled_handshake_has_a_deadline() { + let (listener, endpoint) = listener().await; + let (events, callback) = capture(); + let limits = Limits { + connect: Duration::from_millis(40), + ..Limits::default() + }; + let session = + CloudSession::start_at(config(), token(), callback, &endpoint, limits).unwrap(); + let (_tcp, _) = listener.accept().await.unwrap(); + assert_eq!( + timeout(Duration::from_secs(2), session.wait()) + .await + .unwrap(), + Err(CloudError::Timeout) + ); + assert_eq!( + *events.lock().unwrap(), + vec![CloudEvent::Error(CloudError::Timeout)] + ); + } + #[tokio::test] + async fn bounded_queue_fails_instead_of_dropping_audio() { + let (_listener, endpoint) = listener().await; + let (events, callback) = capture(); + let session = + CloudSession::start_at(config(), token(), callback, &endpoint, Limits::default()) + .unwrap(); + for _ in 0..QUEUE_PACKETS { + session.try_send_pcm16(&[1; 3200]).unwrap(); + } + assert_eq!(session.try_send_pcm16(&[2]), Err(CloudError::QueueFull)); + assert_eq!(session.wait().await, Err(CloudError::QueueFull)); + assert_eq!( + *events.lock().unwrap(), + vec![CloudEvent::Error(CloudError::QueueFull)] + ); + } + #[tokio::test] + async fn invalid_settings_never_start_a_session() { + let (_, callback) = capture(); + assert!(matches!( + CloudSession::start_at( + CloudConfig::default(), + token(), + callback, + ENDPOINT, + Limits::default() + ), + Err(CloudError::InvalidConfig) + )); + } + #[tokio::test] + async fn cancelled_live_session_emits_no_late_final() { + let (listener, endpoint) = listener().await; + let (events, callback) = capture(); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let (send_tx, send_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let mut ws = accept_async(tcp).await.unwrap(); + ws.next().await.unwrap().unwrap(); + ready_tx.send(()).unwrap(); + send_rx.await.unwrap(); + let _ = ws + .send(Message::Binary( + protocol::response("late secret text", 3, -1).into(), + )) + .await; + }); + let session = + CloudSession::start_at(config(), token(), callback, &endpoint, Limits::default()) + .unwrap(); + ready_rx.await.unwrap(); + session.cancel(); + send_tx.send(()).unwrap(); + assert_eq!(session.wait().await, Err(CloudError::Cancelled)); + server.await.unwrap(); + assert!(events.lock().unwrap().is_empty()); + } + fn shared_gate() -> Arc { + Arc::new(Shared { + cancellation: CancellationToken::new(), + callback_gate: Mutex::new(CallbackGate { + enabled: true, + executing: None, + }), + callback_idle: Condvar::new(), + done: AtomicBool::new(false), + failure: Mutex::new(None), + }) + } + #[test] + fn callback_can_cancel_its_own_session_without_deadlock() { + let shared = shared_gate(); + let callback_shared = shared.clone(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let callback_calls = calls.clone(); + let callback: Callback = Arc::new(move |_| { + callback_calls.fetch_add(1, Ordering::SeqCst); + callback_shared.cancel(); + }); + shared.emit(&callback, CloudEvent::Partial("first".into())); + shared.emit(&callback, CloudEvent::Final("must not appear".into())); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + #[test] + fn cross_thread_cancel_waits_for_an_active_callback() { + let shared = shared_gate(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let release_rx = Mutex::new(release_rx); + let callback: Callback = Arc::new(move |_| { + entered_tx.send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + }); + let worker_shared = shared.clone(); + let worker = std::thread::spawn(move || { + worker_shared.emit(&callback, CloudEvent::Partial("in flight".into())) + }); + entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let (cancelled_tx, cancelled_rx) = std::sync::mpsc::channel(); + let canceller = std::thread::spawn(move || { + shared.cancel(); + cancelled_tx.send(()).unwrap(); + }); + assert!(cancelled_rx + .recv_timeout(Duration::from_millis(20)) + .is_err()); + release_tx.send(()).unwrap(); + cancelled_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + worker.join().unwrap(); + canceller.join().unwrap(); + } +} From 75856cc2e5d001f352a4c822b4933225069c3f2f Mon Sep 17 00:00:00 2001 From: Lihao Date: Mon, 14 Sep 2026 02:58:52 -0700 Subject: [PATCH 04/21] feat(profiles): add four-key bindings and persisted input preferences Introduce the feature implementation and its focused tests/components. The following UI and runtime commits connect the shared application entry points. --- ahakey-desktop/src-tauri/src/input.rs | 88 +++++ ahakey-desktop/src-tauri/src/keys.rs | 161 ++++++++ ahakey-desktop/src-tauri/src/settings.rs | 476 +++++++++++++++++++++++ ahakey-desktop/src/FourKeysPanel.tsx | 53 +++ 4 files changed, 778 insertions(+) create mode 100644 ahakey-desktop/src-tauri/src/input.rs create mode 100644 ahakey-desktop/src-tauri/src/keys.rs create mode 100644 ahakey-desktop/src-tauri/src/settings.rs create mode 100644 ahakey-desktop/src/FourKeysPanel.tsx diff --git a/ahakey-desktop/src-tauri/src/input.rs b/ahakey-desktop/src-tauri/src/input.rs new file mode 100644 index 00000000..e7fa7490 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/input.rs @@ -0,0 +1,88 @@ +use crate::settings::TriggerMode; + +#[derive(Default)] +pub struct KeyTest { + was_down: bool, + active: bool, + mode: Option, +} + +impl KeyTest { + pub fn stop(&mut self) -> Option { + let changed = self.active; + *self = Self::default(); + changed.then_some(false) + } + + pub fn update(&mut self, down: bool, mode: TriggerMode) -> Option { + // A mode change ends the old session instead of leaving a toggle latched. + if self.mode.as_ref().is_some_and(|previous| *previous != mode) { + let was_active = self.active; + self.was_down = down; + self.active = false; + self.mode = Some(mode); + return was_active.then_some(false); + } + self.mode = Some(mode.clone()); + let event = if down && !self.was_down { + self.active = if mode == TriggerMode::Toggle { + !self.active + } else { + true + }; + Some(self.active) + } else if !down && self.was_down && mode == TriggerMode::Hold && self.active { + self.active = false; + Some(false) + } else { + None + }; + self.was_down = down; + event + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn repeated_presses_keep_hold_edges_paired() { + let mut input = KeyTest::default(); + for _ in 0..100 { + assert_eq!(input.update(true, TriggerMode::Hold), Some(true)); + assert_eq!(input.update(true, TriggerMode::Hold), None); + assert_eq!(input.update(false, TriggerMode::Hold), Some(false)); + assert_eq!(input.update(false, TriggerMode::Hold), None); + } + assert_eq!(input.stop(), None); + assert_eq!(input.update(true, TriggerMode::Hold), Some(true)); + assert_eq!(input.stop(), Some(false)); + } + #[test] + fn hold_has_one_start_and_one_end_without_repeat() { + let mut input = KeyTest::default(); + assert_eq!(input.update(true, TriggerMode::Hold), Some(true)); + assert_eq!(input.update(true, TriggerMode::Hold), None); + assert_eq!(input.update(false, TriggerMode::Hold), Some(false)); + assert_eq!(input.update(false, TriggerMode::Hold), None); + } + #[test] + fn toggle_ends_on_next_down_and_disable_cancels_active_session() { + let mut input = KeyTest::default(); + assert_eq!(input.update(true, TriggerMode::Toggle), Some(true)); + assert_eq!(input.update(false, TriggerMode::Toggle), None); + assert_eq!(input.update(true, TriggerMode::Toggle), Some(false)); + input.update(false, TriggerMode::Toggle); + input.update(true, TriggerMode::Toggle); + assert_eq!(input.stop(), Some(false)); + assert_eq!(input.stop(), None); + } + #[test] + fn changing_mode_cannot_leave_session_stuck() { + let mut input = KeyTest::default(); + input.update(true, TriggerMode::Toggle); + input.update(false, TriggerMode::Toggle); + assert_eq!(input.update(false, TriggerMode::Hold), Some(false)); + assert_eq!(input.update(true, TriggerMode::Hold), Some(true)); + } +} diff --git a/ahakey-desktop/src-tauri/src/keys.rs b/ahakey-desktop/src-tauri/src/keys.rs new file mode 100644 index 00000000..2a8ab80e --- /dev/null +++ b/ahakey-desktop/src-tauri/src/keys.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Binding { + pub action: Action, + pub shortcut: String, + pub label: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum Action { + Voice, + Shortcut, + Disabled, +} +pub fn defaults(accept: &str, reject: &str) -> Vec { + [ + (Action::Voice, "", "Voice"), + (Action::Shortcut, accept, "Accept"), + (Action::Shortcut, reject, "Cancel"), + (Action::Shortcut, "Backspace", "Backspace"), + ] + .into_iter() + .map(|(action, shortcut, label)| Binding { + action, + shortcut: shortcut.into(), + label: label.into(), + }) + .collect() +} +impl Binding { + pub fn hid(&self, mode: usize) -> Result, String> { + if self.label.len() > 80 || self.label.chars().any(char::is_control) { + return Err("按键名称过长或包含控制字符".into()); + } + match self.action { + Action::Voice => Ok(vec![if mode == 1 { 0x6c } else { 0x6d }]), + Action::Disabled => Ok(vec![]), + Action::Shortcut => parse_shortcut(&self.shortcut), + } + } +} +pub fn parse_shortcut(value: &str) -> Result, String> { + if value.len() > 80 { + return Err("快捷键过长".into()); + } + let parts: Vec<_> = value.split('+').map(str::trim).collect(); + let mut codes = vec![]; + for (index, part) in parts.iter().enumerate() { + let text = part.to_ascii_uppercase(); + let modifier = match text.as_str() { + "CTRL" | "CONTROL" => Some(0xe0), + "SHIFT" => Some(0xe1), + "ALT" => Some(0xe2), + "WIN" | "META" | "SUPER" => Some(0xe3), + _ => None, + }; + let code = + if index + 1 < parts.len() { + modifier.ok_or("组合键格式为 Ctrl+Shift+V,修饰键放前面")? + } else { + if modifier.is_some() { + return Err("修饰键后需要一个按键,例如 Ctrl+Enter".into()); + } + match text.as_str() { + "ENTER" => 0x28, + "ESC" | "ESCAPE" => 0x29, + "BACKSPACE" => 0x2a, + "TAB" => 0x2b, + "SPACE" => 0x2c, + "DELETE" => 0x4c, + "INSERT" => 0x49, + "HOME" => 0x4a, + "END" => 0x4d, + "PAGEUP" => 0x4b, + "PAGEDOWN" => 0x4e, + "RIGHT" | "ARROWRIGHT" => 0x4f, + "LEFT" | "ARROWLEFT" => 0x50, + "DOWN" | "ARROWDOWN" => 0x51, + "UP" | "ARROWUP" => 0x52, + "MINUS" => 0x2d, + "EQUAL" => 0x2e, + "COMMA" => 0x36, + "PERIOD" => 0x37, + "SLASH" => 0x38, + _ if text.len() == 1 && text.as_bytes()[0].is_ascii_uppercase() => { + 0x04 + text.as_bytes()[0] - b'A' + } + _ if text.len() == 1 && text.as_bytes()[0].is_ascii_digit() => { + if text == "0" { + 0x27 + } else { + 0x1e + text.as_bytes()[0] - b'1' + } + } + _ => match text + .strip_prefix('F') + .and_then(|n| n.parse::().ok()) + .filter(|n| (1..=12).contains(n)) + { + Some(n) => 0x3a + n - 1, + None => return Err( + "不支持此快捷键。可用字母、数字、F1–F12、Enter 等;F17/F18 保留给语音" + .into(), + ), + }, + } + }; + if codes.contains(&code) { + return Err("快捷键包含重复按键".into()); + } + codes.push(code); + } + Ok(codes) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn encodes_four_independent_bindings_and_modifiers() { + let keys = defaults("Ctrl+Enter", "Escape"); + assert_eq!(keys[0].hid(0).unwrap(), vec![0x6d]); + assert_eq!(keys[0].hid(1).unwrap(), vec![0x6c]); + assert_eq!(keys[1].hid(0).unwrap(), vec![0xe0, 0x28]); + assert_eq!(keys[3].hid(0).unwrap(), vec![0x2a]); + assert_eq!( + parse_shortcut("Ctrl+Shift+V").unwrap(), + vec![0xe0, 0xe1, 0x19] + ); + assert_eq!(parse_shortcut("F12").unwrap(), vec![0x45]); + } + #[test] + fn rejects_incomplete_ambiguous_and_reserved_shortcuts() { + for text in [ + "", + "Ctrl", + "Ctrl+", + "Ctrl+Ctrl+A", + "A+B", + "F17", + "F18", + "F99", + "run something", + ] { + assert!(parse_shortcut(text).is_err(), "{text}"); + } + } + #[test] + fn disabled_key_sends_no_usage() { + assert!(Binding { + action: Action::Disabled, + shortcut: "".into(), + label: "Off".into() + } + .hid(0) + .unwrap() + .is_empty()); + } +} diff --git a/ahakey-desktop/src-tauri/src/settings.rs b/ahakey-desktop/src-tauri/src/settings.rs new file mode 100644 index 00000000..06a8b4ba --- /dev/null +++ b/ahakey-desktop/src-tauri/src/settings.rs @@ -0,0 +1,476 @@ +use serde::{Deserialize, Serialize}; +use std::{fs, path::Path}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum Provider { + Local, + Doubao, + #[default] + Wechat, + WindowsNative, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum TriggerMode { + #[default] + Hold, + Toggle, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Profile { + pub id: String, + pub name: String, + pub accept: String, + pub reject: String, + #[serde(default)] + pub keys: Vec, + #[serde(default = "default_lights")] + pub light_effects: [u8; 9], +} +pub fn default_lights() -> [u8; 9] { + [11, 5, 1, 1, 1, 6, 6, 7, 0] +} + +pub fn default_profiles() -> Vec { + [ + ("claude-code", "Claude Code", "Y"), + ("claude-desktop", "Claude Desktop", "Enter"), + ("codex-cli", "Codex CLI", "Y"), + ("chatgpt-app", "ChatGPT App", "Enter"), + ] + .into_iter() + .map(|(id, name, accept)| Profile { + id: id.into(), + name: name.into(), + accept: accept.into(), + reject: if accept == "Y" { "N" } else { "Escape" }.into(), + keys: vec![], + light_effects: default_lights(), + }) + .collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Settings { + pub schema_version: u32, + pub provider: Provider, + pub trigger_mode: TriggerMode, + pub active_profile: String, + pub captions_enabled: bool, + pub caption_bottom_offset: u32, + pub profiles: Vec, + #[serde(default)] + pub microphone: Option, + #[serde(default)] + pub saved_device: Option, + #[serde(default)] + pub cloud_app_id: String, + #[serde(default = "default_resource")] + pub cloud_resource_id: String, + #[serde(default = "yes")] + pub auto_insert: bool, + #[serde(default = "yes")] + pub minimize_to_tray: bool, + #[serde(default = "brightness")] + pub light_brightness: u8, + #[serde(default = "yes")] + pub voice_keys_enabled: bool, +} + +fn yes() -> bool { + true +} +fn brightness() -> u8 { + 35 +} +fn default_resource() -> String { + "volc.bigasr.sauc.duration".into() +} + +impl Default for Settings { + fn default() -> Self { + Self { + schema_version: 1, + provider: if cfg!(windows) { + Provider::Wechat + } else { + Provider::Local + }, + trigger_mode: TriggerMode::Hold, + active_profile: "codex-cli".into(), + captions_enabled: true, + caption_bottom_offset: 20, + profiles: default_profiles(), + microphone: None, + saved_device: None, + cloud_app_id: String::new(), + cloud_resource_id: default_resource(), + auto_insert: true, + minimize_to_tray: true, + light_brightness: 35, + voice_keys_enabled: true, + } + } +} + +impl Settings { + pub fn start_voice_keys(&self, settings_error: bool) -> bool { + self.voice_keys_enabled && !settings_error + } + /// Apply only fields the caller edited. A stale open settings window must + /// not undo a tray selection on an unrelated field. + pub fn merge_changes(desired: &Self, base: &Self, current: &Self) -> Result { + let desired = serde_json::to_value(desired).map_err(|e| e.to_string())?; + let base = serde_json::to_value(base).map_err(|e| e.to_string())?; + let mut merged = serde_json::to_value(current).map_err(|e| e.to_string())?; + for (key, value) in desired.as_object().ok_or("无效设置")? { + if base.get(key) != Some(value) { + merged[key] = value.clone(); + } + } + serde_json::from_value(merged).map_err(|e| e.to_string()) + } + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != 1 { + return Err("配置版本不兼容,原文件已保留".into()); + } + if !(8..=160).contains(&self.caption_bottom_offset) { + return Err("字幕底部间距必须为 8–160".into()); + } + let expected = default_profiles(); + if !(1..=100).contains(&self.light_brightness) + || self.cloud_app_id.len() > 128 + || self.cloud_app_id.chars().any(|c| c.is_control()) + || self.saved_device.as_ref().is_some_and(|s| s.len() > 4096) + || self.microphone.as_ref().is_some_and(|s| s.len() > 1024) + { + return Err("设备或语音设置无效".into()); + } + if ![ + "volc.bigasr.sauc.duration", + "volc.bigasr.sauc.concurrent", + "volc.seedasr.sauc.duration", + "volc.seedasr.sauc.concurrent", + ] + .contains(&self.cloud_resource_id.as_str()) + { + return Err("不支持的豆包语音资源".into()); + } + if self.profiles.len() != expected.len() { + return Err("需要四个应用配置".into()); + } + for (profile, default) in self.profiles.iter().zip(&expected) { + if !profile.keys.is_empty() && profile.keys.len() != 4 { + return Err("每个模式需要四个按键定义".into()); + } + if profile + .keys + .iter() + .filter(|k| k.action == crate::keys::Action::Voice) + .count() + > 1 + { + return Err("每个模式最多设置一个语音键".into()); + } + for (index, key) in profile.keys.iter().enumerate() { + key.hid(0) + .map_err(|e| format!("{} · 按键 {}:{e}", profile.name, index + 1))?; + } + if profile.light_effects.iter().any(|&v| v > 16) { + return Err("不支持的灯效".into()); + } + if profile.id != default.id || profile.name != default.name { + return Err("应用配置标识不兼容".into()); + } + for key in [&profile.accept, &profile.reject] { + if !["Enter", "Escape", "Y", "N", "Tab", "Space"].contains(&key.as_str()) { + return Err("不支持的快捷键".into()); + } + } + } + if !expected.iter().any(|p| p.id == self.active_profile) { + return Err("应用配置不存在".into()); + } + Ok(()) + } +} + +pub fn load(path: &Path) -> Result { + if !path.exists() { + return Ok(Settings::default()); + } + let data = fs::read(path).map_err(|_| "无法读取设置文件,原文件已保留")?; + let settings: Settings = + serde_json::from_slice(&data).map_err(|_| "设置文件格式不正确,原文件已保留")?; + settings.validate()?; + Ok(settings) +} + +/// One-time settings import for the production identity. Never edits previews +/// or copies credentials/model files; an existing production config always wins. +pub fn load_for_launch(path: &Path) -> Result { + if path.exists() { + return load(path); + } + let Some(directory) = path.parent() else { + return load(path); + }; + if directory.file_name().and_then(|s| s.to_str()) != Some("ai.ahakey.studio") { + return load(path); + } + let Some(parent) = directory.parent() else { + return load(path); + }; + for namespace in [ + "ai.ahakey.studio.app006.routing.preview", + "ai.ahakey.studio.preview", + ] { + let candidate = parent.join(namespace).join("settings.json"); + if candidate.exists() { + let settings = load(&candidate)?; + save(path, &settings)?; + return Ok(settings); + } + } + load(path) +} + +pub fn save(path: &Path, settings: &Settings) -> Result<(), String> { + settings.validate()?; + // Refuse to silently replace an incompatible/corrupt file from another version. + load(path)?; + // Keep the pre-editor configuration so an older client can be restored + // without discarding user choices if its strict schema rejects new fields. + if let Ok(previous) = fs::read(path) { + if serde_json::from_slice::(&previous) + .ok() + .is_some_and(|v| v.get("voiceKeysEnabled").is_none()) + { + use std::io::Write; + let backup = path.with_extension("before-four-keys.json"); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(backup) + { + Ok(mut file) => { + file.write_all(&previous) + .and_then(|_| file.sync_all()) + .map_err(|_| "旧设置备份失败,未覆盖配置")?; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err("无法备份旧设置,未覆盖配置".into()), + } + } + } + let bytes = serde_json::to_vec_pretty(settings).map_err(|_| "无法编码设置")?; + write_atomic(path, &bytes) +} + +pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = path.parent().ok_or("无效的设置目录")?; + fs::create_dir_all(parent).map_err(|_| "无法创建设置目录")?; + let temporary = path.with_extension("json.pending"); + { + use std::io::Write; + let mut file = fs::File::create(&temporary).map_err(|_| "无法写入临时设置")?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|_| "保存设置失败")?; + } + // Windows MoveFileEx atomically replaces the destination on the same volume. + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + let src: Vec = temporary.as_os_str().encode_wide().chain(Some(0)).collect(); + let dst: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + if unsafe { + MoveFileExW( + src.as_ptr(), + dst.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + return Err("无法完成设置保存,原设置已保留".into()); + } + } + #[cfg(not(windows))] + fs::rename(&temporary, path).map_err(|_| "无法完成设置保存")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn production_import_preserves_preview_and_explicit_choices() { + let temp = tempfile::tempdir().unwrap(); + let preview = temp + .path() + .join("ai.ahakey.studio.app006.routing.preview/settings.json"); + let production = temp.path().join("ai.ahakey.studio/settings.json"); + let mut expected = Settings { + voice_keys_enabled: false, + active_profile: "chatgpt-app".into(), + ..Settings::default() + }; + save(&preview, &expected).unwrap(); + let before = fs::read(&preview).unwrap(); + assert_eq!(load_for_launch(&production).unwrap(), expected); + assert_eq!(fs::read(&preview).unwrap(), before); + expected.voice_keys_enabled = true; + save(&production, &expected).unwrap(); + assert_eq!(load_for_launch(&production).unwrap(), expected); + } + #[test] + fn voice_keys_default_on_but_explicit_off_and_bad_settings_stay_off() { + let defaults = Settings::default(); + assert!(defaults.voice_keys_enabled); + assert!(defaults.start_voice_keys(false)); + assert!(!defaults.start_voice_keys(true)); + let mut old = serde_json::to_value(&defaults).unwrap(); + old.as_object_mut().unwrap().remove("voiceKeysEnabled"); + assert!( + serde_json::from_value::(old.clone()) + .unwrap() + .voice_keys_enabled + ); + old["voiceKeysEnabled"] = false.into(); + assert!(!serde_json::from_value::(old) + .unwrap() + .start_voice_keys(false)); + } + #[test] + fn stale_ui_save_only_applies_edited_fields() { + let base = Settings::default(); + let mut desired = base.clone(); + desired.caption_bottom_offset = 44; + let current = Settings { + provider: Provider::Doubao, + active_profile: "chatgpt-app".into(), + ..base.clone() + }; + let merged = Settings::merge_changes(&desired, &base, ¤t).unwrap(); + assert_eq!(merged.provider, Provider::Doubao); + assert_eq!(merged.active_profile, "chatgpt-app"); + assert_eq!(merged.caption_bottom_offset, 44); + } + #[test] + fn four_keys_and_explicit_listener_choice_round_trip() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + let mut settings = Settings { + voice_keys_enabled: true, + ..Settings::default() + }; + settings.profiles[3].keys = crate::keys::defaults("Enter", "Escape"); + settings.profiles[3].keys[3].shortcut = "Ctrl+Shift+V".into(); + save(&path, &settings).unwrap(); + assert_eq!(load(&path).unwrap(), settings); + settings.profiles[3].keys[1].action = crate::keys::Action::Voice; + assert!(settings.validate().is_err()); + } + #[test] + fn first_editor_save_backs_up_old_settings_without_resetting_custom_keys() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + let mut old = serde_json::to_value(Settings::default()).unwrap(); + old.as_object_mut().unwrap().remove("voiceKeysEnabled"); + old["profiles"][3]["accept"] = "Tab".into(); + for profile in old["profiles"].as_array_mut().unwrap() { + profile.as_object_mut().unwrap().remove("keys"); + } + let bytes = serde_json::to_vec(&old).unwrap(); + fs::write(&path, &bytes).unwrap(); + let settings = load(&path).unwrap(); + save(&path, &settings).unwrap(); + assert_eq!( + fs::read(path.with_extension("before-four-keys.json")).unwrap(), + bytes + ); + assert_eq!(load(&path).unwrap().profiles[3].accept, "Tab"); + } + #[test] + fn migrates_preview_settings_without_losing_profiles() { + let mut old = serde_json::to_value(Settings::default()).unwrap(); + for key in [ + "microphone", + "savedDevice", + "cloudAppId", + "cloudResourceId", + "autoInsert", + "minimizeToTray", + "lightBrightness", + ] { + old.as_object_mut().unwrap().remove(key); + } + for profile in old["profiles"].as_array_mut().unwrap() { + profile.as_object_mut().unwrap().remove("lightEffects"); + } + let loaded: Settings = serde_json::from_value(old).unwrap(); + loaded.validate().unwrap(); + assert_eq!(loaded, Settings::default()); + } + #[test] + fn refuses_invalid_light_mapping_and_plaintext_secrets() { + let mut settings = Settings::default(); + settings.profiles[0].light_effects[1] = 17; + assert!(settings.validate().is_err()); + let mut value = serde_json::to_value(Settings::default()).unwrap(); + value["token"] = "must-not-be-persisted".into(); + assert!(serde_json::from_value::(value).is_err()); + } + #[test] + fn profiles_use_stable_ids_and_desktop_enter() { + let settings = Settings::default(); + settings.validate().unwrap(); + assert_eq!(settings.profiles[3].id, "chatgpt-app"); + assert_eq!(settings.profiles[3].accept, "Enter"); + assert_eq!(settings.profiles[2].accept, "Y"); + } + #[test] + fn save_reload_preserves_choice_and_replaces_existing_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + let mut settings = load(&path).unwrap(); + save(&path, &settings).unwrap(); + settings.provider = Provider::Doubao; + settings.active_profile = "chatgpt-app".into(); + settings.profiles[3].accept = "Tab".into(); + save(&path, &settings).unwrap(); + assert_eq!(load(&path).unwrap(), settings); + } + #[test] + fn refuse_corrupt_and_future_schema_without_overwrite() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + fs::write(&path, b"incomplete").unwrap(); + assert!(save(&path, &Settings::default()).is_err()); + assert_eq!(fs::read(&path).unwrap(), b"incomplete"); + let future = Settings { + schema_version: 2, + ..Settings::default() + }; + assert!(future.validate().is_err()); + } + #[test] + fn reject_out_of_bounds_overlay_and_arbitrary_key_sequences() { + let mut settings = Settings { + caption_bottom_offset: 0, + ..Settings::default() + }; + assert!(settings.validate().is_err()); + settings.caption_bottom_offset = 20; + settings.profiles[0].accept = "arbitrary shell command".into(); + assert!(settings.validate().is_err()); + } +} diff --git a/ahakey-desktop/src/FourKeysPanel.tsx b/ahakey-desktop/src/FourKeysPanel.tsx new file mode 100644 index 00000000..38f7f5bd --- /dev/null +++ b/ahakey-desktop/src/FourKeysPanel.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { effectiveKeys, KeyBinding, Settings, Snapshot } from "./contracts"; + +export function FourKeysPanel({snapshot:s,draft,patch,changed,fail}: { + snapshot:Snapshot|null; draft:Settings; patch:(value:Partial)=>void; changed:boolean; fail:(message:string)=>void; +}) { + const [selected,setSelected]=useState(0); + const [confirmed,setConfirmed]=useState(false); + const [busy,setBusy]=useState(false); + const profile=draft.profiles.find(p=>p.id===draft.activeProfile)!; + const keys=effectiveKeys(profile); + const binding=keys[selected]; + const isVoice=binding.action==="voice"; + const voiceCount=keys.filter(k=>k.action==="voice").length; + useEffect(()=>{setConfirmed(false);setSelected(0);},[profile.id]); + const update=(value:Partial)=>{ + setConfirmed(false); + patch({profiles:draft.profiles.map(p=>p.id===profile.id?{...p,keys:keys.map((k,i)=>i===selected?{...k,...value}:k)}:p)}); + }; + const run=async(command:string,args:Record)=>{ + setBusy(true);try{await invoke(command,args);}catch(e){fail(String(e));}finally{setBusy(false);} + }; + const observation=s?.keyObservation; + return
+

四个实体按键 · {profile.name}

点击按键逐一编辑
+
+ {keys.map((key,index)=>)} +
+
+ + +
+ {binding.action==="shortcut" && } + {isVoice &&

使用语音页选择的渠道(当前 {draft.provider})与触发方式({draft.triggerMode==="hold"?"按住说话":"按一下开始 / 停止"})。固件发出 F17/F18,Rust 再启动识别。

} + {voiceCount>1 &&

每个模式只能指定一个语音键,请把多余的语音键改为快捷键或禁用。

} +

编辑会自动保存到本机,但不会自动覆盖键盘。快捷键由键盘直接发往当前输入框,不经过语音识别。名称在固件上仅支持英文字符。

+
+ +
+

{s?.keyWriteNotice ?? "请先连接键盘"}

+
+
语音键监听:{s?.nativeKeyTestEnabled?"已开启":"已关闭"}

明确开启后会记住选择,下次启动自动恢复;不会自动开始录音。

+ +
+

{observation?.events?`最近收到 ${observation.key} ${observation.pressed?"按下":"松开"},本次共 ${observation.events} 个事件。`:"尚未收到 F17/F18。写入四键并启用监听后,在目标输入框按实物语音键检查此状态。"}

+

如果非语音键也没反应,请先确认当前模式四键已写入、Windows 已连接键盘,并把光标放进一个可输入的文本框。

+
; +} From 0a67e3c5e21d6eb83bdacfacbf92c4b56d3c88d5 Mon Sep 17 00:00:00 2001 From: Lihao Date: Mon, 14 Sep 2026 02:58:52 -0700 Subject: [PATCH 05/21] feat(voice): add hold-to-talk sessions and monitor-aware captions Introduce the feature implementation and its focused tests/components. The following UI and runtime commits connect the shared application entry points. --- .../scripts/probe-caption-monitors.ps1 | 55 +++ ahakey-desktop/src-tauri/src/caption.rs | 100 ++++ ahakey-desktop/src-tauri/src/platform.rs | 288 +++++++++++ ahakey-desktop/src-tauri/src/voice.rs | 454 ++++++++++++++++++ .../src-tauri/src/windows_voice_keys.rs | 258 ++++++++++ ahakey-desktop/src/ProviderPicker.tsx | 17 + 6 files changed, 1172 insertions(+) create mode 100644 ahakey-desktop/scripts/probe-caption-monitors.ps1 create mode 100644 ahakey-desktop/src-tauri/src/caption.rs create mode 100644 ahakey-desktop/src-tauri/src/platform.rs create mode 100644 ahakey-desktop/src-tauri/src/voice.rs create mode 100644 ahakey-desktop/src-tauri/src/windows_voice_keys.rs create mode 100644 ahakey-desktop/src/ProviderPicker.tsx diff --git a/ahakey-desktop/scripts/probe-caption-monitors.ps1 b/ahakey-desktop/scripts/probe-caption-monitors.ps1 new file mode 100644 index 00000000..f95ea0a3 --- /dev/null +++ b/ahakey-desktop/scripts/probe-caption-monitors.ps1 @@ -0,0 +1,55 @@ +[CmdletBinding()] +param([switch]$OpenTestWindows) +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +public static class AhaKeyMonitorProbe { + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } + [StructLayout(LayoutKind.Sequential)] public struct INFO { public uint Size; public RECT Monitor, Work; public uint Flags; } + [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X,Y; } + [StructLayout(LayoutKind.Sequential)] public struct MSG { public IntPtr Window; public uint Message; public UIntPtr WParam; public IntPtr LParam; public uint Time; public POINT Point; public uint Private; } + public delegate bool MonitorCallback(IntPtr monitor, IntPtr dc, ref RECT rect, IntPtr data); + [DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr value); + [DllImport("user32.dll")] static extern bool EnumDisplayMonitors(IntPtr dc,IntPtr rect,MonitorCallback callback,IntPtr data); + [DllImport("user32.dll")] static extern bool GetMonitorInfo(IntPtr monitor,ref INFO info); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern IntPtr CreateWindowEx(uint ex,string cls,string title,uint style,int x,int y,int width,int height,IntPtr parent,IntPtr menu,IntPtr instance,IntPtr param); + [DllImport("user32.dll")] static extern bool ShowWindow(IntPtr window,int command); + [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr window); + [DllImport("user32.dll")] public static extern bool DestroyWindow(IntPtr window); + [DllImport("user32.dll")] static extern bool PeekMessage(out MSG msg,IntPtr window,uint first,uint last,uint remove); + [DllImport("user32.dll")] static extern bool TranslateMessage(ref MSG msg); + [DllImport("user32.dll")] static extern IntPtr DispatchMessage(ref MSG msg); + public static INFO[] Monitors() { + var result=new List(); MonitorCallback callback=delegate(IntPtr monitor,IntPtr dc,ref RECT rect,IntPtr data) { + var info=new INFO { Size=(uint)Marshal.SizeOf(typeof(INFO)) };if(GetMonitorInfo(monitor,ref info))result.Add(info);return true; + };EnumDisplayMonitors(IntPtr.Zero,IntPtr.Zero,callback,IntPtr.Zero);return result.ToArray(); + } + public static IntPtr Open(INFO info,int index) { + var window=CreateWindowEx(0,"STATIC","AhaKey monitor probe "+index,0x00CF0000,info.Work.Left+60,info.Work.Top+60,300,140,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero); + if(window==IntPtr.Zero)throw new Exception("Cannot create monitor probe window");ShowWindow(window,4);return window; + } + public static void Pump() { MSG msg;while(PeekMessage(out msg,IntPtr.Zero,0,0,1)){TranslateMessage(ref msg);DispatchMessage(ref msg);} } +} +'@ +$priorContext = [AhaKeyMonitorProbe]::SetThreadDpiAwarenessContext([IntPtr](-4)) +$windows = [System.Collections.Generic.List[IntPtr]]::new() +try { + $index=0 + $observations=@(foreach($monitor in [AhaKeyMonitorProbe]::Monitors()) { + $index++ + $window=if($OpenTestWindows){[AhaKeyMonitorProbe]::Open($monitor,$index)}else{[IntPtr]::Zero} + if($window -ne [IntPtr]::Zero){$windows.Add($window)} + [pscustomobject]@{index=$index;window=$window.ToInt64();left=$monitor.Work.Left;top=$monitor.Work.Top;right=$monitor.Work.Right;bottom=$monitor.Work.Bottom;primary=($monitor.Flags -band 1)-ne 0} + }) + $observations | ConvertTo-Json -Compress + # The caller closes only these disposable windows after its placement checks. + while(@($windows | Where-Object {[AhaKeyMonitorProbe]::IsWindow($_)}).Count -gt 0) { + [AhaKeyMonitorProbe]::Pump() + Start-Sleep -Milliseconds 20 + } +} finally { + foreach($window in $windows){if([AhaKeyMonitorProbe]::IsWindow($window)){[AhaKeyMonitorProbe]::DestroyWindow($window)|Out-Null}} + [AhaKeyMonitorProbe]::SetThreadDpiAwarenessContext($priorContext)|Out-Null +} diff --git a/ahakey-desktop/src-tauri/src/caption.rs b/ahakey-desktop/src-tauri/src/caption.rs new file mode 100644 index 00000000..6e6f5469 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/caption.rs @@ -0,0 +1,100 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct Caption { + pub phase: String, + pub text: String, + pub sequence: u64, +} + +impl Caption { + pub fn idle() -> Self { + Self { + phase: "idle".into(), + text: String::new(), + sequence: 0, + } + } + #[cfg(test)] + pub fn test_press(&mut self) { + self.sequence += 1; + self.phase = "listening".into(); + self.text = "已收到按下 · 字幕位置测试,未录音".into(); + } + #[cfg(test)] + pub fn test_release(&mut self) { + self.sequence += 1; + self.phase = "final".into(); + self.text = "已收到松开 · 按键与字幕窗口测试完成".into(); + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Placement { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +pub fn place(left: i32, top: i32, right: i32, bottom: i32, scale: f64, offset: u32) -> Placement { + let margin = (16.0 * scale).round() as i32; + let width = ((560.0 * scale).round() as i32).min((right - left - 2 * margin).max(1)); + let height = ((106.0 * scale).round() as i32).min((bottom - top).max(1)); + Placement { + x: left + (right - left - width) / 2, + y: (bottom - height - (f64::from(offset) * scale).round() as i32).max(top), + width: width as u32, + height: height as u32, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn different_dpi_and_vertical_monitor_layouts_stay_in_target_work_area() { + for (left, top, right, bottom, scale) in [ + (0, -1440, 2560, 0, 1.5), + (1920, 0, 5760, 2080, 2.0), + (-2560, 120, 0, 1520, 1.25), + ] { + let p = place(left, top, right, bottom, scale, 20); + assert!(p.x >= left && p.y >= top); + assert!(p.x + p.width as i32 <= right && p.y + p.height as i32 <= bottom); + assert!((p.x + p.width as i32 / 2 - (left + right) / 2).abs() <= 1); + } + } + #[test] + fn places_on_negative_origin_secondary_monitor_above_taskbar() { + let p = place(-1920, 0, 0, 1040, 1.0, 20); + assert_eq!( + p, + Placement { + x: -1240, + y: 914, + width: 560, + height: 106 + } + ); + } + #[test] + fn uses_target_monitor_dpi_and_clamps_small_screen() { + let p = place(1920, 0, 4480, 1400, 1.5, 20); + assert_eq!(p.width, 840); + assert_eq!(p.y, 1211); + let small = place(0, 0, 320, 100, 2.0, 160); + assert!(small.x >= 0 && small.y >= 0 && small.width <= 320 && small.height <= 100); + } + #[test] + fn distinguishes_test_from_recognition_and_sequences_results() { + let mut caption = Caption::idle(); + caption.test_press(); + assert_eq!(caption.phase, "listening"); + assert!(caption.text.contains("未录音")); + caption.test_release(); + assert_eq!(caption.phase, "final"); + assert_eq!(caption.sequence, 2); + } +} diff --git a/ahakey-desktop/src-tauri/src/platform.rs b/ahakey-desktop/src-tauri/src/platform.rs new file mode 100644 index 00000000..aeb8544d --- /dev/null +++ b/ahakey-desktop/src-tauri/src/platform.rs @@ -0,0 +1,288 @@ +#[cfg(windows)] +static LAST_EXTERNAL_WINDOW: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); +#[cfg(windows)] +fn external_window(hwnd: windows_sys::Win32::Foundation::HWND) -> Option { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetClassNameW, GetWindowThreadProcessId, IsWindow, + }; + unsafe { + if hwnd.is_null() || IsWindow(hwnd) == 0 { + return None; + } + let mut pid = 0; + GetWindowThreadProcessId(hwnd, &mut pid); + if pid == 0 || pid == std::process::id() { + return None; + } + let mut class = [0u16; 80]; + let len = GetClassNameW(hwnd, class.as_mut_ptr(), class.len() as i32); + let class = String::from_utf16_lossy(&class[..len.max(0) as usize]); + if matches!( + class.as_str(), + "Shell_TrayWnd" | "Shell_SecondaryTrayWnd" | "Progman" | "WorkerW" | "#32768" + ) { + None + } else { + Some(hwnd as usize) + } + } +} +pub fn foreground() -> Option { + #[cfg(windows)] + unsafe { + external_window(windows_sys::Win32::UI::WindowsAndMessaging::GetForegroundWindow()) + } + #[cfg(not(windows))] + { + None + } +} +pub fn last_external_window() -> Option { + #[cfg(windows)] + { + foreground().or_else(|| { + external_window(LAST_EXTERNAL_WINDOW.load(std::sync::atomic::Ordering::Relaxed) as _) + }) + } + #[cfg(not(windows))] + { + None + } +} +pub fn watch_foreground() -> usize { + #[cfg(windows)] + unsafe { + use windows_sys::Win32::UI::{Accessibility::*, WindowsAndMessaging::*}; + unsafe extern "system" fn changed( + _: HWINEVENTHOOK, + _: u32, + hwnd: windows_sys::Win32::Foundation::HWND, + _: i32, + _: i32, + _: u32, + _: u32, + ) { + if let Some(window) = external_window(hwnd) { + LAST_EXTERNAL_WINDOW.store(window, std::sync::atomic::Ordering::Relaxed); + } + } + if let Some(window) = foreground() { + LAST_EXTERNAL_WINDOW.store(window, std::sync::atomic::Ordering::Relaxed); + } + // Metadata only: keep the last external HWND, never titles or contents. + SetWinEventHook( + EVENT_SYSTEM_FOREGROUND, + EVENT_SYSTEM_FOREGROUND, + std::ptr::null_mut(), + Some(changed), + 0, + 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS, + ) as usize + } + #[cfg(not(windows))] + { + 0 + } +} +pub fn stop_foreground_watch(hook: usize) { + #[cfg(windows)] + if hook != 0 { + unsafe { + windows_sys::Win32::UI::Accessibility::UnhookWinEvent(hook as _); + } + } + #[cfg(not(windows))] + let _ = hook; +} +#[cfg(windows)] +fn other_legacy_launcher(name: &str, pid: u32, current_pid: u32) -> bool { + pid != current_pid && name.eq_ignore_ascii_case("AhaKeyStudio.exe") +} +pub fn java_running() -> bool { + #[cfg(windows)] + unsafe { + use windows_sys::Win32::{ + Foundation::{CloseHandle, INVALID_HANDLE_VALUE}, + System::Diagnostics::ToolHelp::*, + }; + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot == INVALID_HANDLE_VALUE { + return false; + } + let mut item: PROCESSENTRY32W = std::mem::zeroed(); + item.dwSize = std::mem::size_of::() as u32; + let mut found = false; + let mut more = Process32FirstW(snapshot, &mut item); + while more != 0 { + let end = item + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(item.szExeFile.len()); + if other_legacy_launcher( + &String::from_utf16_lossy(&item.szExeFile[..end]), + item.th32ProcessID, + std::process::id(), + ) { + found = true; + break; + } + more = Process32NextW(snapshot, &mut item); + } + CloseHandle(snapshot); + found + } + #[cfg(not(windows))] + { + false + } +} +pub fn process_alive(pid: u32) -> bool { + #[cfg(windows)] + unsafe { + use windows_sys::Win32::{Foundation::CloseHandle, System::Threading::*}; + let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if h.is_null() { + return false; + } + let mut code = 0; + let ok = GetExitCodeProcess(h, &mut code) != 0 && code == 259; + CloseHandle(h); + ok + } + #[cfg(unix)] + { + pid > 0 + && pid <= i32::MAX as u32 + && (unsafe { libc::kill(pid as i32, 0) } == 0 + || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)) + } + #[cfg(not(any(windows, unix)))] + { + pid == std::process::id() + } +} +#[cfg(windows)] +fn key(vk: u16, scan: u16, flags: u32) -> windows_sys::Win32::UI::Input::KeyboardAndMouse::INPUT { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::*; + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: scan, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } +} +pub fn insert(target: Option, text: &str) -> Result<(), String> { + let target = target.ok_or("已识别;界面录音仅预览,请使用复制或在目标输入框按硬件键")?; + #[cfg(windows)] + unsafe { + use windows_sys::Win32::UI::{Input::KeyboardAndMouse::*, WindowsAndMessaging::*}; + if GetForegroundWindow() as usize != target || IsWindow(target as _) == 0 { + return Err("目标焦点已变化,文字保留在预览中,未自动输入".into()); + } + for vk in [0x10, 0x11, 0x12, 0x5b, 0x5c] { + if GetAsyncKeyState(vk) < 0 { + return Err("修饰键仍按住,文字保留在预览中".into()); + } + } + let events: Vec<_> = text + .encode_utf16() + .filter(|&u| u >= 32 || u == 10 || u == 13 || u == 9) + .flat_map(|u| { + [ + key(0, u, KEYEVENTF_UNICODE), + key(0, u, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP), + ] + }) + .collect(); + if events.is_empty() { + return Ok(()); + } + if SendInput( + events.len() as u32, + events.as_ptr(), + std::mem::size_of::() as i32, + ) != events.len() as u32 + { + return Err("系统拒绝输入;请检查目标窗口权限,文字仍在预览中".into()); + } + Ok(()) + } + #[cfg(not(windows))] + { + let _ = (target, text); + Err("此平台请从预览复制文字;原生输入适配待验证".into()) + } +} +#[cfg(windows)] +fn external_events(wechat: bool) -> Vec { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::KEYEVENTF_KEYUP; + // Match Java simulateKeyByHid(0x0B00), including generic VKs and order. + let keys: &[u16] = if wechat { + &[0x10, 0x11, 0x5b] + } else { + &[0x5b, 0x48] + }; + keys.iter() + .map(|&v| key(v, 0, 0)) + .chain(keys.iter().rev().map(|&v| key(v, 0, KEYEVENTF_KEYUP))) + .collect() +} +pub fn external_toggle(wechat: bool) -> Result<(), String> { + #[cfg(windows)] + unsafe { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::*; + let events = external_events(wechat); + if SendInput( + events.len() as u32, + events.as_ptr(), + std::mem::size_of::() as i32, + ) != events.len() as u32 + { + return Err("系统拒绝语音快捷键".into()); + } + Ok(()) + } + #[cfg(not(windows))] + { + let _ = wechat; + Err("微信和 Win+H 快捷键仅在 Windows 提供".into()) + } +} + +#[cfg(all(test, windows))] +mod shortcut_tests { + #[test] + fn renamed_rust_launcher_does_not_block_its_own_voice_keys() { + assert!(!super::other_legacy_launcher("AhaKeyStudio.exe", 7, 7)); + assert!(super::other_legacy_launcher("AhaKeyStudio.exe", 8, 7)); + assert!(!super::other_legacy_launcher("unrelated.exe", 8, 7)); + } + #[test] + fn wechat_packet_matches_java_generic_modifier_order() { + let events = super::external_events(true); + let actual: Vec<_> = events + .iter() + .map(|e| unsafe { (e.Anonymous.ki.wVk, e.Anonymous.ki.dwFlags) }) + .collect(); + assert_eq!( + actual, + vec![ + (0x10, 0), + (0x11, 0), + (0x5b, 0), + (0x5b, 2), + (0x11, 2), + (0x10, 2) + ] + ); + } +} diff --git a/ahakey-desktop/src-tauri/src/voice.rs b/ahakey-desktop/src-tauri/src/voice.rs new file mode 100644 index 00000000..c5085c6d --- /dev/null +++ b/ahakey-desktop/src-tauri/src/voice.rs @@ -0,0 +1,454 @@ +use crate::{ + settings::Provider, + state::{Runtime, SpeechStatus}, +}; +use ahakey_cloud::{CloudConfig, CloudEvent, CloudSession}; +use ahakey_speech::{MicrophoneCapture, SessionConfig, SpeechEvent, SpeechSession}; +use std::{ + sync::{atomic::Ordering, Arc, Mutex}, + time::Duration, +}; +use tauri::{Emitter, Manager}; +use tokio::sync::mpsc; +pub enum Active { + Local(SpeechSession), + Cloud { + session: Arc, + capture: Option, + pending: Arc>>, + }, + External { + wechat: bool, + }, +} +impl Active { + fn cancel(&mut self) { + match self { + Self::Local(s) => s.cancel(), + Self::Cloud { + session, capture, .. + } => { + if let Some(mut c) = capture.take() { + c.stop() + } + session.cancel() + } + Self::External { wechat } => { + let _ = crate::platform::external_toggle(*wechat); + } + } + } +} + +fn take_external(slot: &mut Option) -> Option { + if let Some(Active::External { wechat }) = slot.as_ref() { + let wechat = *wechat; + slot.take(); + Some(wechat) + } else { + None + } +} +enum Update { + Phase(&'static str, &'static str), + Partial(String), + Final(String), + Error(String), + Cancel, +} +fn post(tx: &mpsc::Sender, event: Update) { + match event { + Update::Partial(_) | Update::Phase(_, _) => { + let _ = tx.try_send(event); + } + _ => { + let sender = tx.clone(); + tauri::async_runtime::spawn(async move { + let _ = sender.send(event).await; + }); + } + } +} +pub async fn cancel(app: &tauri::AppHandle) { + let state = app.state::(); + let _gate = state.voice_gate.lock().await; + cancel_locked(app); +} +fn cancel_locked(app: &tauri::AppHandle) { + let state = app.state::(); + state.generation.fetch_add(1, Ordering::SeqCst); + state.recording.store(false, Ordering::SeqCst); + let active = state.voice.lock().unwrap().take(); + if let Some(mut active) = active { + active.cancel(); + } + *state.speech.lock().unwrap() = SpeechStatus { + phase: "idle".into(), + message: "语音已取消".into(), + recording: false, + }; + let _ = crate::backend::display_caption(app, "idle", "", false); +} +pub async fn start( + app: tauri::AppHandle, + target: Option, + key_epoch: Option, + press_sequence: Option, +) -> Result<(), String> { + let state = app.state::(); + let _gate = state.voice_gate.lock().await; + if state.closing.load(Ordering::SeqCst) + || key_epoch.is_some_and(|e| { + e != state.key_epoch.load(Ordering::SeqCst) || !state.key_enabled.load(Ordering::SeqCst) + }) + || press_sequence.is_some_and(|seq| seq != state.key_sequence.load(Ordering::SeqCst)) + { + return Ok(()); + } + if state.recording.load(Ordering::SeqCst) { + return Ok(()); + } + if state.speech.lock().unwrap().phase == "transcribing" { + return Err("上一句仍在识别,请稍候".into()); + } + cancel_locked(&app); + let settings = state.settings.lock().unwrap().clone(); + if settings.provider == Provider::Local && !state.model_store().is_installed() { + return Err("请先在设置中下载或导入 SenseVoice 模型".into()); + } + let external = matches!( + settings.provider, + Provider::Wechat | Provider::WindowsNative + ); + if external && target.is_none() { + return Err("请先把光标放到目标输入框,再按键盘语音键使用微信或 Win+H".into()); + } + if external { + // External input methods own their recording UI and lifetime. Do not + // create an ASR event task, caption placement or a blind timeout toggle. + if state.closing.load(Ordering::SeqCst) + || key_epoch.is_some_and(|e| { + e != state.key_epoch.load(Ordering::SeqCst) + || !state.key_enabled.load(Ordering::SeqCst) + }) + || press_sequence.is_some_and(|seq| seq != state.key_sequence.load(Ordering::SeqCst)) + { + return Ok(()); + } + let wechat = settings.provider == Provider::Wechat; + let result = crate::platform::external_toggle(wechat); + if result.is_ok() { + *state.voice.lock().unwrap() = Some(Active::External { wechat }); + state.recording.store(true, Ordering::SeqCst); + } + *state.speech.lock().unwrap() = SpeechStatus { + phase: if result.is_ok() { "listening" } else { "error" }.into(), + message: result + .as_ref() + .err() + .cloned() + .unwrap_or_else(|| "已发送外部语音启动快捷键;输入法状态未回读".into()), + recording: result.is_ok(), + }; + let _ = app.emit("runtime-update", ()); + return result; + } + let id = state.generation.fetch_add(1, Ordering::SeqCst) + 1; + let (tx, mut rx) = mpsc::channel(16); + let target_app = app.clone(); + let insert = settings.auto_insert; + tauri::async_runtime::spawn(async move { + while let Some(update) = rx.recv().await { + let state = target_app.state::(); + let _gate = state.voice_gate.lock().await; + if state.generation.load(Ordering::SeqCst) != id { + break; + } + match update { + Update::Phase(phase, message) => { + if phase == "listening" && !state.recording.load(Ordering::SeqCst) { + continue; + } + *state.speech.lock().unwrap() = SpeechStatus { + phase: phase.into(), + message: message.into(), + recording: state.recording.load(Ordering::SeqCst), + }; + let _ = crate::backend::display_caption(&target_app, phase, message, false); + } + Update::Partial(text) => { + if !state.recording.load(Ordering::SeqCst) { + continue; + } + let _ = crate::backend::display_caption(&target_app, "listening", &text, false); + } + Update::Final(text) => { + state.recording.store(false, Ordering::SeqCst); + let mut message = "识别完成".to_string(); + if insert + && !text.trim().is_empty() + && state.generation.load(Ordering::SeqCst) == id + { + if let Err(e) = crate::platform::insert(target, &text) { + message = e; + } + } + *state.speech.lock().unwrap() = SpeechStatus { + phase: "final".into(), + message, + recording: false, + }; + let _ = crate::backend::display_caption(&target_app, "final", &text, true); + state.voice.lock().unwrap().take(); + break; + } + Update::Error(error) => { + state.recording.store(false, Ordering::SeqCst); + *state.speech.lock().unwrap() = SpeechStatus { + phase: "error".into(), + message: error.clone(), + recording: false, + }; + let _ = crate::backend::display_caption(&target_app, "error", &error, true); + let active = state.voice.lock().unwrap().take(); + if let Some(mut a) = active { + a.cancel(); + } + break; + } + Update::Cancel => break, + } + let _ = target_app.emit("runtime-update", ()); + } + let _ = target_app.emit("runtime-update", ()); + }); + // Caption placement uses the same captured target as text insertion. + // Manual UI recording has no insertion target, so preview on the main window. + let caption_target = target.or_else(|| { + #[cfg(windows)] + { + app.get_webview_window("main") + .and_then(|w| w.hwnd().ok()) + .map(|h| h.0 as usize) + } + #[cfg(not(windows))] + { + None + } + }); + *state.caption_target.lock().unwrap() = caption_target; + crate::backend::position_caption(&app, settings.caption_bottom_offset)?; + state.recording.store(true, Ordering::SeqCst); + *state.speech.lock().unwrap() = SpeechStatus { + phase: "listening".into(), + message: "正在录音".into(), + recording: true, + }; + let active = match settings.provider { + Provider::Local => { + let mut config = SessionConfig::new(state.model_store().directory().to_path_buf()); + config.device_name = settings.microphone; + let events = tx.clone(); + SpeechSession::start(config, move |event| { + let update = match event { + SpeechEvent::Loading => Update::Phase("listening", "正在录音 · 准备本地引擎"), + SpeechEvent::Recording => Update::Phase("listening", "正在聆听…"), + SpeechEvent::Partial(t) => Update::Partial(t), + SpeechEvent::Recognizing => Update::Phase("transcribing", "正在完成识别…"), + SpeechEvent::Final(t) => Update::Final(t), + SpeechEvent::Error(e) => Update::Error(e), + SpeechEvent::Cancelled => Update::Cancel, + }; + post(&events, update); + }) + .map(Active::Local) + .map_err(|e| e.to_string()) + } + Provider::Doubao => { + let result = (|| -> Result { + let token = state.credentials().load().map_err(|e| e.to_string())?; + let config = CloudConfig { + app_id: settings.cloud_app_id, + resource_id: settings.cloud_resource_id, + }; + let events = tx.clone(); + let session = Arc::new( + CloudSession::start(config, token, move |event| { + post( + &events, + match event { + CloudEvent::Partial(t) => Update::Partial(t), + CloudEvent::Final(t) => Update::Final(t), + CloudEvent::Error(e) => Update::Error(e.to_string()), + }, + ); + }) + .map_err(|e| e.to_string())?, + ); + let pending = Arc::new(Mutex::new(Vec::::new())); + let buffer = pending.clone(); + let cloud = session.clone(); + let errors = tx.clone(); + let capture_errors = tx.clone(); + let capture = MicrophoneCapture::start( + settings.microphone.as_deref(), + move |audio| { + let mut data = buffer.lock().unwrap(); + data.extend( + audio + .iter() + .map(|s| (s.clamp(-1.0, 1.0) * 32767.0).round() as i16), + ); + while data.len() >= 3200 { + let packet: Vec = data.drain(..3200).collect(); + if let Err(error) = cloud.try_send_pcm16(&packet) { + post(&errors, Update::Error(error.to_string())); + break; + } + } + }, + move |error| post(&capture_errors, Update::Error(error)), + ) + .map_err(|e| e.to_string())?; + Ok(Active::Cloud { + session, + capture: Some(capture), + pending, + }) + })(); + result + } + Provider::Wechat | Provider::WindowsNative => unreachable!("handled before recorder setup"), + }; + match active { + Ok(mut active) => { + if state.generation.load(Ordering::SeqCst) != id { + active.cancel(); + return Ok(()); + } + *state.voice.lock().unwrap() = Some(active); + } + Err(error) => { + state.recording.store(false, Ordering::SeqCst); + post(&tx, Update::Error(error.clone())); + return Err(error); + } + } + let _ = app.emit("runtime-update", ()); + let deadline = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_secs(120)).await; + let state = deadline.state::(); + if state.generation.load(Ordering::SeqCst) == id && state.recording.load(Ordering::SeqCst) { + let _ = finish_generation(deadline.clone(), Some(id)).await; + } + }); + Ok(()) +} +pub async fn finish(app: tauri::AppHandle) -> Result<(), String> { + finish_generation(app, None).await +} +async fn finish_generation(app: tauri::AppHandle, expected: Option) -> Result<(), String> { + let state = app.state::(); + let _gate = state.voice_gate.lock().await; + if expected.is_some_and(|id| state.generation.load(Ordering::SeqCst) != id) { + return Ok(()); + } + if !state.recording.swap(false, Ordering::SeqCst) { + return Ok(()); + } + // Consume ownership BEFORE sending the stop toggle. If SendInput fails or + // only partly succeeds, generic cleanup must never send a second toggle. + let external = take_external(&mut state.voice.lock().unwrap()); + if let Some(wechat) = external { + let result = crate::platform::external_toggle(wechat); + if result.is_err() { + crate::backend::invalidate_keys(&app); + } + *state.speech.lock().unwrap() = SpeechStatus { + phase: if result.is_ok() { "idle" } else { "error" }.into(), + message: if result.is_ok() { + "已发送外部语音结束快捷键".into() + } else { + "结束快捷键未确认,请在输入法浮窗手动结束后再使用;不会自动重试开关".into() + }, + recording: false, + }; + let _ = app.emit("runtime-update", ()); + return result; + } + let result = (|| -> Result>, String> { + let mut active = state.voice.lock().unwrap(); + match active.as_mut() { + Some(Active::Local(s)) => { + s.finish(); + Ok(None) + } + Some(Active::Cloud { + session, + capture, + pending, + }) => { + if let Some(mut c) = capture.take() { + c.stop() + } + let tail = std::mem::take(&mut *pending.lock().unwrap()); + if !tail.is_empty() { + session.try_send_pcm16(&tail).map_err(|e| e.to_string())?; + } + Ok(Some(session.clone())) + } + Some(Active::External { .. }) => unreachable!("external session was consumed above"), + None => Ok(None), + } + })(); + let cloud = match result { + Ok(c) => c, + Err(error) => { + cancel_locked(&app); + *state.speech.lock().unwrap() = SpeechStatus { + phase: "error".into(), + message: error.clone(), + recording: false, + }; + let _ = app.emit("runtime-update", ()); + return Err(error); + } + }; + if state.voice.lock().unwrap().is_none() { + return Ok(()); + } + *state.speech.lock().unwrap() = SpeechStatus { + phase: "transcribing".into(), + message: "正在完成识别…".into(), + recording: false, + }; + let _ = app.emit("runtime-update", ()); + if let Some(cloud) = cloud { + if let Err(e) = cloud.finish().await { + cancel_locked(&app); + let error = e.to_string(); + *state.speech.lock().unwrap() = SpeechStatus { + phase: "error".into(), + message: error.clone(), + recording: false, + }; + let _ = app.emit("runtime-update", ()); + return Err(error); + } + } + Ok(()) +} + +#[cfg(test)] +mod external_tests { + use super::*; + #[test] + fn failed_stop_cannot_leave_an_external_session_for_cleanup_to_toggle_again() { + let mut slot = Some(Active::External { wechat: true }); + assert_eq!(take_external(&mut slot), Some(true)); + // A failed send does not put the consumed toggle session back. + assert!(slot.is_none()); + assert_eq!(take_external(&mut slot), None); + } +} diff --git a/ahakey-desktop/src-tauri/src/windows_voice_keys.rs b/ahakey-desktop/src-tauri/src/windows_voice_keys.rs new file mode 100644 index 00000000..8b63e895 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/windows_voice_keys.rs @@ -0,0 +1,258 @@ +//! Match the Java relay: consume only F17/F18 down/up using WH_KEYBOARD_LL. +//! Never poll global key state or execute speech/IPC inside the hook callback. +use std::{ + cell::RefCell, + sync::{ + atomic::{AtomicBool, AtomicU32, Ordering}, + mpsc, Arc, + }, + thread::{self, JoinHandle}, + time::Duration, +}; +use tokio::sync::mpsc::{channel, Receiver, Sender}; +use windows_sys::Win32::{ + System::{LibraryLoader::GetModuleHandleW, Threading::GetCurrentThreadId}, + UI::WindowsAndMessaging::*, +}; + +pub struct Event { + pub vk: u32, + pub pressed: bool, + pub target: Option, +} +#[derive(Default)] +struct Edges(u8); +impl Edges { + fn update(&mut self, vk: u32, pressed: bool) -> (bool, Option) { + let bit = match vk { + 0x80 => 1, + 0x81 => 2, + _ => return (false, None), + }; + let held = self.0 & bit != 0; + if pressed { + self.0 |= bit; + (true, (!held).then_some(true)) + } else { + self.0 &= !bit; + (held, held.then_some(false)) + } + } +} +struct Context { + edges: Edges, + sink: Sender, + stopped: Arc, + fault: Arc, +} +thread_local! { static CONTEXT:RefCell>=const{RefCell::new(None)}; } +unsafe extern "system" fn callback(code: i32, message: usize, data: isize) -> isize { + if code >= 0 + && matches!( + message as u32, + WM_KEYDOWN | WM_KEYUP | WM_SYSKEYDOWN | WM_SYSKEYUP + ) + { + let event = unsafe { &*(data as *const KBDLLHOOKSTRUCT) }; + let consume = CONTEXT.with(|slot| { + let Ok(mut slot) = slot.try_borrow_mut() else { + return false; + }; + let Some(ctx) = slot.as_mut() else { + return false; + }; + if ctx.stopped.load(Ordering::Acquire) { + return false; + } + let pressed = event.flags & LLKHF_UP == 0; + let (consume, edge) = ctx.edges.update(event.vkCode, pressed); + if let Some(pressed) = edge { + if ctx + .sink + .try_send(Event { + vk: event.vkCode, + pressed, + target: crate::platform::foreground(), + }) + .is_err() + { + ctx.fault.store(true, Ordering::Release); + ctx.stopped.store(true, Ordering::Release); + } + } + consume + }); + if consume { + return 1; + } + } + unsafe { CallNextHookEx(std::ptr::null_mut(), code, message, data) } +} + +pub struct VoiceKeyHook { + stopped: Arc, + pub fault: Arc, + thread_id: Arc, + done: mpsc::Receiver<()>, + worker: Option>, +} +impl VoiceKeyHook { + pub fn start() -> Result<(Self, Receiver), String> { + let (sink, events) = channel(64); + let stopped = Arc::new(AtomicBool::new(false)); + let fault = Arc::new(AtomicBool::new(false)); + let thread_id = Arc::new(AtomicU32::new(0)); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let (done_tx, done) = mpsc::sync_channel(1); + let (stop_worker, fault_worker, id_worker) = + (stopped.clone(), fault.clone(), thread_id.clone()); + let worker = thread::Builder::new() + .name("ahakey-voice-keys".into()) + .spawn(move || { + unsafe { + id_worker.store(GetCurrentThreadId(), Ordering::Release); + let mut msg: MSG = std::mem::zeroed(); + PeekMessageW(&mut msg, std::ptr::null_mut(), 0, 0, PM_NOREMOVE); + CONTEXT.with(|slot| { + *slot.borrow_mut() = Some(Context { + edges: Edges::default(), + sink, + stopped: stop_worker.clone(), + fault: fault_worker.clone(), + }) + }); + let hook = SetWindowsHookExW( + WH_KEYBOARD_LL, + Some(callback), + GetModuleHandleW(std::ptr::null()), + 0, + ); + if hook.is_null() { + let _ = ready_tx.send(Err(format!( + "无法安装 F17/F18 键盘钩子:{}", + std::io::Error::last_os_error() + ))); + } else { + if ready_tx.send(Ok(())).is_err() { + stop_worker.store(true, Ordering::Release); + } + while !stop_worker.load(Ordering::Acquire) { + let result = GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0); + if result <= 0 { + if result < 0 { + fault_worker.store(true, Ordering::Release); + } + break; + } + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + UnhookWindowsHookEx(hook); + } + CONTEXT.with(|slot| { + slot.borrow_mut().take(); + }); + } + let _ = done_tx.send(()); + }) + .map_err(|e| e.to_string())?; + let guard = Self { + stopped, + fault, + thread_id, + done, + worker: Some(worker), + }; + ready_rx + .recv_timeout(Duration::from_secs(3)) + .map_err(|_| "键盘钩子启动超时".to_string())??; + Ok((guard, events)) + } +} +impl Drop for VoiceKeyHook { + fn drop(&mut self) { + self.stopped.store(true, Ordering::Release); + let id = self.thread_id.load(Ordering::Acquire); + if id != 0 { + unsafe { + PostThreadMessageW(id, WM_QUIT, 0, 0); + } + } + if self.done.recv_timeout(Duration::from_secs(1)).is_ok() { + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + #[ignore = "Controlled Windows F18 injection; no microphone or external shortcut is executed"] + async fn native_hook_gets_physical_edges_and_consumes_async_fn_state() { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + keybd_event, GetAsyncKeyState, KEYEVENTF_KEYUP, + }; + assert_eq!( + unsafe { GetAsyncKeyState(0x81) } & i16::MIN, + 0, + "Release F18 before this test" + ); + let (hook, mut events) = VoiceKeyHook::start().unwrap(); + struct Release; + impl Drop for Release { + fn drop(&mut self) { + unsafe { keybd_event(0x81, 0, KEYEVENTF_KEYUP, 0) } + } + } + let release = Release; + unsafe { keybd_event(0x81, 0, 0, 0) }; + let down = tokio::time::timeout(Duration::from_secs(2), events.recv()) + .await + .unwrap() + .unwrap(); + assert!(down.pressed); + assert_eq!(down.vk, 0x81); + tokio::time::sleep(Duration::from_millis(150)).await; + assert!(events.try_recv().is_err(), "No release while still held"); + assert_eq!( + unsafe { GetAsyncKeyState(0x81) } & i16::MIN, + 0, + "Consumed F18 must not remain in the IME chord state" + ); + drop(release); + let up = tokio::time::timeout(Duration::from_secs(2), events.recv()) + .await + .unwrap() + .unwrap(); + assert!(!up.pressed); + assert_eq!(up.vk, 0x81); + assert!(!hook.fault.load(Ordering::Acquire)); + drop(hook); + } + #[test] + fn consumes_one_press_and_real_release_without_repeat() { + let mut e = Edges::default(); + assert_eq!(e.update(0x81, true), (true, Some(true))); + assert_eq!(e.update(0x81, true), (true, None)); + assert_eq!(e.update(0x81, false), (true, Some(false))); + assert_eq!(e.update(0x81, false), (false, None)); + } + #[test] + fn unrelated_typing_and_injected_wechat_modifiers_pass_through() { + let mut e = Edges::default(); + for key in [0x41, 0x0d, 0x10, 0x11, 0x5b, 0xa0, 0xa2] { + assert_eq!(e.update(key, true), (false, None)); + } + assert_eq!(e.0, 0); + } + #[test] + fn independent_voice_keys_do_not_lose_release() { + let mut e = Edges::default(); + e.update(0x80, true); + e.update(0x81, true); + assert_eq!(e.update(0x80, false), (true, Some(false))); + assert_eq!(e.update(0x81, false), (true, Some(false))); + } +} diff --git a/ahakey-desktop/src/ProviderPicker.tsx b/ahakey-desktop/src/ProviderPicker.tsx new file mode 100644 index 00000000..b197ff88 --- /dev/null +++ b/ahakey-desktop/src/ProviderPicker.tsx @@ -0,0 +1,17 @@ +import { Check, Cloud, Cpu, MessageCircle, Monitor } from "lucide-react"; +import { Provider } from "./contracts"; + +export function ProviderPicker({value,onChange,windows}: {value:Provider;onChange:(provider:Provider)=>void;windows:boolean}) { + const choices = [ + {id:"wechat" as const,label:"微信输入法",detail:"输入法语音",icon:MessageCircle,windows:true}, + {id:"windows-native" as const,label:"Windows 听写",detail:"Win + H",icon:Monitor,windows:true}, + {id:"local" as const,label:"本地模型",detail:"SenseVoice · 离线",icon:Cpu,windows:false}, + {id:"doubao" as const,label:"豆包云端",detail:"API · 流式识别",icon:Cloud,windows:false}, + ].filter(choice=>windows||!choice.windows); + return
+ {choices.map(({id,label,detail,icon:Icon})=>)} +
; +} From 35099067a7bd31c6959fcda7bc44d1394bb6af7f Mon Sep 17 00:00:00 2001 From: Lihao Date: Mon, 14 Sep 2026 02:58:52 -0700 Subject: [PATCH 06/21] feat(hooks): add local coding-event listener for keyboard lighting Introduce the feature implementation and its focused tests/components. The following UI and runtime commits connect the shared application entry points. --- ahakey-desktop/src-tauri/src/hooks.rs | 189 ++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 ahakey-desktop/src-tauri/src/hooks.rs diff --git a/ahakey-desktop/src-tauri/src/hooks.rs b/ahakey-desktop/src-tauri/src/hooks.rs new file mode 100644 index 00000000..a8fc05f1 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/hooks.rs @@ -0,0 +1,189 @@ +use serde_json::json; +use std::{ + io::{BufRead, BufReader, Read, Write}, + net::{TcpListener, TcpStream}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; +pub struct HookServer { + pub port: u16, + pub last: Arc>>, + stop: Arc, + path: PathBuf, + descriptor: Vec, +} +impl Drop for HookServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if std::fs::read(&self.path).is_ok_and(|data| data == self.descriptor) { + let _ = std::fs::remove_file(&self.path); + } + } +} +pub fn state_for(name: &str) -> Option { + let trimmed = name + .trim() + .trim_start_matches("Codex") + .trim_start_matches("Kimi"); + match trimmed.to_ascii_lowercase().as_str() { + "sessionstart" => Some(4), + "userpromptsubmit" => Some(7), + "pretooluse" => Some(3), + "permissionrequest" => Some(1), + "posttooluse" => Some(2), + "stop" => Some(5), + "notification" => Some(0), + "taskcompleted" => Some(6), + "sessionend" => Some(8), + _ => None, + } +} +fn atomic(path: &Path, data: &[u8]) -> Result<(), String> { + let temp = path.with_extension("pending"); + std::fs::write(&temp, data).map_err(|_| "无法写入 Hook 状态")?; + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + let a: Vec = temp.as_os_str().encode_wide().chain(Some(0)).collect(); + let b: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + if unsafe { + MoveFileExW( + a.as_ptr(), + b.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + return Err("无法发布 Hook 状态".into()); + } + } + #[cfg(not(windows))] + std::fs::rename(temp, path).map_err(|_| "无法发布 Hook 状态")?; + Ok(()) +} +pub fn start( + directory: PathBuf, + callback: impl Fn(String, u8) + Send + Sync + 'static, +) -> Result { + std::fs::create_dir_all(&directory).map_err(|_| "无法创建 Hook 目录")?; + let path = directory.join("active-endpoint.json"); + if let Ok(bytes) = std::fs::read(&path) { + let value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| "现有 Hook 记录无效,已保留")?; + if let Some(pid) = value.get("processId").and_then(|p| p.as_u64()) { + if pid != std::process::id() as u64 && crate::platform::process_alive(pid as u32) { + return Err("另一客户端仍在管理 Hook,请先退出它".into()); + } + } + } + let listener = (8769..8790) + .find_map(|port| TcpListener::bind(("127.0.0.1", port)).ok()) + .ok_or("没有可用的 Hook 回环端口")?; + listener.set_nonblocking(true).map_err(|e| e.to_string())?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + let descriptor=serde_json::to_vec(&json!({"schemaVersion":1,"host":"127.0.0.1","port":port,"processId":std::process::id(),"startedAt":format!("{:?}",std::time::SystemTime::now())})).unwrap(); + let script = directory.join("ahakey-hook.ps1"); + if !script.exists() { + std::fs::write(&script, DISPATCHER).map_err(|_| "无法写入 Hook 分发脚本")?; + } + atomic(&path, &descriptor)?; + let stop = Arc::new(AtomicBool::new(false)); + let last = Arc::new(Mutex::new(None)); + let stop_worker = stop.clone(); + let observed = last.clone(); + std::thread::spawn(move || { + while !stop_worker.load(Ordering::SeqCst) { + match listener.accept() { + Ok((mut stream, _)) => { + let _ = stream.set_read_timeout(Some(Duration::from_millis(1000))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(1000))); + if let Some(name) = read_event(&mut stream) { + if let Some(state) = state_for(&name) { + *observed.lock().unwrap() = Some(name.clone()); + callback(name, state); + let _ = stream.write_all(b"{\"ok\":true,\"autoApproved\":false}\n"); + } else { + let _ = stream.write_all(b"{\"ok\":false}\n"); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(50)) + } + Err(_) => break, + } + } + }); + Ok(HookServer { + port, + last, + stop, + path, + descriptor, + }) +} +fn read_event(stream: &mut TcpStream) -> Option { + let mut line = String::new(); + BufReader::new(stream) + .take(4097) + .read_line(&mut line) + .ok()?; + if line.len() > 4096 { + return None; + } + let line = line.trim(); + if line.starts_with('{') { + let json: serde_json::Value = serde_json::from_str(line).ok()?; + json.get("cmd") + .or_else(|| json.get("event")) + .or_else(|| json.get("eventName")) + .and_then(|s| s.as_str()) + .map(str::to_owned) + } else { + Some(line.to_owned()) + } +} +const DISPATCHER: &str = r#"# AhaKey Hook Dispatcher - Auto-generated, do not edit +param([Parameter(Position=0)][string]$EventName) +try { if ([Console]::IsInputRedirected) { $null=[Console]::In.ReadToEnd() } } catch {} +try { + $endpoint=Get-Content -Raw (Join-Path $PSScriptRoot 'active-endpoint.json')|ConvertFrom-Json + if ($endpoint.host -ne '127.0.0.1') { throw 'Invalid endpoint' } + if (-not (Get-Process -Id $endpoint.processId -ErrorAction SilentlyContinue)) { throw 'Offline' } + $client=[Net.Sockets.TcpClient]::new() + if (-not $client.ConnectAsync('127.0.0.1',[int]$endpoint.port).Wait(1500)) { throw 'Timeout' } + $stream=$client.GetStream();$stream.ReadTimeout=1500 + $writer=[IO.StreamWriter]::new($stream);$writer.WriteLine($EventName);$writer.Flush() + $reader=[IO.StreamReader]::new($stream);$null=$reader.ReadLine() +} catch {} finally { if ($client) {$client.Dispose()} } +if ($EventName -match 'PermissionRequest$') { + [Console]::WriteLine('{"hookSpecificOutput":{"hookEventName":"PermissionRequest"}}') +} else { [Console]::WriteLine('{}') } +"#; +#[cfg(test)] +mod tests { + #[test] + fn event_mapping_does_not_execute_unknown_input() { + assert_eq!(super::state_for("CodexPermissionRequest"), Some(1)); + for (event, code) in [ + ("Notification", 0), + ("PreToolUse", 3), + ("PostToolUse", 2), + ("SessionStart", 4), + ("Stop", 5), + ("TaskCompleted", 6), + ("UserPromptSubmit", 7), + ] { + assert_eq!(super::state_for(event), Some(code)); + } + assert_eq!(super::state_for("SessionEnd"), Some(8)); + assert_eq!(super::state_for("exec arbitrary text"), None); + } +} From f4394df72e8e720f9a5a68f87cc434001f295e01 Mon Sep 17 00:00:00 2001 From: Lihao Date: Mon, 14 Sep 2026 02:58:53 -0700 Subject: [PATCH 07/21] feat(devices): add transport recovery and per-slot host management Introduce the feature implementation and its focused tests/components. The following UI and runtime commits connect the shared application entry points. --- ahakey-desktop/src-tauri/src/device.rs | 190 +++++++++++ .../src-tauri/src/device_routing.rs | 305 +++++++++++++++++ ahakey-desktop/src-tauri/src/host_notes.rs | 56 ++++ ahakey-desktop/src-tauri/src/recovery.rs | 316 ++++++++++++++++++ ahakey-desktop/src/RoutingPanel.tsx | 197 +++++++++++ ahakey-desktop/src/device.test.ts | 20 ++ ahakey-desktop/src/host-info.test.ts | 8 + ahakey-desktop/src/routing.test.ts | 49 +++ ahakey-desktop/src/routing.ts | 56 ++++ 9 files changed, 1197 insertions(+) create mode 100644 ahakey-desktop/src-tauri/src/device.rs create mode 100644 ahakey-desktop/src-tauri/src/device_routing.rs create mode 100644 ahakey-desktop/src-tauri/src/host_notes.rs create mode 100644 ahakey-desktop/src-tauri/src/recovery.rs create mode 100644 ahakey-desktop/src/RoutingPanel.tsx create mode 100644 ahakey-desktop/src/device.test.ts create mode 100644 ahakey-desktop/src/host-info.test.ts create mode 100644 ahakey-desktop/src/routing.test.ts create mode 100644 ahakey-desktop/src/routing.ts diff --git a/ahakey-desktop/src-tauri/src/device.rs b/ahakey-desktop/src-tauri/src/device.rs new file mode 100644 index 00000000..2c961c83 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/device.rs @@ -0,0 +1,190 @@ +//! Connection-independent, read-only device information. +#[cfg(windows)] +use crate::state::Runtime; +use ahakey_ble::{BleSnapshot, ConnectionPhase, DeviceStatus}; +use serde::Serialize; +#[cfg(windows)] +use tauri::Manager; + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsbSnapshot { + pub supported: bool, + pub present: Option, + pub status: Option, + pub error: Option, +} +impl Default for UsbSnapshot { + fn default() -> Self { + Self { + supported: cfg!(windows), + present: None, + status: None, + error: None, + } + } +} +impl UsbSnapshot { + #[cfg(any(windows, test))] + fn from_poll(present: Option, result: Result, String>) -> Self { + match result { + Ok(status) => Self { + present, + status, + ..Self::default() + }, + Err(error) => Self { + present, + error: Some(error), + ..Self::default() + }, + } + } +} +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceView { + pub transport: Option<&'static str>, + pub name: Option, + pub status: Option, +} +impl DeviceView { + pub fn from_transports(usb: &UsbSnapshot, ble: Option<&BleSnapshot>) -> Self { + if let Some(status) = &usb.status { + return Self { + transport: Some("usb"), + name: Some("AhaKey · USB".into()), + status: Some(status.clone()), + }; + } + if let Some(ble) = ble.filter(|b| b.phase == ConnectionPhase::Ready) { + if let Some(status) = &ble.status { + return Self { + transport: Some("ble"), + name: ble.device.as_ref().map(|d| d.name.clone()), + status: Some(status.clone()), + }; + } + } + Self { + transport: None, + name: None, + status: None, + } + } +} +#[cfg(windows)] +async fn poll_usb() -> UsbSnapshot { + // One bounded deadline, but retain interface presence if only the read fails. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(4); + let opened = + tokio::time::timeout_at(deadline, ahakey_ble::usb_routing::UsbRouting::try_open()).await; + let mut port = match opened { + Ok(Ok(Some(port))) => port, + Ok(Ok(None)) => return UsbSnapshot::from_poll(Some(false), Ok(None)), + Ok(Err(error)) => return UsbSnapshot::from_poll(None, Err(error)), + Err(_) => return UsbSnapshot::from_poll(None, Err("USB 接口检测超时".into())), + }; + let result = tokio::time::timeout_at(deadline, port.device_status()) + .await + .map_err(|_| "USB 已识别,但设备状态读取超时;按键输入与状态通信是独立通道".to_owned()) + .and_then(|result| result.map(Some)); + UsbSnapshot::from_poll(Some(true), result) +} +pub fn start(app: &tauri::AppHandle) { + #[cfg(windows)] + { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let mut timer = tokio::time::interval(std::time::Duration::from_secs(2)); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + timer.tick().await; + let state = app.state::(); + if state.closing.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + // Explicit settings commands take priority; do not interleave + // monitor reads with an A3 transaction on the same endpoint. + let Ok(gate) = state.usb_gate.try_lock() else { + continue; + }; + let next = poll_usb().await; + drop(gate); + let changed = { + let mut current = state.usb.lock().unwrap(); + let changed = *current != next; + *current = next; + changed + }; + if changed { + crate::backend::pulse(&app); + } + } + }); + } + #[cfg(not(windows))] + let _ = app; +} +#[cfg(test)] +mod tests { + use super::*; + fn info(battery: u8) -> DeviceStatus { + DeviceStatus { + battery_level: battery, + signal: 50, + firmware_main: 1, + firmware_sub: 0, + work_mode: 3, + light_mode: 5, + switch_state: 1, + light_brightness: 35, + } + } + fn ble(phase: ConnectionPhase) -> BleSnapshot { + BleSnapshot { + generation: 1, + phase, + device: None, + status: Some(info(42)), + error: None, + } + } + #[test] + fn usb_works_without_ble_and_never_uses_ble_fields() { + let usb = UsbSnapshot::from_poll(Some(true), Ok(Some(info(76)))); + let view = DeviceView::from_transports(&usb, Some(&ble(ConnectionPhase::Error))); + assert_eq!(view.transport, Some("usb")); + assert_eq!(view.status.unwrap().battery_level, 76); + assert_eq!( + DeviceView::from_transports(&usb, Some(&ble(ConnectionPhase::Ready))).transport, + Some("usb") + ); + } + #[test] + fn usb_removal_falls_back_only_to_a_ready_ble_snapshot() { + let usb = UsbSnapshot::from_poll(Some(false), Ok(None)); + assert_eq!(usb.present, Some(false)); + assert_eq!( + DeviceView::from_transports(&usb, Some(&ble(ConnectionPhase::Ready))).transport, + Some("ble") + ); + assert!( + DeviceView::from_transports(&usb, Some(&ble(ConnectionPhase::Error))) + .status + .is_none() + ); + assert!(DeviceView::from_transports(&usb, None).status.is_none()); + } + #[test] + fn failed_usb_poll_clears_old_status() { + let failed = UsbSnapshot::from_poll(Some(true), Err("read failed".into())); + assert_eq!(failed.present, Some(true)); + assert!(failed.status.is_none()); + assert!(failed.error.is_some()); + assert!(DeviceView::from_transports(&failed, None).status.is_none()); + let unknown = UsbSnapshot::from_poll(None, Err("open failed".into())); + assert_eq!(unknown.present, None); + assert!(DeviceView::from_transports(&unknown, None).status.is_none()); + } +} diff --git a/ahakey-desktop/src-tauri/src/device_routing.rs b/ahakey-desktop/src-tauri/src/device_routing.rs new file mode 100644 index 00000000..0eb3a79a --- /dev/null +++ b/ahakey-desktop/src-tauri/src/device_routing.rs @@ -0,0 +1,305 @@ +use crate::{backend::require_main, state::Runtime}; +use ahakey_ble::routing::{Config, Details, ManagementAction, Status}; +use tauri::Manager; + +#[tauri::command] +pub async fn select_configuration_transport( + window: tauri::WebviewWindow, +) -> Result { + require_main(&window)?; + let state = window.state::(); + #[cfg(windows)] + { + let _gate = state.usb_gate.lock().await; + let port = tokio::time::timeout( + std::time::Duration::from_secs(4), + ahakey_ble::usb_routing::UsbRouting::try_open(), + ) + .await + .map_err(|_| "USB 检测超时,未切换到蓝牙")??; + if port.is_some() { + return Ok("usb".into()); + } + } + if state + .ble + .lock() + .await + .as_ref() + .is_some_and(|c| c.status().phase == ahakey_ble::ConnectionPhase::Ready) + { + Ok("ble".into()) + } else { + Err("未检测到 USB 配置接口或已连接的客户端蓝牙".into()) + } +} +#[tauri::command] +pub async fn get_device_policy( + window: tauri::WebviewWindow, + transport: Option, +) -> Result { + require_main(&window)?; + let state = window.state::(); + if use_usb(transport.as_deref())? { + let _gate = state.usb_gate.lock().await; + #[cfg(windows)] + return tokio::time::timeout(std::time::Duration::from_secs(6), async { + let mut port = ahakey_ble::usb_routing::UsbRouting::open().await?; + port.read_policy().await + }) + .await + .map_err(|_| "USB 策略读取超时")?; + #[cfg(not(windows))] + return Err("此平台尚无 USB 配置支持".into()); + } + let client = state.ble.lock().await.clone().ok_or("蓝牙未连接")?; + client.read_policy().await.map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn reset_device_pairing( + window: tauri::WebviewWindow, + target: u8, +) -> Result { + require_main(&window)?; + if target > 2 { + return Err("无效的重置目标".into()); + } + // Intentionally no transport parameter or BLE fallback. + #[cfg(windows)] + { + let state = window.state::(); + let _gate = state.usb_gate.lock().await; + tokio::time::timeout(std::time::Duration::from_secs(24), async { + let mut port = ahakey_ble::usb_routing::UsbRouting::open().await?; + port.reset_pairing(target).await + }) + .await + .map_err(|_| "USB 重置结果未知,请重新读取;不会自动重试")? + } + #[cfg(not(windows))] + Err("客户端重置仅支持 Windows USB,请使用硬件选槽长按;不会通过蓝牙重置".into()) +} + +#[tauri::command] +pub fn get_host_aliases(window: tauri::WebviewWindow) -> Result<[String; 2], String> { + require_main(&window)?; + crate::host_notes::load(&window.state::().data_dir.join("host-notes.json")) +} +#[tauri::command] +pub async fn set_host_aliases( + window: tauri::WebviewWindow, + aliases: [String; 2], +) -> Result<[String; 2], String> { + require_main(&window)?; + let state = window.state::(); + let _gate = state.settings_gate.lock().await; + let aliases = aliases.map(|s| s.trim().to_owned()); + crate::host_notes::save(&state.data_dir.join("host-notes.json"), &aliases)?; + Ok(aliases) +} +#[tauri::command] +pub async fn get_device_hosts( + window: tauri::WebviewWindow, + transport: Option, +) -> Result<[ahakey_ble::host_info::HostInfo; 2], String> { + require_main(&window)?; + let state = window.state::(); + if use_usb(transport.as_deref())? { + let _gate = state.usb_gate.lock().await; + #[cfg(windows)] + return tokio::time::timeout(std::time::Duration::from_secs(26), async { + let mut port = ahakey_ble::usb_routing::UsbRouting::open().await?; + port.read_host_info().await + }) + .await + .map_err(|_| "USB 主机信息读取超时".to_owned())?; + #[cfg(not(windows))] + return Err("USB 配置仅支持 Windows".into()); + } + let client = state.ble.lock().await.clone().ok_or("请先连接键盘")?; + client.read_host_info().await.map_err(|e| e.to_string()) +} +fn local_host() -> (String, u8) { + #[cfg(windows)] + let (name, system) = (std::env::var("COMPUTERNAME").unwrap_or_default(), 1); + #[cfg(unix)] + let (name, system) = { + let mut bytes = [0u8; 256]; + let name = if unsafe { libc::gethostname(bytes.as_mut_ptr().cast(), bytes.len()) } == 0 { + let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..end]).into_owned() + } else { + String::new() + }; + (name, if cfg!(target_os = "macos") { 2 } else { 3 }) + }; + (ahakey_ble::host_info::bounded_name(&name), system) +} +pub async fn report_local_host(client: &ahakey_ble::BleClient) -> Result<(), String> { + let (name, system) = local_host(); + client + .register_host(&name, system) + .await + .map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn report_this_host(window: tauri::WebviewWindow) -> Result<(), String> { + require_main(&window)?; + let client = window + .state::() + .ble + .lock() + .await + .clone() + .ok_or("请先通过蓝牙连接本机;USB 不能代替蓝牙 A/B 上报名称")?; + report_local_host(&client).await +} + +#[tauri::command] +pub async fn get_device_routing( + window: tauri::WebviewWindow, + transport: Option, +) -> Result { + require_main(&window)?; + if use_usb(transport.as_deref())? { + let state = window.state::(); + let _gate = state.usb_gate.lock().await; + return usb_operation(None).await; + } + let client = window + .state::() + .ble + .lock() + .await + .clone() + .ok_or("请先连接键盘")?; + client.read_routing().await.map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn set_device_routing( + window: tauri::WebviewWindow, + config: Config, + transport: Option, +) -> Result { + require_main(&window)?; + if use_usb(transport.as_deref())? { + let state = window.state::(); + let _gate = state.usb_gate.lock().await; + return usb_operation(Some(config)).await; + } + let client = window + .state::() + .ble + .lock() + .await + .clone() + .ok_or("请先连接键盘")?; + client.set_routing(&config).await.map_err(|e| e.to_string()) +} +fn use_usb(transport: Option<&str>) -> Result { + match transport { + None | Some("ble") => Ok(false), + Some("usb") => Ok(true), + _ => Err("未知的配置连接类型".into()), + } +} +#[tauri::command] +pub async fn get_device_pairing( + window: tauri::WebviewWindow, + transport: Option, +) -> Result { + require_main(&window)?; + if use_usb(transport.as_deref())? { + let state = window.state::(); + let _gate = state.usb_gate.lock().await; + return usb_pairing(None).await; + } + let client = window + .state::() + .ble + .lock() + .await + .clone() + .ok_or("请先连接键盘")?; + client.read_pairing().await.map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn manage_device_pairing( + window: tauri::WebviewWindow, + transport: Option, + action: ManagementAction, +) -> Result<(), String> { + require_main(&window)?; + if use_usb(transport.as_deref())? { + let state = window.state::(); + let _gate = state.usb_gate.lock().await; + usb_manage(action).await?; + return Ok(()); + } + let client = window + .state::() + .ble + .lock() + .await + .clone() + .ok_or("请先连接键盘")?; + client + .manage_pairing(action) + .await + .map_err(|e| e.to_string()) +} +#[cfg(windows)] +async fn usb_pairing(action: Option) -> Result { + tokio::time::timeout(std::time::Duration::from_secs(14), async { + let mut port = ahakey_ble::usb_routing::UsbRouting::open().await?; + if let Some(action) = action { + port.manage_pairing(action).await?; + } + port.read_pairing().await + }) + .await + .map_err(|_| "USB 配对管理超时,操作未确认,请重新读取而非重复提交".to_owned())? +} +#[cfg(not(windows))] +async fn usb_pairing(_: Option) -> Result { + Err("此版本 USB 配置仅支持 Windows,请使用蓝牙配置".into()) +} +#[cfg(windows)] +async fn usb_manage(action: ManagementAction) -> Result<(), String> { + tokio::time::timeout(std::time::Duration::from_secs(12), async { + let mut port = ahakey_ble::usb_routing::UsbRouting::open().await?; + port.manage_pairing(action).await + }) + .await + .map_err(|_| "USB 操作超时,未确认执行,请先重新读取".to_owned())? +} +#[cfg(not(windows))] +async fn usb_manage(_: ManagementAction) -> Result<(), String> { + Err("此版本 USB 配置仅支持 Windows".into()) +} +#[cfg(windows)] +async fn usb_operation(config: Option) -> Result { + tokio::time::timeout(std::time::Duration::from_secs(12), async { + let mut port = ahakey_ble::usb_routing::UsbRouting::open().await?; + match config { + Some(config) => port.apply(&config).await, + None => port.read().await, + } + }) + .await + .map_err(|_| "USB 操作超时,请检查数据线和设备连接;未确认保存".to_owned())? +} +#[cfg(not(windows))] +async fn usb_operation(_: Option) -> Result { + Err("此预览版的 USB 配置通道暂仅支持 Windows;蓝牙配置仍可使用".into()) +} +#[cfg(test)] +mod tests { + #[test] + fn explicit_transport_never_silently_falls_back() { + assert_eq!(super::use_usb(None), Ok(false)); + assert_eq!(super::use_usb(Some("ble")), Ok(false)); + assert_eq!(super::use_usb(Some("usb")), Ok(true)); + assert!(super::use_usb(Some("auto")).is_err()); + } +} diff --git a/ahakey-desktop/src-tauri/src/host_notes.rs b/ahakey-desktop/src-tauri/src/host_notes.rs new file mode 100644 index 00000000..38aa3ee5 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/host_notes.rs @@ -0,0 +1,56 @@ +//! Local slot notes are separate from the strict legacy voice/profile schema. +use std::{fs, path::Path}; +pub fn validate(notes: &[String; 2]) -> Result<(), String> { + if notes.iter().any(|s| { + s.chars().count() > 32 || s.chars().any(|c| { + c.is_control() + || matches!(c,'\u{200e}'|'\u{200f}'|'\u{202a}'..='\u{202e}'|'\u{2066}'..='\u{2069}') + }) + }) { + return Err("槽位备注限 32 字符,不支持控制字符".into()); + } + Ok(()) +} +pub fn load(path: &Path) -> Result<[String; 2], String> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Default::default()), + Err(_) => return Err("备注文件读取失败,原文件已保留".into()), + }; + if bytes.len() > 4096 { + return Err("备注文件过大,原文件已保留".into()); + } + let notes = serde_json::from_slice(&bytes).map_err(|_| "备注文件无效,原文件已保留")?; + validate(¬es)?; + Ok(notes) +} +pub fn save(path: &Path, notes: &[String; 2]) -> Result<(), String> { + validate(notes)?; + load(path)?; // Never overwrite unknown/corrupt content. + let bytes = serde_json::to_vec_pretty(notes).map_err(|_| "备注编码失败")?; + crate::settings::write_atomic(path, &bytes) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn notes_roundtrip_preserves_legacy_settings_and_rejects_bad_content() { + let folder = tempfile::tempdir().unwrap(); + let settings = folder.path().join("settings.json"); + crate::settings::save(&settings, &Default::default()).unwrap(); + let before = fs::read(&settings).unwrap(); + let path = folder.path().join("host-notes.json"); + assert_eq!(load(&path).unwrap(), ["", ""]); + let notes = ["办公电脑".into(), "MacBook".into()]; + save(&path, ¬es).unwrap(); + assert_eq!(load(&path).unwrap(), notes); + save(&path, &["新备注".into(), "".into()]).unwrap(); + assert_eq!(load(&path).unwrap(), ["新备注", ""]); + assert_eq!(before, fs::read(&settings).unwrap()); + assert!(validate(&["x".repeat(33), "".into()]).is_err()); + assert!(validate(&["bad\u{202e}".into(), "".into()]).is_err()); + fs::write(&path, b"invalid").unwrap(); + assert!(save(&path, ¬es).is_err()); + assert_eq!(fs::read(&path).unwrap(), b"invalid"); + } +} diff --git a/ahakey-desktop/src-tauri/src/recovery.rs b/ahakey-desktop/src-tauri/src/recovery.rs new file mode 100644 index 00000000..bd8e0c97 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/recovery.rs @@ -0,0 +1,316 @@ +//! One connection supervisor, with explicit user intent separate from link state. +use crate::{backend, platform, settings, state::Runtime}; +use ahakey_ble::{BleClient, ConnectionPhase}; +use serde::Serialize; +use std::{ + sync::{atomic::Ordering, Arc}, + time::{Duration, Instant}, +}; +use tauri::Manager; + +#[derive(Clone)] +struct Ticket { + epoch: u64, + id: String, +} + +pub struct Recovery { + target: Option, + epoch: u64, + attempt: u32, + in_flight: bool, + next_at: Instant, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Status { + enabled: bool, + connecting: bool, + attempt: u32, + retry_after_seconds: u64, +} + +impl Recovery { + pub fn new(target: Option) -> Self { + Self { + target, + epoch: 0, + attempt: 0, + in_flight: false, + next_at: Instant::now(), + } + } + fn select(&mut self, id: String, now: Instant) { + self.epoch += 1; + self.target = Some(id); + self.attempt = 0; + self.in_flight = false; + self.next_at = now; + } + pub fn pause(&mut self) { + self.epoch += 1; + self.target = None; + self.in_flight = false; + self.attempt = 0; + } + fn valid(&self, ticket: &Ticket) -> bool { + self.epoch == ticket.epoch && self.target.as_ref() == Some(&ticket.id) + } + fn begin(&mut self, now: Instant) -> Option { + if self.in_flight || now < self.next_at { + return None; + } + let id = self.target.clone()?; + self.in_flight = true; + self.attempt = self.attempt.saturating_add(1); + Some(Ticket { + epoch: self.epoch, + id, + }) + } + fn complete(&mut self, ticket: &Ticket, ready: bool, now: Instant) { + if !self.valid(ticket) { + return; + } + self.in_flight = false; + if ready { + self.attempt = 0; + } + self.next_at = now + + if ready { + Duration::ZERO + } else { + backoff(self.attempt) + }; + } + pub fn status(&self) -> Status { + Status { + enabled: self.target.is_some(), + connecting: self.in_flight, + attempt: self.attempt, + retry_after_seconds: self + .next_at + .saturating_duration_since(Instant::now()) + .as_secs() + .saturating_add(1), + } + } +} + +fn backoff(attempt: u32) -> Duration { + Duration::from_secs((1u64 << attempt.min(5)).min(30)) +} + +fn current(app: &tauri::AppHandle, ticket: &Ticket) -> bool { + let state = app.state::(); + !state.closing.load(Ordering::SeqCst) && state.ble_recovery.lock().unwrap().valid(ticket) +} + +// Caller owns ble_connection_gate. Do not hold any standard mutex over await. +async fn attempt(app: &tauri::AppHandle, ticket: &Ticket) -> Result<(), String> { + if !current(app, ticket) { + return Err("连接请求已取消".into()); + } + if platform::java_running() { + return Err("JavaFX AhaKey 仍在运行,请退出后自动重连".into()); + } + let state = app.state::(); + let client = backend::ble_client(app).await?; + let generation = { + let policy = state.ble_recovery.lock().unwrap(); + if !policy.valid(ticket) || state.closing.load(Ordering::SeqCst) { + return Err("连接请求已取消".into()); + } + client.status().generation + }; + client + .reconnect_once_if(&ticket.id, generation) + .await + .map_err(|e| e.to_string())?; + { + let _settings_gate = state.settings_gate.lock().await; + let policy = state.ble_recovery.lock().unwrap(); + if !policy.valid(ticket) || state.closing.load(Ordering::SeqCst) { + return Err("连接请求已取消".into()); + } + let mut saved = state.settings.lock().unwrap(); + if saved.saved_device.as_ref() != Some(&ticket.id) { + let mut next = saved.clone(); + next.saved_device = Some(ticket.id.clone()); + settings::save(&state.settings_path, &next)?; + *saved = next; + } + } + backend::restore_device_preferences(app, &client).await; + // Optional metadata must never turn a healthy secondary link into reconnect churn. + if current(app, ticket) { + let _ = crate::device_routing::report_local_host(&client).await; + } + if !current(app, ticket) { + return Err("连接请求已取消".into()); + } + if client.status().phase != ConnectionPhase::Ready { + return Err("设备在恢复设置时断开,将自动重试".into()); + } + Ok(()) +} + +fn finish(app: &tauri::AppHandle, ticket: &Ticket, result: &Result<(), String>) { + let state = app.state::(); + { + let mut policy = state.ble_recovery.lock().unwrap(); + if state.closing.load(Ordering::SeqCst) || !policy.valid(ticket) { + return; + } + policy.complete(ticket, result.is_ok(), Instant::now()); + *state.ble_error.lock().unwrap() = result.as_ref().err().cloned(); + } + backend::pulse(app); +} + +/// Opt-in integration probe: release only our GATT session, keeping recovery +/// intent. This tests recovery plumbing, not actual firmware sleep or radio loss. +#[tauri::command] +pub async fn test_ble_link_loss(window: tauri::WebviewWindow) -> Result<(), String> { + backend::require_main(&window)?; + if std::env::var("AHAKEY_BLE_TEST").as_deref() != Ok("1") { + return Err("BLE 故障模拟未启用".into()); + } + let state = window.state::(); + let _gate = state.ble_connection_gate.lock().await; + let client = state.ble.lock().await.clone().ok_or("蓝牙尚未初始化")?; + if client.status().phase != ConnectionPhase::Ready { + return Err("仅对已就绪设备运行测试".into()); + } + client.disconnect().await.map_err(|e| e.to_string())?; + backend::pulse(window.app_handle()); + Ok(()) +} + +/// Started exactly once at application setup, including when no device is saved. +pub fn start(app: &tauri::AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(1)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tick.tick().await; + let state = app.state::(); + if state.closing.load(Ordering::SeqCst) { + break; + } + let Ok(_gate) = state.ble_connection_gate.try_lock() else { + continue; + }; + let link = state.ble.lock().await.as_ref().map(|c| c.status()); + if link.as_ref().is_some_and(|s| { + matches!( + s.phase, + ConnectionPhase::Ready | ConnectionPhase::Connecting + ) + }) { + continue; + } + let ticket = state.ble_recovery.lock().unwrap().begin(Instant::now()); + if let Some(ticket) = ticket { + backend::pulse(&app); + let result = attempt(&app, &ticket).await; + finish(&app, &ticket, &result); + } + } + }); +} + +pub async fn connect(app: &tauri::AppHandle, id: String) -> Result<(), String> { + let client = backend::ble_client(app).await?; + let state = app.state::(); + let ticket = { + let mut policy = state.ble_recovery.lock().unwrap(); + policy.select(id, Instant::now()); + client.cancel_pending(); + policy + .begin(Instant::now()) + .expect("new explicit intent is immediately eligible") + }; + backend::pulse(app); + let _gate = state.ble_connection_gate.lock().await; + let result = attempt(app, &ticket).await; + finish(app, &ticket, &result); + result +} + +pub async fn disconnect(app: &tauri::AppHandle) -> Result<(), String> { + let state = app.state::(); + let client: Option> = state.ble.lock().await.clone(); + let epoch = { + let mut policy = state.ble_recovery.lock().unwrap(); + policy.pause(); + if let Some(client) = &client { + client.cancel_pending(); + } + policy.epoch + }; + backend::pulse(app); + let _gate = state.ble_connection_gate.lock().await; + if state.ble_recovery.lock().unwrap().epoch != epoch { + return Ok(()); + } + if let Some(client) = client { + client.disconnect().await.map_err(|e| e.to_string())?; + } + *state.ble_error.lock().unwrap() = None; + *state.settings_notice.lock().unwrap() = + "已手动断开,本次运行暂停自动重连;点击连接可恢复".into(); + backend::pulse(app); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn sleep_failure_retries_until_awake_and_resets_backoff() { + let now = Instant::now(); + let mut p = Recovery::new(None); + p.select("keyboard".into(), now); + for (i, delay) in [2, 4, 8, 16, 30, 30].into_iter().enumerate() { + let t = p.begin(p.next_at).unwrap(); + assert_eq!(p.attempt, i as u32 + 1); + assert!(p.begin(p.next_at).is_none()); + p.complete(&t, false, now); + assert_eq!(p.next_at.duration_since(now), Duration::from_secs(delay)); + assert!(p.begin(p.next_at - Duration::from_millis(1)).is_none()); + } + let t = p.begin(p.next_at).unwrap(); + p.complete(&t, true, now); + assert_eq!(p.attempt, 0); + // A later sleep is immediately eligible, without an old 30-second penalty. + assert!(p.begin(now).is_some()); + } + #[test] + fn manual_disconnect_and_shutdown_invalidate_pending_success() { + let now = Instant::now(); + let mut p = Recovery::new(Some("a".into())); + let old = p.begin(now + Duration::from_secs(1)).unwrap(); + p.pause(); + p.complete(&old, true, now); + assert!(!p.valid(&old)); + assert!(p.begin(now + Duration::from_secs(3600)).is_none()); + assert!(!p.status().enabled); + } + #[test] + fn switching_device_rejects_old_completion_and_no_saved_device_stays_idle() { + let now = Instant::now(); + let mut p = Recovery::new(None); + assert!(p.begin(now).is_none()); + p.select("a".into(), now); + let old = p.begin(now).unwrap(); + p.select("b".into(), now); + let next = p.begin(now).unwrap(); + p.complete(&old, false, now); + assert!(p.in_flight); + assert!(p.valid(&next)); + assert_eq!(p.attempt, 1); + } +} diff --git a/ahakey-desktop/src/RoutingPanel.tsx b/ahakey-desktop/src/RoutingPanel.tsx new file mode 100644 index 00000000..e0b93e8e --- /dev/null +++ b/ahakey-desktop/src/RoutingPanel.tsx @@ -0,0 +1,197 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { HostInfo, reportedHostLabel, DevicePolicy, canReset } from "./routing"; +import { HostTarget, RoutingConfig, RoutingStatus, RoutingTransport, PairingDetails, hostNames, linkLabel, sameRouting, validRouting, effectivePair, pairingLabel, pairingReason } from "./routing"; + +export function RoutingPanel({ ready, usbSupported = false, usbPresent, generation = 0 }: { ready: boolean; usbSupported?: boolean; usbPresent?:boolean|null; generation?: number }) { + const [transport, setTransport] = useState("usb"); + const available = usbSupported || ready; + const [policy,setPolicy]=useState(null); + const [policyError,setPolicyError]=useState(""); + const [resetTarget,setResetTarget]=useState<0|1|2|null>(null); + const [status, setStatus] = useState(null); + const [draft, setDraft] = useState({ mode: 1, up: 2, down: 1 }); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [pairing, setPairing] = useState(null); + const [pairingError, setPairingError] = useState(""); + const [confirmSwap, setConfirmSwap] = useState(false); + const [hosts, setHosts] = useState(null); + const [hostError, setHostError] = useState(""); + const [aliases, setAliases] = useState<[string,string]>(["",""]); + const [savedAliases, setSavedAliases] = useState<[string,string] | null>(null); + const [aliasBusy, setAliasBusy] = useState(false); + const [aliasMessage, setAliasMessage] = useState(""); + useEffect(() => { + let active=true; + invoke<[string,string]>("get_host_aliases").then(v => { + if(active){setAliases(v);setSavedAliases(v);} + }).catch(e => {if(active)setAliasMessage(`备注读取失败:${String(e)}`);}); + return () => {active=false;}; + }, []); + const saveAliases = async () => { + if(!savedAliases || aliasBusy) return; + setAliasBusy(true);setAliasMessage(""); + try { + const next=await invoke<[string,string]>("set_host_aliases",{aliases}); + setAliases(next);setSavedAliases(next);setAliasMessage("槽位备注已保存在本机,未修改键盘配对。"); + } catch(e){setAliasMessage(`备注未保存:${String(e)}`);} + finally{setAliasBusy(false);} + }; + const epoch = useRef(0); + const gate = useRef(false); + const loadState = async (current: number, chosen:RoutingTransport=transport) => { + if(current===epoch.current){setHosts(null);setHostError("");setPolicy(null);} + const next = await invoke("get_device_routing", { transport:chosen }); + if (current !== epoch.current) return; + setStatus(next); setDraft(next.config); setPairing(null); setPairingError(""); + try { + const details = await invoke("get_device_pairing", { transport:chosen }); + if (current === epoch.current) setPairing(details); + } catch (e) { if (current === epoch.current) setPairingError(`配对详情不可用:${String(e)}`); } + try { + const nextHosts=await invoke("get_device_hosts",{transport:chosen}); + if(current===epoch.current)setHosts(nextHosts); + } catch(e){if(current===epoch.current)setHostError(`名称读取不可用:${String(e)}`);} + try{ + const nextPolicy=await invoke("get_device_policy",{transport:chosen}); + if(current===epoch.current){setPolicy(nextPolicy);setPolicyError("");} + }catch(e){if(current===epoch.current){setPolicy(null);setPolicyError(String(e));}} + }; + const read = async () => { + if (!available || gate.current) return; + const current = epoch.current; + gate.current = true; setBusy(true); setError(""); setMessage(""); + try { + const chosen=await invoke("select_configuration_transport"); + if(current!==epoch.current)return; + setTransport(chosen); + await loadState(current,chosen); + if (current === epoch.current) setMessage("已读取设备配置"); + } catch (e) { + if (current === epoch.current) { setStatus(null); setError(`未取得设备确认:${String(e)}。请检查${transport === "usb" ? "USB 数据线" : "系统蓝牙配对"}后重新读取。`); } + } finally { if (current === epoch.current) { gate.current = false; setBusy(false); } } + }; + useEffect(() => { + ++epoch.current;gate.current = false;setBusy(false);setStatus(null);setPolicy(null);setResetTarget(null);setPairing(null);setHosts(null);setHostError("");setPairingError("");setConfirmSwap(false);setError("");setMessage(""); + void read(); + return () => { ++epoch.current; }; + }, [available, usbPresent, generation, ready]); + const apply = async () => { + if (!available || !status || gate.current || !validRouting(draft)) return; + const current = epoch.current; + gate.current = true; setBusy(true); setError(""); setMessage(""); + const submitted = { ...draft }; + try { + const next = await invoke("set_device_routing", { config: submitted, transport }); + if (current === epoch.current) { + setStatus(next); setDraft(next.config); + setPairing(null); + await loadState(current); + if (current !== epoch.current) return; + setMessage(next.routingError ? "设备已保存配置,正在等待旧目标释放按键;可刷新确认。" : "设备已确认保存配置"); + } + } catch (e) { + if (current === epoch.current) { setError(`未确认保存成功:${String(e)}。请重新读取设备确认,勿重复连续点击。`); setStatus(null); } + } finally { if (current === epoch.current) { gate.current = false; setBusy(false); } } + }; + const edit = (patch: Partial) => { setDraft(v => ({ ...v, ...patch })); setMessage(""); }; + const manage = async (action: "swapSlots" | "retry") => { + if (!available || !pairing || gate.current || (action === "swapSlots" && !confirmSwap)) return; + const current = epoch.current; let acknowledged = false; gate.current = true; setBusy(true); setError(""); setMessage(""); + try { + await invoke("manage_device_pairing", { action, transport }); + acknowledged = true; + await loadState(current); + if (current === epoch.current) { setConfirmSwap(false); setMessage(action === "swapSlots" ? "设备已确认互换 A/B,原配对保留。" : "设备已确认重新尝试连接,输入目标未改变。"); } + } catch (e) { if (current === epoch.current) { setError(`${acknowledged ? "操作已确认,但刷新失败" : "操作未确认"}:${String(e)}。请重新读取,勿重复提交。`); setPairing(null); setConfirmSwap(false); } } + finally { if (current === epoch.current) { gate.current = false; setBusy(false); } } + }; + const wiredPair = effectivePair(draft, true), wirelessPair = effectivePair(draft, false); + const resetAllowed=canReset(transport,policy,busy,usbPresent); + const reset = async () => { + if(!resetAllowed || resetTarget===null || gate.current)return; + const target=resetTarget,current=epoch.current; + gate.current=true;setBusy(true);setError("");setMessage(""); + try{ + await invoke("reset_device_pairing",{target}); + if(current!==epoch.current)return; + setResetTarget(null);await loadState(current); + if(current===epoch.current)setMessage(`已确认重置${target===2?"全部蓝牙绑定":hostNames[target]}。请在目标电脑的系统蓝牙中删除旧 AhaKey 配对后重新配对。`); + }catch(e){if(current===epoch.current){setError(`重置未确认完成:${String(e)}。请重新读取,勿重复提交。`);setResetTarget(null);}} + finally{if(current===epoch.current){gate.current=false;setBusy(false);}} + }; + const reportHost = async () => { + if(!ready || gate.current)return; + const current=epoch.current;gate.current=true;setBusy(true);setHostError(""); + try { + await invoke("report_this_host"); + if(current!==epoch.current)return; + await loadState(current); + if(current===epoch.current)setMessage("本机名称已通过本机蓝牙连接上报。"); + }catch(e){if(current===epoch.current)setHostError(String(e));} + finally{if(current===epoch.current){gate.current=false;setBusy(false);}} + }; + const dirty = !!status && !sameRouting(draft, status.config); + return
+

拨杆与输入目标

{status ? "上次读取的设备状态" : "需要支持三路路由的固件"}
+

切设备模式:插线时上端固定 USB,拔线后恢复上端蓝牙,下端蓝牙不变。需要固件 0.1.9+。

+

配置通道:{status ? transport==="usb" ? "USB(自动优先)" : "蓝牙(自动选择)" : "尚未确认"}。重置只通过 USB,失败不会切换通道重发。

+

配置连接不是输入目标。屏幕冒号前的 A / B / U 才是当前输入目标,右侧字母仅表示链路就绪。

+
{hostNames.map((name, i) =>
+ {name} + {i<2 && <>{reportedHostLabel(hosts?.[i])}{hosts?.[i]?.system ? "来源:对端客户端上报 · 上次读取" : "由对端新版客户端提供"}} + {pairing ? pairingLabel(pairing, i as HostTarget) : status ? linkLabel(status, i as HostTarget) : "连接状态未读取"} + {(pairing?.selected ?? status?.selected) === i ? "当前输入目标" : "未选中"}{!pairing && i < 2 ? " · 配对详情未知" : ""} + {i<2 && } +
)}
+
+ + +
+ {aliasMessage &&

{aliasMessage}

} +

自动名称需要固件 0.1.8+,以及对端运行新版客户端并连接蓝牙;仅在系统中配对不会上报名称。名称按 UTF-8 最多 24 字节,断开后清除。备注属于本机槽位,不代表自动识别;互换 A/B 或重新配对后请核对备注。

+ {hostError &&

{hostError};不会影响按键或配对。

} + {pairing &&

USB {pairing.wired ? "已接入" : "未接入"}:当前上 {hostNames[pairing.effectiveUp]},下 {hostNames[pairing.effectiveDown]}。{pairing.radioState === 3 ? "HOLD:自动重连已暂停,可手动重试。" : pairing.radioState === 2 ? `配对窗口:${pairing.remainingSeconds} 秒(上次读取)。` : "陌生设备配对未开放。"} 最近记录:{pairingReason(pairing.lastReason)},HCI {pairing.lastHci === 255 ? "未知" : `0x${pairing.lastHci.toString(16).padStart(2, "0")}`}。

} + {pairingError &&

{pairingError};不会将未知状态当成未配对。

} + {pairing &&

键盘保存 {pairing.bondCount} 条配对,当前 {pairing.rawLinks} 条蓝牙物理连接。{pairing.pending ? "部分链路正在完成配对或槽位绑定,请稍后重新读取。" : ""}

} +
+ + {draft.mode===1 ? : +
{(["up", "down"] as const).map((side, i) => )}
} +
+ {policy && !validRouting(draft) &&

请选择有效目标;切设备模式上端固定为 USB / 蓝牙。

} + {policy && pairing && validRouting(draft) &&

配置预览:插线为上 {hostNames[wiredPair[0]]} / 下 {hostNames[wiredPair[1]]};拔线为上 {hostNames[wirelessPair[0]]} / 下 {hostNames[wirelessPair[1]]}。

} + {!policy && status &&

新规则及重置暂不可用:{policyError || "等待固件能力确认"}。旧固件的状态仍可读取。

} +

{draft.mode === 1 ? "切设备模式不发出自动批准状态;拨杆中间位置保持原目标。" : "批准模式下,双击独立电源 / 模式键切换这两个输入目标。保留设备的旧批准状态协议,但 Rust 客户端不会代你开启外部工具自动批准。"}

+

蓝牙 A / B 是设备保存的槽位,不是连接先后顺序。USB 配置暂仅支持 Windows。设置不会随 profile 自动覆盖。

+
+ + +
+ {confirmSwap &&

互换 A/B 标签与槽位关联,保留原配对和实际输入位置。请先松开实体按键。

} +
蓝牙重置(仅 USB) +

单槽重置保留另一槽、键盘地址及按键设置。全部重置清除 A/B 和旧绑定。请先松开按键。

+
{([0,1,2] as const).map(target=>)}
+ {!resetAllowed && policy?.state!==1 &&

请接入 USB 并读取支持的新固件;蓝牙连接不能执行客户端重置。

} + {policy?.state===1 &&

设备正在重置{policy.target===2?"全部蓝牙":hostNames[policy.target ?? 0]},请等待后重新读取。

} + {policy?.state===2 &&

设备确认上次重置已完成:{policy.target===2?"全部蓝牙":hostNames[policy.target ?? 0]}。

} + {policy?.state===3 &&

上次重置未确认完成(错误 {policy.error}),可能已断开目标或部分清除。请先核对配对状态,不要连续重复提交。

} + {resetTarget!==null &&

确认清除{resetTarget===2?"全部蓝牙绑定":hostNames[resetTarget]+"绑定"}?目标电脑随后需要删除系统中的旧 AhaKey 配对并重新配对。不会改变键盘蓝牙地址。

} +
+

硬件长按:选中 A/B 仅重置该槽;USB 位置只提示先选择蓝牙。按住期间移动拨杆会取消本次重置。

+
+ + +
+

{!available ? "请接入 USB,或先在系统配对并连接客户端蓝牙。" : busy ? "等待设备回传确认…" : message}

+ {error &&

{error}

} +
; +} diff --git a/ahakey-desktop/src/device.test.ts b/ahakey-desktop/src/device.test.ts new file mode 100644 index 00000000..fa1caaf8 --- /dev/null +++ b/ahakey-desktop/src/device.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from "vitest"; +import { deviceConnectionLabel, previewSettings, type HardwareStatus } from "./contracts"; +const status: HardwareStatus={batteryLevel:76,signal:50,firmwareMain:1,firmwareSub:0,workMode:3,lightMode:5,lightBrightness:35}; +it("USB information is not labeled unready when BLE is absent", () => { + expect(deviceConnectionLabel({device:{transport:"usb",name:"AhaKey",status},nativeKeyTestEnabled:true})).toBe("USB 已连接 · 语音键已开启"); + expect(deviceConnectionLabel({device:{transport:"ble",name:"AhaKey",status},nativeKeyTestEnabled:false})).toBe("蓝牙 已连接 · 语音键未开启"); + expect(deviceConnectionLabel({device:{transport:null,name:null,status:null},nativeKeyTestEnabled:true})).toContain("等待键盘"); +}); +it("renderer voice-listener default is on without starting a recording", () => { + expect(previewSettings.voiceKeysEnabled).toBe(true); + expect(previewSettings.triggerMode).toBe("hold"); +}); +it("distinguishes missing USB, failed detection, and failed status without inventing readiness", () => { + const base = {device:{transport:null,name:null,status:null},nativeKeyTestEnabled:true}; + const usb = {supported:true,present:true,status:null,error:"timeout"}; + expect(deviceConnectionLabel({...base,usb})).toBe("USB 已识别 · 状态读取失败"); + expect(deviceConnectionLabel({...base,usb:{...usb,present:null}})).toBe("本机服务运行 · USB 通信异常"); + expect(deviceConnectionLabel({...base,usb:{...usb,present:false,error:null}})).toContain("等待键盘"); + expect(deviceConnectionLabel({...base,usb,device:{transport:"ble",name:"AhaKey",status}})).toContain("蓝牙 已连接"); +}); diff --git a/ahakey-desktop/src/host-info.test.ts b/ahakey-desktop/src/host-info.test.ts new file mode 100644 index 00000000..22370bba --- /dev/null +++ b/ahakey-desktop/src/host-info.test.ts @@ -0,0 +1,8 @@ +import { expect, it } from "vitest"; +import { reportedHostLabel } from "./routing"; +it("keeps unknown, unreported and client-reported host identity distinct", () => { + expect(reportedHostLabel(undefined)).toBe("设备名称未读取"); + expect(reportedHostLabel({name:null,system:null})).toBe("名称未上报"); + expect(reportedHostLabel({name:null,system:"macOS"})).toBe("主机名未知 · macOS"); + expect(reportedHostLabel({name:"开发电脑",system:"Windows"})).toBe("开发电脑 · Windows"); +}); diff --git a/ahakey-desktop/src/routing.test.ts b/ahakey-desktop/src/routing.test.ts new file mode 100644 index 00000000..d4646b48 --- /dev/null +++ b/ahakey-desktop/src/routing.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { linkLabel, sameRouting, validRouting, routingAvailable, effectivePair, pairingLabel, canReset, type DevicePolicy, type RoutingConfig, type PairingDetails } from "./routing"; +describe("two lever targets from three links", () => { + it("preserves arbitrary pairs only in approve mode; host mode fixes upper USB", () => { + for (const up of [0,1,2]) for (const down of [0,1,2]) if(up !== down) { + const c={mode:0,up,down} as RoutingConfig; + expect(effectivePair(c,true)).toEqual([up,down]); + expect(effectivePair(c,false)).toEqual([up===2?1-down:up,down===2?1-up:down]); + } + expect(effectivePair({mode:1,up:2,down:1},false)).toEqual([0,1]); + expect(effectivePair({mode:1,up:2,down:0},false)).toEqual([1,0]); + }); + it("never labels unknown pairing information as unpaired", () => { + expect(pairingLabel(null,0)).toBe("配对详情未知"); + const d={paired:2,connected:6,ready:4} as PairingDetails; + expect(pairingLabel(d,1)).toBe("已配对 · 已连接 · 未就绪"); + expect(pairingLabel(d,0)).toBe("未绑定配对 · 未连接"); + expect(pairingLabel(d,2)).toBe("USB 可输入"); + }); + it("allows USB configuration with disconnected BLE without automatic fallback", () => { + expect(routingAvailable("usb", true, false)).toBe(true); + expect(routingAvailable("ble", true, false)).toBe(false); + expect(routingAvailable("usb", false, true)).toBe(false); + expect(routingAvailable("ble", false, true)).toBe(true); + }); + it("accepts six approve pairs and only two fixed-upper host pairs", () => { + for (const mode of [0, 1]) for (const up of [0, 1, 2]) for (const down of [0, 1, 2]) + expect(validRouting({ mode, up, down } as RoutingConfig)).toBe(up !== down && (mode===0 || (up===2&&down<2))); + expect(validRouting({ mode: 1, up: 3, down: 0 } as unknown as RoutingConfig)).toBe(false); + }); + it("never enables reset on BLE, absent USB, unknown firmware or pending operation", () => { + const p={fixedUpperUsb:true,state:0,target:null,error:0,request:0,paired:3} as DevicePolicy; + expect(canReset("usb",p,false,true)).toBe(true); + expect(canReset("ble",p,false,true)).toBe(false); + expect(canReset("usb",p,false,false)).toBe(false); + expect(canReset("usb",null,false,true)).toBe(false); + expect(canReset("usb",p,true,true)).toBe(false); + expect(canReset("usb",{...p,state:1},false,true)).toBe(false); + }); + it("distinguishes connectivity from readiness and configuration equality", () => { + const config: RoutingConfig = { mode: 1, up: 2, down: 0 }; + const s = { config, selected: 2 as const, connected: 5, ready: 1, lever: 0, routingError: 0 }; + expect(linkLabel(s, 0)).toBe("可输入"); + expect(linkLabel(s, 1)).toBe("离线"); + expect(linkLabel(s, 2)).toBe("已连接 · 未就绪"); + expect(sameRouting(config, { ...config })).toBe(true); + expect(sameRouting(config, { ...config, up: 0, down: 2 })).toBe(false); + }); +}); diff --git a/ahakey-desktop/src/routing.ts b/ahakey-desktop/src/routing.ts new file mode 100644 index 00000000..9ed55e69 --- /dev/null +++ b/ahakey-desktop/src/routing.ts @@ -0,0 +1,56 @@ +export type HostTarget = 0 | 1 | 2; +export type RoutingTransport = "usb" | "ble"; +export interface HostInfo { name: string | null; system: string | null } +export interface DevicePolicy {fixedUpperUsb:boolean;state:number;target:number|null;error:number;request:number;paired:number} +export function canReset(transport:RoutingTransport,policy:DevicePolicy|null,busy:boolean,usbPresent?:boolean|null):boolean{ + return transport==="usb"&&usbPresent!==false&&!!policy&&policy.state!==1&&!busy; +} +export function reportedHostLabel(info: HostInfo | undefined): string { + if (!info) return "设备名称未读取"; + if (!info.name && !info.system) return "名称未上报"; + return [info.name || "主机名未知", info.system].filter(Boolean).join(" · "); +} +export function routingAvailable(transport: RoutingTransport, usbSupported: boolean, bleReady: boolean): boolean { + return transport === "usb" ? usbSupported : bleReady; +} +export interface RoutingConfig { mode: 0 | 1; up: HostTarget; down: HostTarget } +export interface RoutingStatus { + config: RoutingConfig; + selected: HostTarget; + connected: number; + ready: number; + lever: number; + routingError: number; +} +export interface PairingDetails { + paired: number; connected: number; ready: number; effectiveUp: HostTarget; effectiveDown: HostTarget; + wired: boolean; radioState: number; pairingSlot: 0 | 1 | null; remainingSeconds: number; + bondCount: number; lastReason: number; lastHci: number; pending: boolean; selected: HostTarget; rawLinks: number; +} +export function effectivePair(config: RoutingConfig, wired: boolean): [HostTarget, HostTarget] { + if(config.mode===1)return [wired?2:(1-config.down) as HostTarget,config.down]; + const replace = (target: HostTarget, other: HostTarget): HostTarget => !wired && target === 2 ? (1 - other) as HostTarget : target; + return [replace(config.up, config.down), replace(config.down, config.up)]; +} +export function pairingLabel(details: PairingDetails | null, target: HostTarget): string { + if (!details) return "配对详情未知"; + if (target === 2) return details.ready & 4 ? "USB 可输入" : details.connected & 4 ? "USB 已连接 · 未就绪" : "USB 未连接"; + const paired = details.paired & (1 << target); + return `${paired ? "已配对" : "未绑定配对"} · ${details.ready & (1 << target) ? "已连接 · 可输入" : details.connected & (1 << target) ? "已连接 · 未就绪" : "未连接"}`; +} +export function pairingReason(code: number): string { + return ({ 0: "无", 4: "配对元数据异常", 5: "身份不可用", 6: "槽位未能分配", 7: "槽位身份冲突", 8: "存储失败", 9: "配对失败", 10: "安全请求失败", 11: "握手超时", 12: "连接断开", 13: "配对已禁用", 14: "密钥长度异常", 15: "等待 SDK 完成绑定" } as Record)[code] ?? `错误 ${code}`; +} +export const hostNames = ["蓝牙 A", "蓝牙 B", "USB 有线"] as const; +export function validRouting(config: RoutingConfig): boolean { + return [0, 1].includes(config.mode) && [0, 1, 2].includes(config.up) + && [0, 1, 2].includes(config.down) && config.up !== config.down + && (config.mode===0 || (config.up===2 && config.down<2)); +} +export function sameRouting(a: RoutingConfig, b: RoutingConfig): boolean { + return a.mode === b.mode && a.up === b.up && a.down === b.down; +} +export function linkLabel(status: RoutingStatus, target: HostTarget): string { + return status.ready & (1 << target) ? "可输入" + : status.connected & (1 << target) ? "已连接 · 未就绪" : "离线"; +} From 33d2d2e489874e37afe25a7503e84baac3f370cb Mon Sep 17 00:00:00 2001 From: Lihao Date: Mon, 14 Sep 2026 02:58:53 -0700 Subject: [PATCH 08/21] feat(display): add artwork previews and extensible quota cards Introduce the feature implementation and its focused tests/components. The following UI and runtime commits connect the shared application entry points. --- ahakey-desktop/src-tauri/src/quota/mod.rs | 449 ++++++++++++ ahakey-desktop/src-tauri/src/quota/model.rs | 415 +++++++++++ ahakey-desktop/src/DisplayPanel.tsx | 764 ++++++++++++++++++++ ahakey-desktop/src/display.test.ts | 53 ++ ahakey-desktop/src/display.ts | 164 +++++ 5 files changed, 1845 insertions(+) create mode 100644 ahakey-desktop/src-tauri/src/quota/mod.rs create mode 100644 ahakey-desktop/src-tauri/src/quota/model.rs create mode 100644 ahakey-desktop/src/DisplayPanel.tsx create mode 100644 ahakey-desktop/src/display.test.ts create mode 100644 ahakey-desktop/src/display.ts diff --git a/ahakey-desktop/src-tauri/src/quota/mod.rs b/ahakey-desktop/src-tauri/src/quota/mod.rs new file mode 100644 index 00000000..25be6c33 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/quota/mod.rs @@ -0,0 +1,449 @@ +mod model; +pub use model::{Account, Cards, Provider, Window}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + fs, + path::Path, + sync::Mutex, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tauri::Manager; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use zeroize::Zeroizing; + +const MAX_RESPONSE: usize = 1024 * 1024; +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QuotaResult { + pub account_id: String, + pub state: String, + pub windows: Vec, + pub checked_at: i64, + pub updated_at: Option, + pub error: Option, +} +#[derive(Default)] +pub struct Service { + config_gate: tokio::sync::Mutex<()>, + query_gate: tokio::sync::Mutex<()>, + cache: Mutex>, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CardsSnapshot { + pub config: Cards, + pub results: Vec, +} +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} +fn path(w: &tauri::WebviewWindow) -> Result { + crate::backend::require_main(w)?; + Ok(w.state::() + .settings_path + .parent() + .ok_or("设置路径无效")? + .join("display-cards.json")) +} +fn load(p: &Path) -> Result { + let bytes = match fs::read(p) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Cards::default()), + Err(_) => return Err("无法读取卡片配置".into()), + }; + if bytes.len() > 128 * 1024 { + return Err("卡片配置过大,原文件已保留".into()); + } + let c: Cards = serde_json::from_slice(&bytes).map_err(|_| "卡片配置格式错误,原文件已保留")?; + c.validate()?; + Ok(c) +} +fn store(p: &Path, cards: &Cards) -> Result<(), String> { + cards.validate()?; + load(p)?; + let parent = p.parent().ok_or("设置路径无效")?; + fs::create_dir_all(parent).map_err(|_| "无法创建卡片目录")?; + let pending = p.with_extension("json.pending"); + let encoded = serde_json::to_vec_pretty(cards).map_err(|_| "卡片编码失败")?; + { + use std::io::Write; + let mut file = fs::File::create(&pending).map_err(|_| "无法写入临时配置")?; + file.write_all(&encoded) + .and_then(|_| file.sync_all()) + .map_err(|_| "卡片保存失败")?; + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + let src: Vec = pending.as_os_str().encode_wide().chain(Some(0)).collect(); + let dst: Vec = p.as_os_str().encode_wide().chain(Some(0)).collect(); + if unsafe { + MoveFileExW( + src.as_ptr(), + dst.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + return Err("原卡片配置已保留,替换失败".into()); + } + } + #[cfg(not(windows))] + fs::rename(&pending, p).map_err(|_| "原卡片配置已保留,替换失败")?; + Ok(()) +} +fn account_at(w: &tauri::WebviewWindow, id: &str) -> Result { + load(&path(w)?)? + .accounts + .into_iter() + .find(|a| a.id == id) + .ok_or("找不到账户,请先保存卡片配置".into()) +} +fn credential_namespace(identifier: &str) -> String { + format!("{identifier}.quota") +} +fn entry(namespace: &str, a: &Account) -> Result { + keyring::Entry::new(namespace, &a.credential_id()).map_err(|_| "系统凭据库不可用".into()) +} + +#[tauri::command] +pub fn get_cards(window: tauri::WebviewWindow) -> Result { + let config = load(&path(&window)?)?; + let service = window.state::(); + let cache = service.cache.lock().unwrap(); + let results = config + .accounts + .iter() + .filter_map(|a| { + cache + .get(&a.id) + .filter(|(old, _)| old == a) + .map(|(_, r)| r.clone()) + }) + .collect(); + Ok(CardsSnapshot { config, results }) +} +#[tauri::command] +pub async fn save_cards(window: tauri::WebviewWindow, config: Cards) -> Result<(), String> { + let p = path(&window)?; + let service = window.state::(); + let _guard = service.config_gate.lock().await; + store(&p, &config)?; + service + .cache + .lock() + .unwrap() + .retain(|_, (a, _)| config.accounts.contains(a)); + Ok(()) +} +#[tauri::command] +pub async fn save_quota_key( + window: tauri::WebviewWindow, + account_id: String, + token: String, +) -> Result<(), String> { + let token = Zeroizing::new(token); + let a = account_at(&window, &account_id)?; + if a.provider == Provider::Codex { + return Err("Codex 使用本机官方登录,不在此保存令牌".into()); + } + if token.is_empty() || token.len() > 8192 || !token.bytes().all(|b| b.is_ascii_graphic()) { + return Err("密钥为空或格式无效".into()); + } + let service = window.state::(); + let _guard = service.query_gate.lock().await; + entry( + &credential_namespace(&window.app_handle().config().identifier), + &a, + )? + .set_password(&token) + .map_err(|_| "无法保存到系统凭据库;没有写入明文")?; + service.cache.lock().unwrap().remove(&account_id); + Ok(()) +} +#[tauri::command] +pub async fn clear_quota_key( + window: tauri::WebviewWindow, + account_id: String, +) -> Result<(), String> { + let a = account_at(&window, &account_id)?; + let service = window.state::(); + let _guard = service.query_gate.lock().await; + match entry( + &credential_namespace(&window.app_handle().config().identifier), + &a, + )? + .delete_credential() + { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(_) => return Err("无法清除系统凭据".into()), + } + service.cache.lock().unwrap().remove(&account_id); + Ok(()) +} +async fn fetch_http(account: &Account, namespace: &str) -> Result { + let token = Zeroizing::new( + entry(namespace, account)? + .get_password() + .map_err(|_| "未配置密钥或系统凭据库已锁定")?, + ); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|_| "无法建立额度查询客户端")?; + let request = client + .get(account.endpoint()) + .header("Accept", "application/json"); + let request = if account.provider == Provider::Glm { + request.header("Authorization", token.as_str()) + } else { + request.bearer_auth(token.as_str()) + }; + let mut response = request.send().await.map_err(|_| "额度查询网络失败或超时")?; + match response.status().as_u16() { + 200..=299 => {} + 401 | 403 => return Err("认证失败,请检查账户地区、套餐和密钥".into()), + 429 => return Err("查询受到限流,请稍后刷新".into()), + s => return Err(format!("额度接口返回 HTTP {s}")), + } + if response + .content_length() + .is_some_and(|n| n > MAX_RESPONSE as u64) + { + return Err("额度响应超过大小限制".into()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| "额度响应读取失败")? { + if bytes.len() + chunk.len() > MAX_RESPONSE { + return Err("额度响应超过大小限制".into()); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(|_| "额度响应不是有效 JSON".into()) +} +async fn fetch_codex() -> Result { + #[cfg(windows)] + let binary = "codex.exe"; + #[cfg(not(windows))] + let binary = "codex"; + let mut cmd = tokio::process::Command::new(binary); + cmd.arg("app-server") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + #[cfg(windows)] + cmd.creation_flags(0x08000000); + let mut child = cmd + .spawn() + .map_err(|_| "找不到 Codex CLI;安装并完成官方登录后重试")?; + let input = child.stdin.take().ok_or("Codex 输入通道不可用")?; + let output = child.stdout.take().ok_or("Codex 输出通道不可用")?; + let result = tokio::time::timeout(Duration::from_secs(20), codex_exchange(output, input)) + .await + .unwrap_or_else(|_| Err("Codex 额度查询超时".into())); + let _ = child.kill().await; + let _ = child.wait().await; + result +} +async fn codex_exchange( + output: impl tokio::io::AsyncRead + Unpin, + mut input: impl tokio::io::AsyncWrite + Unpin, +) -> Result { + let mut lines = BufReader::new(output.take(MAX_RESPONSE as u64)).lines(); + let init = json!({"id":1,"method":"initialize","params":{"clientInfo":{"name":"ahakey-quota","version":"0.1.0"}}}); + input + .write_all(format!("{init}\n").as_bytes()) + .await + .map_err(|_| "Codex 初始化发送失败")?; + let mut initialized = false; + while let Some(line) = lines.next_line().await.map_err(|_| "Codex 响应读取失败")? { + let v: Value = serde_json::from_str(&line).map_err(|_| "Codex 响应格式错误")?; + if v["id"] == 1 { + if initialized || v.get("error").is_some() || v.get("result").is_none() { + return Err("Codex 初始化失败,请检查本机版本".into()); + } + initialized = true; + input.write_all(b"{\"method\":\"initialized\"}\n{\"id\":2,\"method\":\"account/rateLimits/read\"}\n") + .await.map_err(|_| "Codex 额度请求发送失败")?; + } else if initialized && v["id"] == 2 { + return v + .get("result") + .filter(|v| v.is_object()) + .cloned() + .ok_or("Codex 未返回额度,请确认官方登录和接口支持".into()); + } + } + Err("Codex 提前退出或响应超限".into()) +} +fn finish( + account: &Account, + fetched: Result, String>, + old: Option<&QuotaResult>, + at: i64, +) -> QuotaResult { + match fetched { + Ok(windows) => QuotaResult { + account_id: account.id.clone(), + state: if windows.iter().any(|w| w.remaining_percent.is_some()) { + "ready" + } else { + "unknown" + } + .into(), + windows, + checked_at: at, + updated_at: Some(at), + error: None, + }, + Err(error) => QuotaResult { + account_id: account.id.clone(), + state: if old.and_then(|v| v.updated_at).is_some() { + "stale" + } else { + "error" + } + .into(), + windows: old.map(|v| v.windows.clone()).unwrap_or_default(), + checked_at: at, + updated_at: old.and_then(|v| v.updated_at), + error: Some(error), + }, + } +} +#[tauri::command] +pub async fn refresh_quota( + window: tauri::WebviewWindow, + account_id: String, +) -> Result { + let account = account_at(&window, &account_id)?; + account.validate()?; + if !account.enabled { + return Err("账户已停用".into()); + } + let service = window.state::(); + let _guard = service + .query_gate + .try_lock() + .map_err(|_| "另一额度查询正在进行")?; + let body = if account.provider == Provider::Codex { + fetch_codex().await + } else { + fetch_http( + &account, + &credential_namespace(&window.app_handle().config().identifier), + ) + .await + }; + let fetched = body.and_then(|body| model::parse(&account, &body)); + if account_at(&window, &account_id)? != account { + return Err("查询期间配置已改变,旧结果已丢弃".into()); + } + let mut cache = service.cache.lock().unwrap(); + let old = cache + .get(&account_id) + .filter(|(a, _)| *a == account) + .map(|(_, r)| r); + let result = finish(&account, fetched, old, now()); + cache.insert(account_id, (account, result.clone())); + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn preview_credentials_are_isolated_from_daily_client() { + assert_ne!( + credential_namespace("ai.ahakey.studio.preview"), + credential_namespace("ai.ahakey.studio.app006.preview") + ); + } + #[tokio::test] + async fn codex_requests_only_initialization_and_readonly_quota() { + let (client, server) = tokio::io::duplex(4096); + let peer = tokio::spawn(async move { + let (read, mut write) = tokio::io::split(server); + let mut lines = BufReader::new(read).lines(); + let first: Value = + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert_eq!(first["method"], "initialize"); + write + .write_all(b"{\"id\":1,\"result\":{}}\n") + .await + .unwrap(); + let second: Value = + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + let third: Value = + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert_eq!(second["method"], "initialized"); + assert_eq!(third["method"], "account/rateLimits/read"); + write + .write_all( + b"{\"id\":2,\"result\":{\"rateLimits\":{\"primary\":{\"usedPercent\":25}}}}\n", + ) + .await + .unwrap(); + assert!(lines.next_line().await.unwrap().is_none()); + }); + let (read, write) = tokio::io::split(client); + let response = codex_exchange(read, write).await.unwrap(); + assert_eq!(response["rateLimits"]["primary"]["usedPercent"], 25); + peer.await.unwrap(); + } + #[tokio::test] + async fn codex_auth_error_does_not_expose_remote_details() { + let (client, server) = tokio::io::duplex(4096); + let peer = tokio::spawn(async move { + let (read, mut write) = tokio::io::split(server); + let mut lines = BufReader::new(read).lines(); + lines.next_line().await.unwrap(); + write + .write_all(b"{\"id\":1,\"error\":{\"message\":\"never-echo-token\"}}\n") + .await + .unwrap(); + assert!(lines.next_line().await.unwrap().is_none()); + }); + let (read, write) = tokio::io::split(client); + assert!(!codex_exchange(read, write) + .await + .unwrap_err() + .contains("never-echo-token")); + peer.await.unwrap(); + } + #[test] + fn card_storage_is_separate_and_rejects_corrupt_overwrite() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("display-cards.json"); + store(&p, &Cards::default()).unwrap(); + assert_eq!(load(&p).unwrap(), Cards::default()); + fs::write(&p, b"broken").unwrap(); + assert!(store(&p, &Cards::default()).is_err()); + assert_eq!(fs::read(&p).unwrap(), b"broken"); + } + #[test] + fn failed_refresh_keeps_age_not_fake_freshness() { + let a = Cards::default().accounts[0].clone(); + let old = finish(&a, Ok(vec![]), None, 10); + let next = finish(&a, Err("offline".into()), Some(&old), 20); + assert_eq!(next.state, "stale"); + assert_eq!(next.updated_at, Some(10)); + assert_eq!(next.checked_at, 20); + } + #[test] + fn endpoint_change_changes_credential_binding() { + let mut a = Cards::default().accounts[0].clone(); + let id = a.credential_id(); + a.international = true; + assert_ne!(id, a.credential_id()); + } +} diff --git a/ahakey-desktop/src-tauri/src/quota/model.rs b/ahakey-desktop/src-tauri/src/quota/model.rs new file mode 100644 index 00000000..81a1dc91 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/quota/model.rs @@ -0,0 +1,415 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Provider { + Minimax, + Glm, + Kimi, + Codex, + Custom, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Mapping { + pub label: String, + pub pointer: String, + pub used_percent: bool, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Account { + pub id: String, + pub label: String, + pub provider: Provider, + pub international: bool, + pub enabled: bool, + pub warning_percent: u8, + #[serde(default)] + pub endpoint: String, + #[serde(default)] + pub mappings: Vec, +} +impl Account { + pub fn validate(&self) -> Result<(), String> { + if self.id.is_empty() + || self.id.len() > 64 + || !self + .id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-') + || self.label.trim().is_empty() + || self.label.chars().count() > 40 + || self.warning_percent > 100 + || self.mappings.len() > 8 + { + return Err("账户标识、名称或警告阈值无效".into()); + } + if self.provider == Provider::Custom { + let url = reqwest::Url::parse(&self.endpoint) + .map_err(|_| "自定义地址必须是完整 HTTPS URL")?; + if self.endpoint.len() > 512 + || url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || self.mappings.is_empty() + { + return Err("自定义来源需要无内嵌凭据/查询参数的 HTTPS 地址和字段映射".into()); + } + for m in &self.mappings { + if m.label.is_empty() + || m.label.chars().count() > 40 + || !m.pointer.starts_with('/') + || m.pointer.len() > 256 + { + return Err( + "字段映射需要名称和 JSON Pointer,例如 /quota/remainingPercent".into(), + ); + } + } + } else if !self.endpoint.is_empty() || !self.mappings.is_empty() { + return Err("内置服务不接受覆盖地址或字段映射".into()); + } + Ok(()) + } + pub fn endpoint(&self) -> &str { + match (self.provider, self.international) { + (Provider::Minimax, false) => { + "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains" + } + (Provider::Minimax, true) => { + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains" + } + (Provider::Glm, false) => "https://open.bigmodel.cn/api/monitor/usage/quota/limit", + (Provider::Glm, true) => "https://api.z.ai/api/monitor/usage/quota/limit", + (Provider::Kimi, _) => "https://api.kimi.com/coding/v1/usages", + (Provider::Custom, _) => &self.endpoint, + (Provider::Codex, _) => "", + } + } + // Binding to the destination prevents an edited custom URL receiving an old credential. + pub fn credential_id(&self) -> String { + format!("{}:{:?}:{}", self.id, self.provider, self.endpoint()) + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Cards { + pub accounts: Vec, + pub auto_refresh: bool, + pub refresh_seconds: u64, +} +impl Default for Cards { + fn default() -> Self { + Self { + accounts: [ + (Provider::Minimax, "minimax", "MiniMax"), + (Provider::Glm, "glm", "智谱 GLM"), + (Provider::Kimi, "kimi", "Kimi Coding"), + (Provider::Codex, "codex", "Codex"), + ] + .into_iter() + .map(|(provider, id, label)| Account { + id: id.into(), + label: label.into(), + provider, + international: false, + enabled: true, + warning_percent: 20, + endpoint: String::new(), + mappings: vec![], + }) + .collect(), + auto_refresh: false, + refresh_seconds: 300, + } + } +} +impl Cards { + pub fn validate(&self) -> Result<(), String> { + if self.accounts.len() > 16 || !(60..=3600).contains(&self.refresh_seconds) { + return Err("最多 16 个账户,刷新间隔为 60–3600 秒".into()); + } + let mut ids = std::collections::HashSet::new(); + for a in &self.accounts { + a.validate()?; + if !ids.insert(&a.id) { + return Err("账户标识重复".into()); + } + } + Ok(()) + } +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Window { + pub id: String, + pub label: String, + pub remaining_percent: Option, + pub resets_at: Option, + pub window_minutes: Option, +} +fn number(v: &Value) -> Option { + v.as_f64() + .or_else(|| v.as_str()?.parse().ok()) + .filter(|v| v.is_finite()) +} +fn percent(v: &Value) -> Option { + number(v).filter(|v| (0.0..=100.0).contains(v)) +} +fn timestamp(v: &Value) -> Option { + if let Some(s) = v.as_str() { + if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) { + return Some(t.timestamp()); + } + } + let n = number(v)?; + if n <= 0.0 || n > 253_402_300_799_000.0 { + return None; + } + Some(if n >= 1_000_000_000_000.0 { + (n / 1000.0) as i64 + } else { + n as i64 + }) +} +fn window( + id: impl Into, + label: impl Into, + remaining: Option, + reset: &Value, + duration: Option, +) -> Window { + Window { + id: id.into(), + label: label.into(), + remaining_percent: remaining, + resets_at: timestamp(reset), + window_minutes: duration, + } +} +fn ratio(v: &Value) -> Option { + let total = number(&v["limit"])?; + let left = number(&v["remaining"])?; + (total > 0.0 && left >= 0.0 && left <= total).then_some(left / total * 100.0) +} +pub fn parse(account: &Account, body: &Value) -> Result, String> { + let mut out = vec![]; + match account.provider { + Provider::Minimax => { + if body.get("base_resp").is_some() + && body["base_resp"]["status_code"].as_i64() != Some(0) + { + return Err("MiniMax 返回服务错误;未更新额度".into()); + } + if let Some(item) = body["model_remains"] + .as_array() + .and_then(|items| items.iter().find(|v| v["model_name"] == "general")) + { + if item.get("current_interval_remaining_percent").is_some() { + out.push(window( + "interval", + "5 小时", + percent(&item["current_interval_remaining_percent"]), + &item["end_time"], + Some(300), + )); + } + if item["current_weekly_status"].as_i64() == Some(1) { + out.push(window( + "weekly", + "每周", + percent(&item["current_weekly_remaining_percent"]), + &item["weekly_end_time"], + Some(10080), + )); + } + } + } + Provider::Glm => { + if body["success"] == false { + return Err("GLM 返回服务错误;未更新额度".into()); + } + if let Some(items) = body["data"]["limits"].as_array() { + for (i, item) in items.iter().enumerate() { + if !item["type"].as_str().is_some_and(|v| { + v.eq_ignore_ascii_case("TOKENS_LIMIT") + || v.eq_ignore_ascii_case("CREDIT_LIMIT") + }) { + continue; + } + let (name, duration) = match item["unit"].as_u64() { + Some(3) => ( + "小时窗口", + item["number"].as_u64().and_then(|n| n.checked_mul(60)), + ), + Some(6) => ("每周", Some(10080)), + _ => ("未标明周期", None), + }; + out.push(window( + format!("limit-{i}"), + name, + percent(&item["percentage"]).map(|p| 100.0 - p), + &item["nextResetTime"], + duration, + )); + } + } + } + Provider::Kimi => { + if let Some(items) = body["limits"].as_array() { + for (i, item) in items.iter().enumerate() { + if let Some(detail) = item.get("detail") { + out.push(window( + format!("limit-{i}"), + format!("周期额度 {}", i + 1), + ratio(detail), + &detail["resetTime"], + None, + )); + } + } + } + if let Some(detail) = body.get("usage") { + out.push(window( + "total", + "套餐总额度", + ratio(detail), + &detail["resetTime"], + None, + )); + } + } + Provider::Codex => { + let single; + let buckets: Vec<(&str, &Value)> = if let Some(map) = body["rateLimitsByLimitId"] + .as_object() + .filter(|m| !m.is_empty()) + { + map.iter().map(|(k, v)| (k.as_str(), v)).collect() + } else { + single = body + .get("rateLimits") + .ok_or("Codex 未返回额度;请确认使用官方账户登录")?; + vec![("codex", single)] + }; + for (id, bucket) in buckets { + for key in ["primary", "secondary"] { + if let Some(item) = bucket.get(key).filter(|v| v.is_object()) { + let minutes = item["windowDurationMins"].as_u64().filter(|n| *n > 0); + let label = minutes + .map(|m| format!("{id} · {m} 分钟")) + .unwrap_or_else(|| format!("{id} · {key}")); + out.push(window( + format!("{id}-{key}"), + label, + percent(&item["usedPercent"]).map(|p| 100.0 - p), + &item["resetsAt"], + minutes, + )); + } + } + } + } + Provider::Custom => { + for (i, m) in account.mappings.iter().enumerate() { + let p = body.pointer(&m.pointer).and_then(percent).map(|p| { + if m.used_percent { + 100.0 - p + } else { + p + } + }); + out.push(window( + format!("custom-{i}"), + &m.label, + p, + &Value::Null, + None, + )); + } + } + } + if out.is_empty() { + Err("响应中没有可识别的额度窗口;不会用零替代".into()) + } else { + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + fn a(provider: Provider) -> Account { + let mut a = Cards::default().accounts[0].clone(); + a.provider = provider; + a + } + #[test] + fn missing_and_zero_are_distinct() { + let w=parse(&a(Provider::Glm),&json!({"data":{"limits":[{"type":"TOKENS_LIMIT","percentage":100,"unit":6},{"type":"TOKENS_LIMIT"}]}})).unwrap(); + assert_eq!(w[0].remaining_percent, Some(0.0)); + assert_eq!(w[1].remaining_percent, None); + assert_eq!(w[1].window_minutes, None); + } + #[test] + fn minimax_inactive_week_is_not_unlimited() { + let w=parse(&a(Provider::Minimax),&json!({"model_remains":[{"model_name":"general","current_interval_remaining_percent":75,"current_weekly_status":3,"current_weekly_remaining_percent":100}]})).unwrap(); + assert_eq!(w.len(), 1); + assert_eq!(w[0].remaining_percent, Some(75.0)); + } + #[test] + fn kimi_missing_total_never_fabricates_percentage() { + let w = parse(&a(Provider::Kimi), &json!({"usage":{"remaining":0}})).unwrap(); + assert_eq!(w[0].remaining_percent, None); + } + #[test] + fn codex_retains_every_bucket_and_actual_window() { + let w=parse(&a(Provider::Codex),&json!({"rateLimitsByLimitId":{"one":{"primary":{"usedPercent":25,"windowDurationMins":15}},"two":{"secondary":{"usedPercent":0,"windowDurationMins":10080}}}})).unwrap(); + assert_eq!(w.len(), 2); + assert_eq!(w[0].remaining_percent, Some(75.0)); + assert_eq!(w[0].window_minutes, Some(15)); + } + #[test] + fn range_errors_are_unknown_not_clamped() { + assert_eq!(percent(&json!(101)), None); + assert_eq!(percent(&json!(-1)), None); + assert_eq!(percent(&json!("0")), Some(0.0)); + } + #[test] + fn reset_seconds_millis_and_iso_agree() { + let t = 1_800_000_000; + assert_eq!(timestamp(&json!(t)), timestamp(&json!(t * 1000_i64))); + assert_eq!(timestamp(&json!("2027-01-15T08:00:00Z")), Some(t)); + } + #[test] + fn custom_maps_fields_without_executing_code() { + let mut account = a(Provider::Custom); + account.endpoint = "https://example.com/quota".into(); + account.mappings = vec![Mapping { + label: "余额".into(), + pointer: "/quota/pct".into(), + used_percent: true, + }]; + account.validate().unwrap(); + let w = parse(&account, &json!({"quota":{"pct":20}})).unwrap(); + assert_eq!(w[0].remaining_percent, Some(80.0)); + account.endpoint = "https://user:secret@example.com/quota".into(); + assert!(account.validate().is_err()); + } + #[test] + fn identity_and_schema_reject_path_traversal_and_plaintext_keys() { + let mut cards = Cards::default(); + cards.accounts[0].id = "../key".into(); + assert!(cards.validate().is_err()); + let mut v = serde_json::to_value(Cards::default()).unwrap(); + v["accounts"][0]["apiKey"] = json!("never-save-me"); + assert!(serde_json::from_value::(v).is_err()); + } +} diff --git a/ahakey-desktop/src/DisplayPanel.tsx b/ahakey-desktop/src/DisplayPanel.tsx new file mode 100644 index 00000000..88dd78b0 --- /dev/null +++ b/ahakey-desktop/src/DisplayPanel.tsx @@ -0,0 +1,764 @@ +import React, { useEffect, useRef, useState } from "react"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { + ArrowDown, + ArrowUp, + Image, + Plus, + RefreshCw, + ShieldCheck, + Trash2, +} from "lucide-react"; +import { + Account, + Cards, + Provider, + QuotaResult, + defaultCards, + encodeRgb565, + fitRect, + imageDimensions, + newAccount, + percentage, + providerNames, +} from "./display"; +const native = isTauri(); +export function DisplayPanel() { + const [config, setConfig] = useState(defaultCards); + const [loaded, setLoaded] = useState(!native); + const [saved, setSaved] = useState(""); + const [results, setResults] = useState>({}); + const [tokens, setTokens] = useState>({}); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(""); + const [error, setError] = useState(""); + const [selected, setSelected] = useState("minimax"); + const [windowIndex, setWindowIndex] = useState(0); + const [mode, setMode] = useState<"contain" | "cover">("contain"); + const [imageSource, setImageSource] = useState(""); + const [imageReady, setImageReady] = useState(false); + const [imageVersion, setImageVersion] = useState(0); + const [imageName, setImageName] = useState(""); + const imageCanvas = useRef(null); + const cardCanvas = useRef(null); + const imageRequest = useRef(0); + const importRequest = useRef(0); + const alive = useRef(true); + const pending = useRef(false); + const configRef = useRef(config); + configRef.current = config; + const dirty = JSON.stringify(config) !== saved; + const account = + config.accounts.find((a) => a.id === selected) ?? config.accounts[0]; + const result = account ? results[account.id] : undefined; + const visibleWindowIndex = Math.min( + windowIndex, + Math.max(0, (result?.windows.length ?? 1) - 1), + ); + const quota = result?.windows[visibleWindowIndex]; + useEffect(() => { + alive.current = true; + if (native) + invoke<{ config: Cards; results: QuotaResult[] }>("get_cards") + .then((s) => { + if (alive.current) { + setConfig(s.config); + setLoaded(true); + setSaved(JSON.stringify(s.config)); + setResults( + Object.fromEntries(s.results.map((r) => [r.accountId, r])), + ); + } + }) + .catch((e) => { + if (alive.current) setError(String(e)); + }); + return () => { + alive.current = false; + imageRequest.current++; + importRequest.current++; + }; + }, []); + function patchAccount(id: string, patch: Partial) { + setConfig((c) => ({ + ...c, + accounts: c.accounts.map((a) => (a.id === id ? { ...a, ...patch } : a)), + })); + setResults((r) => { + const next = { ...r }; + delete next[id]; + return next; + }); + } + async function run(action: () => Promise) { + if (pending.current) return; + pending.current = true; + setBusy(true); + setError(""); + setNotice(""); + try { + await action(); + } catch (e) { + if (alive.current) setError(String(e)); + } finally { + pending.current = false; + if (alive.current) setBusy(false); + } + } + async function refreshAll() { + await run(async () => { + for (const a of configRef.current.accounts.filter((a) => a.enabled)) { + if (!alive.current) break; + const identity = JSON.stringify(a); + const next = await invoke("refresh_quota", { + accountId: a.id, + }); + if ( + alive.current && + JSON.stringify( + configRef.current.accounts.find((v) => v.id === a.id), + ) === identity + ) + setResults((r) => ({ ...r, [a.id]: next })); + } + }); + } + useEffect(() => { + if (!native || dirty || !config.autoRefresh) return; + const timer = window.setInterval(() => { + void refreshAll(); + }, config.refreshSeconds * 1000); + return () => window.clearInterval(timer); + }, [config.autoRefresh, config.refreshSeconds, dirty]); + useEffect(() => { + const ctx = cardCanvas.current?.getContext("2d"); + if (!ctx) return; + ctx.fillStyle = "#102922"; + ctx.fillRect(0, 0, 160, 80); + ctx.fillStyle = "#ffffff"; + ctx.font = "11px sans-serif"; + ctx.fillText((account?.label ?? "选择账户").slice(0, 20), 8, 16); + ctx.font = "bold 25px sans-serif"; + ctx.fillText(percentage(quota?.remainingPercent), 8, 45); + ctx.font = "10px sans-serif"; + ctx.fillText((quota?.label ?? "尚未查询真实额度").slice(0, 24), 8, 61); + ctx.fillStyle = "#c5d8cc"; + ctx.font = "9px sans-serif"; + ctx.fillText( + result?.state === "stale" + ? "旧数据 · 查询失败" + : result?.updatedAt + ? new Date(result.updatedAt * 1000).toLocaleTimeString() + : "电脑预览 · 尚未发送", + 8, + 75, + ); + }, [account, quota, result]); + useEffect(() => { + const current = ++imageRequest.current; + setImageReady(false); + const ctx = imageCanvas.current?.getContext("2d"); + if (!ctx) return; + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, 160, 80); + if (!imageSource) return; + const img = new window.Image(); + img.onload = () => { + if (current !== imageRequest.current) return; + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, 160, 80); + ctx.drawImage(img, ...fitRect(img.naturalWidth, img.naturalHeight, mode)); + setImageReady(true); + }; + img.onerror = () => { + if (current === imageRequest.current) setError("图片解码失败"); + }; + img.src = imageSource; + }, [imageSource, mode, imageVersion]); + async function importImage(file: File | undefined) { + if (!file) return; + const request = ++importRequest.current; + setImageReady(false); + setError(""); + try { + if (file.size > 4 * 1024 * 1024) throw new Error("图片上限为 4 MB"); + const bytes = new Uint8Array(await file.arrayBuffer()); + imageDimensions(bytes, file.type); + const source = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(new Error("图片读取失败")); + reader.readAsDataURL(file); + }); + if (request === importRequest.current && alive.current) { + setImageName(file.name); + setImageSource(source); + setImageVersion((v) => v + 1); + } + } catch (e) { + if (request === importRequest.current && alive.current) + setError(String(e)); + } + } + function exportImage() { + const canvas = imageCanvas.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx || !imageReady) return; + const bytes = encodeRgb565(ctx.getImageData(0, 0, 160, 80).data); + const url = URL.createObjectURL( + new Blob([bytes as BlobPart], { type: "application/octet-stream" }), + ); + const a = document.createElement("a"); + a.href = url; + a.download = "ahakey-160x80.rgb565"; + a.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + } + function move(index: number, delta: number) { + setConfig((c) => { + const accounts = [...c.accounts]; + [accounts[index], accounts[index + delta]] = [ + accounts[index + delta], + accounts[index], + ]; + return { ...c, accounts }; + }); + } + return ( +
+
+
+

+ + 屏幕素材 +

+ 本机预览 +
+

+ 160 × 80 · + RGB565。先预览和导出素材;设备图片存储区域尚未核实,上传保持关闭。 +

+
+ +
+ + +
+ + +
+
+
+
+
+
+

额度插件

+ +
+

+ 以真实账户和套餐为准,不按模型名推算。密钥只保存在本机系统凭据库,不发送到键盘。 +

+ {!native && ( +

+ 浏览器仅可编辑和预览;保存、凭据管理与真实查询需要原生客户端。 +

+ )} + {error && ( +

+ {error} +

+ )} + {notice && ( +

+ {notice} +

+ )} + {!loaded && !error && ( +

+ 正在读取卡片配置… +

+ )} +
+
+ + + +
+
+ {config.accounts.map((a, index) => { + const r = results[a.id]; + return ( +
+
+ +
+ + +
+
+
+ {r?.windows.length ? ( + r.windows.map((w) => ( +
+ {w.label} + + {percentage(w.remainingPercent)} 剩余 + + {w.remainingPercent != null && ( + + )} + + {w.resetsAt + ? `重置:${new Date(w.resetsAt * 1000).toLocaleString()}` + : "重置时间未提供"} + +
+ )) + ) : ( +

尚无额度数据

+ )} +
+

+ {r?.error ?? + (r?.state === "unknown" + ? "响应字段缺失,额度未知" + : r?.updatedAt + ? `更新时间:${new Date(r.updatedAt * 1000).toLocaleString()}` + : "未查询")} + {r?.state === "stale" ? " · 上面是旧数据" : ""} +

+
+ 账户设置与密钥 +
+ + + {(a.provider === "minimax" || a.provider === "glm") && ( + + )} + + {a.provider === "custom" && ( + <> + + {a.mappings.map((m, i) => ( +
+ + + + +
+ ))} + + + )} + {a.provider === "codex" ? ( +

+ 使用 PATH 中的 Codex CLI 官方登录。仅请求 + account/rateLimits/read,不创建会话或执行模型任务。 +

+ ) : ( + <> + +
+ + +
+ + )} + +
+
+
+ ); + })} +
+ +
+
+
+
+

键盘信息卡片预览

+ 未发送到设备 +
+
+ +
+ + +

+ 实时卡片和双主机控制需要经过验证的新固件。动态刷新不复用持久图片写入;当前不会发送 + BLE 命令。 +

+
+
+
+
+ ); +} diff --git a/ahakey-desktop/src/display.test.ts b/ahakey-desktop/src/display.test.ts new file mode 100644 index 00000000..5e9cb7a8 --- /dev/null +++ b/ahakey-desktop/src/display.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + defaultCards, + encodeRgb565, + fitRect, + imageDimensions, + percentage, +} from "./display"; +describe("display assets", () => { + it("encodes known colors big endian", () => { + const p = new Uint8ClampedArray(160 * 80 * 4); + p.set([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]); + expect([...encodeRgb565(p).slice(0, 6)]).toEqual([248, 0, 7, 224, 0, 31]); + expect(encodeRgb565(p)).toHaveLength(25600); + }); + it("rejects malformed frame lengths", () => + expect(() => encodeRgb565(new Uint8ClampedArray(4))).toThrow()); + it("fits without stretching", () => { + expect(fitRect(100, 100, "contain")).toEqual([40, 0, 80, 80]); + expect(fitRect(100, 100, "cover")).toEqual([0, -40, 160, 160]); + }); + it("rejects corrupt and oversized headers before decoding", () => { + expect(() => imageDimensions(new Uint8Array(30), "image/png")).toThrow(); + const b = new Uint8Array(24); + b.set([137, 80, 78, 71, 13, 10, 26, 10]); + b.set([73, 72, 68, 82], 12); + const v = new DataView(b.buffer); + v.setUint32(16, 100000); + v.setUint32(20, 80); + expect(() => imageDimensions(b, "image/png")).toThrow(); + v.setUint32(16, 160); + expect(imageDimensions(b, "image/png")).toEqual([160, 80]); + }); +}); +describe("quota cards", () => { + it("never renders missing or invalid as zero", () => { + expect(percentage(null)).toBe("未知"); + expect(percentage(NaN)).toBe("未知"); + expect(percentage(101)).toBe("未知"); + expect(percentage(0)).toBe("0%"); + }); + it("has four independent providers with no automatic account queries", () => { + const c = defaultCards(); + expect(c.accounts.map((a) => a.provider)).toEqual([ + "minimax", + "glm", + "kimi", + "codex", + ]); + expect(c.autoRefresh).toBe(false); + expect(JSON.stringify(c)).not.toContain("apiKey"); + }); +}); diff --git a/ahakey-desktop/src/display.ts b/ahakey-desktop/src/display.ts new file mode 100644 index 00000000..8d7dadbb --- /dev/null +++ b/ahakey-desktop/src/display.ts @@ -0,0 +1,164 @@ +export const SCREEN_WIDTH = 160; +export const SCREEN_HEIGHT = 80; +export type Provider = "minimax" | "glm" | "kimi" | "codex" | "custom"; +export type Mapping = { label: string; pointer: string; usedPercent: boolean }; +export type Account = { + id: string; + label: string; + provider: Provider; + international: boolean; + enabled: boolean; + warningPercent: number; + endpoint: string; + mappings: Mapping[]; +}; +export type Cards = { + accounts: Account[]; + autoRefresh: boolean; + refreshSeconds: number; +}; +export type QuotaWindow = { + id: string; + label: string; + remainingPercent: number | null; + resetsAt: number | null; + windowMinutes: number | null; +}; +export type QuotaResult = { + accountId: string; + state: "ready" | "unknown" | "stale" | "error"; + windows: QuotaWindow[]; + checkedAt: number; + updatedAt: number | null; + error: string | null; +}; +export const providerNames: Record = { + minimax: "MiniMax", + glm: "智谱 GLM", + kimi: "Kimi Coding", + codex: "Codex", + custom: "自定义 HTTP / JSON", +}; +export function newAccount(provider: Provider, id: string): Account { + return { + id, + label: providerNames[provider], + provider, + international: false, + enabled: true, + warningPercent: 20, + endpoint: "", + mappings: + provider === "custom" + ? [ + { + label: "剩余额度", + pointer: "/remainingPercent", + usedPercent: false, + }, + ] + : [], + }; +} +export function defaultCards(): Cards { + return { + accounts: (["minimax", "glm", "kimi", "codex"] as Provider[]).map((p) => + newAccount(p, p), + ), + autoRefresh: false, + refreshSeconds: 300, + }; +} +export function percentage(value: number | null | undefined): string { + return value != null && Number.isFinite(value) && value >= 0 && value <= 100 + ? `${Math.round(value)}%` + : "未知"; +} +export function encodeRgb565( + rgba: Uint8ClampedArray, + width = SCREEN_WIDTH, + height = SCREEN_HEIGHT, +): Uint8Array { + if ( + width !== SCREEN_WIDTH || + height !== SCREEN_HEIGHT || + rgba.length !== width * height * 4 + ) + throw new Error("图片必须是 160 × 80 RGBA"); + const bytes = new Uint8Array(width * height * 2); + for (let pixel = 0; pixel < width * height; pixel++) { + const i = pixel * 4; + const packed = + ((rgba[i] >> 3) << 11) | ((rgba[i + 1] >> 2) << 5) | (rgba[i + 2] >> 3); + bytes[pixel * 2] = packed >> 8; + bytes[pixel * 2 + 1] = packed & 255; + } + return bytes; +} +export function imageDimensions( + bytes: Uint8Array, + mime: string, +): [number, number] { + if (bytes.length > 4 * 1024 * 1024) throw new Error("图片上限为 4 MB"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let width = 0, + height = 0; + if ( + mime === "image/png" && + bytes.length >= 24 && + [137, 80, 78, 71, 13, 10, 26, 10].every((v, i) => bytes[i] === v) && + String.fromCharCode(...bytes.slice(12, 16)) === "IHDR" + ) { + width = view.getUint32(16); + height = view.getUint32(20); + } else if (mime === "image/jpeg" && bytes[0] === 255 && bytes[1] === 216) { + let pos = 2; + while (pos + 4 <= bytes.length) { + if (bytes[pos++] !== 255) break; + while (bytes[pos] === 255) pos++; + const marker = bytes[pos++]; + if (marker === 217 || marker === 218 || pos + 2 > bytes.length) break; + if (marker === 1 || (marker >= 208 && marker <= 215)) continue; + const length = view.getUint16(pos); + if (length < 2 || pos + length > bytes.length) break; + if ([192, 193, 194].includes(marker) && length >= 8) { + height = view.getUint16(pos + 3); + width = view.getUint16(pos + 5); + break; + } + pos += length; + } + } + if ( + width < 1 || + height < 1 || + width > 4096 || + height > 4096 || + width * height > 4_000_000 + ) + throw new Error("仅支持有效 PNG / JPEG,最多 400 万像素、单边 4096 像素"); + return [width, height]; +} +export function fitRect( + width: number, + height: number, + mode: "contain" | "cover", +): [number, number, number, number] { + if ( + width <= 0 || + height <= 0 || + !Number.isFinite(width) || + !Number.isFinite(height) + ) + throw new Error("无效图片尺寸"); + const scale = + mode === "contain" + ? Math.min(SCREEN_WIDTH / width, SCREEN_HEIGHT / height) + : Math.max(SCREEN_WIDTH / width, SCREEN_HEIGHT / height); + return [ + (SCREEN_WIDTH - width * scale) / 2, + (SCREEN_HEIGHT - height * scale) / 2, + width * scale, + height * scale, + ]; +} From f35372ad974ae51e58b9dd6bb587ac4fe834f257 Mon Sep 17 00:00:00 2001 From: Lihao Date: Mon, 14 Sep 2026 02:58:53 -0700 Subject: [PATCH 09/21] feat(ui): assemble React settings and live device panels Connect the feature panels through shared contracts and the React entry point. --- ahakey-desktop/.gitignore | 14 + ahakey-desktop/index.html | 13 + ahakey-desktop/package.json | 31 + ahakey-desktop/pnpm-lock.yaml | 1519 ++++++++++++++++++++++++++ ahakey-desktop/public/favicon.svg | 1 + ahakey-desktop/src/LivePanels.tsx | 145 +++ ahakey-desktop/src/contracts.test.ts | 67 ++ ahakey-desktop/src/contracts.ts | 154 +++ ahakey-desktop/src/main.tsx | 567 ++++++++++ ahakey-desktop/src/styles.css | 1014 +++++++++++++++++ ahakey-desktop/tsconfig.json | 15 + ahakey-desktop/vite.config.ts | 14 + 12 files changed, 3554 insertions(+) create mode 100644 ahakey-desktop/.gitignore create mode 100644 ahakey-desktop/index.html create mode 100644 ahakey-desktop/package.json create mode 100644 ahakey-desktop/pnpm-lock.yaml create mode 100644 ahakey-desktop/public/favicon.svg create mode 100644 ahakey-desktop/src/LivePanels.tsx create mode 100644 ahakey-desktop/src/contracts.test.ts create mode 100644 ahakey-desktop/src/contracts.ts create mode 100644 ahakey-desktop/src/main.tsx create mode 100644 ahakey-desktop/src/styles.css create mode 100644 ahakey-desktop/tsconfig.json create mode 100644 ahakey-desktop/vite.config.ts diff --git a/ahakey-desktop/.gitignore b/ahakey-desktop/.gitignore new file mode 100644 index 00000000..4132a297 --- /dev/null +++ b/ahakey-desktop/.gitignore @@ -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 diff --git a/ahakey-desktop/index.html b/ahakey-desktop/index.html new file mode 100644 index 00000000..0a10fb85 --- /dev/null +++ b/ahakey-desktop/index.html @@ -0,0 +1,13 @@ + + + + + + AhaKey Studio · 统一客户端 + + + +
+ + + diff --git a/ahakey-desktop/package.json b/ahakey-desktop/package.json new file mode 100644 index 00000000..d3b8b6cd --- /dev/null +++ b/ahakey-desktop/package.json @@ -0,0 +1,31 @@ +{ + "name": "ahakey-desktop", + "version": "1.1.5", + "private": true, + "type": "module", + "packageManager": "pnpm@10.12.3", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 1420 --strictPort", + "build": "tsc --noEmit && vite build", + "tauri": "tauri", + "desktop:dev": "tauri dev", + "desktop:build": "tauri build --no-bundle --features custom-protocol", + "desktop:bundle": "tauri build --bundles app --features custom-protocol", + "test": "vitest run" + }, + "dependencies": { + "@tauri-apps/api": "^2.10.1", + "lucide-react": "^0.468.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.11.4", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vitest": "^4.0.18" + } +} diff --git a/ahakey-desktop/pnpm-lock.yaml b/ahakey-desktop/pnpm-lock.yaml new file mode 100644 index 00000000..efda4dde --- /dev/null +++ b/ahakey-desktop/pnpm-lock.yaml @@ -0,0 +1,1519 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tauri-apps/api': + specifier: ^2.10.1 + version: 2.11.1 + lucide-react: + specifier: ^0.468.0 + version: 0.468.0(react@19.2.8) + react: + specifier: ^19.2.0 + version: 19.2.8 + react-dom: + specifier: ^19.2.0 + version: 19.2.8(react@19.2.8) + devDependencies: + '@tauri-apps/cli': + specifier: ^2.11.4 + version: 2.11.4 + '@types/react': + specifier: ^19.2.0 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.7(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^5.1.0 + version: 5.2.0(vite@7.3.6) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.6 + vitest: + specifier: ^4.0.18 + version: 4.1.11(vite@7.3.6) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} + engines: {node: '>= 10'} + hasBin: true + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + electron-to-chromium@1.5.422: + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.468.0: + resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.9 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli@2.11.4': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@5.2.0(vite@7.3.6)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.6 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@7.3.6)': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6 + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + assertion-error@2.0.1: {} + + baseline-browser-mapping@2.11.21: {} + + browserslist@4.28.9: + dependencies: + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.422 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) + + caniuse-lite@1.0.30001810: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + electron-to-chromium@1.5.422: {} + + es-module-lexer@2.3.2: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.468.0(react@19.2.8): + dependencies: + react: 19.2.8 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-releases@2.0.54: {} + + obug@2.1.4: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-refresh@0.18.0: {} + + react@19.2.8: {} + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + + typescript@5.9.3: {} + + update-browserslist-db@1.3.2(browserslist@4.28.9): + dependencies: + browserslist: 4.28.9 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@7.3.6: + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@4.1.11(vite@7.3.6): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@7.3.6) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 7.3.6 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yallist@3.1.1: {} diff --git a/ahakey-desktop/public/favicon.svg b/ahakey-desktop/public/favicon.svg new file mode 100644 index 00000000..a68b4be2 --- /dev/null +++ b/ahakey-desktop/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/ahakey-desktop/src/LivePanels.tsx b/ahakey-desktop/src/LivePanels.tsx new file mode 100644 index 00000000..02d4b5c0 --- /dev/null +++ b/ahakey-desktop/src/LivePanels.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Settings, Snapshot } from "./contracts"; + +type Props = { snapshot: Snapshot | null; draft: Settings; patch: (value: Partial) => void; changed: boolean; fail: (message: string) => void }; +function useAction(fail: Props["fail"]) { + const [pending, setPending] = useState(""); + const actionVersion = useRef(0); + const run = async (command: string, args?: Record) => { + const version = ++actionVersion.current; + setPending(command); + try { await invoke(command, args); } catch (error) { if (version === actionVersion.current) fail(String(error)); } finally { if (version === actionVersion.current) setPending(""); } + }; + return { pending, run }; +} + +export function VoiceControls({ snapshot: s, changed, fail }: Props) { + const { pending, run } = useAction(fail); + const external = s?.settings.provider === "wechat" || s?.settings.provider === "windows-native"; + const title = s?.settings.provider === "wechat" ? "微信语音" : "Windows 听写"; + if (external) return
+

{title}

{s?.speech.recording?"语音进行中":s?.nativeKeyTestEnabled?"语音键就绪":"监听未开启"}
+

{changed?"正在应用选择…":s?.nativeKeyTestEnabled?`在目标输入框${s.settings.triggerMode==="hold"?"按住语音键,松开结束":"按一下语音键开始,再按一下结束"}。无需打开本地录音预览。`:"先开启上方语音键监听,再回到输入框使用。"}

+ {s?.speech.recording&&
} +
; + const ready = s && (s.settings.provider === "local" ? s.modelInstalled : s.cloudConfigured); + return
+

录音与预览

{s?.speech.recording?"正在使用麦克风":"按需开始"}
+

{s?.speech.message??"请打开原生客户端"}

+
+ + + + +
+ {!ready&&

请先在设置中{s?.settings.provider==="doubao"?"配置豆包 API 凭据":"下载或导入模型"}。

} + {s?.settings.provider==="doubao"&&

录音会发送到火山引擎,可能产生账号费用。

} +
输入与隐私说明

界面按钮仅预览。自动输入请先聚焦目标输入框,再按设备语音键。仅输入最终文字,焦点变化时保留预览。本地字幕约每 800 ms 重算最近 12 秒,最终结果可能修正;最长录音 120 秒。本地音频不上传,不自动回退云端。

+
; +} + +export function DeviceInformation({ snapshot: s }: { snapshot: Snapshot | null }) { + const device=s?.device; + const status=device?.status; + const source=device?.transport === "usb" ? "USB" : device?.transport === "ble" ? "蓝牙" : null; + return
+

设备信息

{source ? `由 ${source} 读取` : "等待设备数据"}
+

USB 数据线即插即读,不需要蓝牙配对。两种连接同时可用时,本页优先显示 USB 读回的信息。

+
+ {s?.usb?.status ? "USB 已连接 · 自动检测" : s?.usb?.supported ? "USB 未检测到有效设备" : "USB 信息读取暂仅支持 Windows"} + {s?.bleReady ? "蓝牙已连接" : "蓝牙未连接(不影响 USB)"} +
+
+
设备
{device?.name ?? "未检测到键盘"}
+
信息来源
{source ?? "—"}
+
电量
{status && status.batteryLevel <= 100 ? `${status.batteryLevel}%` : "未知 · 等待有效数据"}
+
固件返回版本
{status ? `${status.firmwareMain}.${status.firmwareSub}(兼容字段)` : "—"}
+
设备当前模式
{status ? s?.settings.profiles[status.workMode]?.name ?? `模式 ${status.workMode}` : "—"}
+
灯条亮度
{status && status.lightBrightness >= 1 && status.lightBrightness <= 100 ? `${status.lightBrightness}%` : "—"}
+
+

USB 信息约每 2 秒自动检查;读取失败会清除旧状态。连接可用不等于当前输入目标,拨杆的目标设置见下方。

+ {s?.usb?.error &&
USB 读取详情

{s.usb.error}

} +
; +} + +export function DevicePanel({ snapshot: s, changed, fail }: Props) { + const { pending, run } = useAction(fail); + const phase = s?.ble?.phase; + return
+

蓝牙连接

{phase === "ready" ? "蓝牙已连接" : phase === "connecting" ? "连接并订阅中…" : "蓝牙未连接"}
+

仅管理蓝牙扫描、配对后的连接与重连。USB 不需要在这里连接;蓝牙未连接不会影响有线输入和上方设备信息。

+

{phase === "ready" ? "自动重连已开启 · 设备休眠掉线后会在后台重试" : s?.bleRecovery.connecting ? `正在尝试连接(第 ${s.bleRecovery.attempt} 次)… 可随时停止` : s?.bleRecovery.enabled ? "等待设备恢复 · 唤醒键盘后会自动重连,无需手动点击" : s?.settings.savedDevice ? "已暂停自动重连 · 点击连接上次设备可恢复" : "首次连接设备后,将自动记住并在掉线后重连"}

+ {(s?.bleError || s?.ble?.error) &&
最近一次连接详情

{s.bleError || s.ble?.error}

} + {phase === "ready" && s?.settingsNotice &&

{s.settingsNotice}

} +

蓝牙设备:{s?.ble?.device?.name ?? "尚未选择"}。首次请先在系统蓝牙设置完成键盘配对;Rust 直接连接,不使用 BLE TCP bridge。

+
+ + + +
+
{s?.devices.filter(device => device.isCandidate).map(device =>
+
{device.name || "未命名设备"}

{device.isCandidate ? "AhaKey 候选设备" : "其他蓝牙设备"} · {device.rssi ?? "—"} dBm

+ +
)}
+
通过蓝牙写入四个模式与灯效

此写入入口目前仍使用蓝牙;USB 设备信息与拨杆路由不受影响。

+
+
; +} + +const effects = ["关闭", "单点流动", "彩虹流动", "彩虹波浪", "慢速彩虹", "呼吸灯", "中间常亮", "输入涟漪", "彗星拖尾", "扫描灯条", "中心脉冲", "警示闪烁", "完成扫光", "蓝色思考", "低电提醒", "充电流动", "等待批准"]; +const states = ["通知提醒", "等待批准", "工具完成", "工具执行前", "会话开始", "AI 停止", "任务完成", "提交输入", "会话结束"]; +export function HookPanel({ snapshot: s, draft, patch, changed, fail }: Props) { + const { pending, run } = useAction(fail); + const profile = draft.profiles.find(p => p.id === draft.activeProfile)!; + const setEffect = (index: number, value: number) => patch({ profiles: draft.profiles.map(p => p.id === profile.id ? { ...p, lightEffects: p.lightEffects.map((v, i) => i === index ? value : v) } : p) }); + return
+

Hook 与灯效 · {profile.name}

{s?.hookPort ? `本机端口 ${s.hookPort}` : "事件接收已关闭"}
+

接收已有 AhaKey Hook 分发脚本的事件。不会自动修改 Codex / Claude 的配置,也不会自动批准命令;桌面应用须自行提供事件来源。

+
最近事件:{s?.hookLastEvent ?? "尚未收到"}
+
{states.map((name, index) =>
+ + +
)}
+ + +

设置自动保存;模式和亮度在连接后立即应用。完整键位 / 灯效映射需点击写入设备。部分灯效取决于固件支持。

+
; +} + +export function EnginePanel({ snapshot: s, draft, patch, fail }: Props) { + const { pending, run } = useAction(fail); + const [microphones, setMicrophones] = useState([]); + const [source, setSource] = useState(""); + const [token, setToken] = useState(""); + useEffect(() => { if (s) invoke("microphone_devices").then(setMicrophones).catch(e => fail(String(e))); }, [!!s]); + return <> +

麦克风与本地模型

+ +
SenseVoice Small INT8

原生引擎已内置;权重约 230 MB,默认不下载。本地识别不上传音频。

{s?.modelInstalled ? "模型已安装" : "尚未安装权重"}
+

{s?.modelDirectory}

+ +
+ + + +
+ {s?.download.busy && } +

{s?.download.message}

+
+

豆包云端识别

+

需要火山引擎语音识别服务的独立 API 凭据。保存密钥不会开始上传;选择豆包并主动开始录音才发送音频,可能产生账号费用。

+
+
+ +
{s?.cloudConfigured ? "凭据已保存于系统保护存储" : "未配置凭据"}
+

App ID 与资源 ID 自动保存;Token 需单独点击安全保存,不写入普通设置文件或诊断信息。

+
+

窗口与输入

+ +

{s?.autoInsertSupported ? "当前支持 Windows。不会自动切换到 Codex 或其他应用。" : "此平台暂仅预览与复制;原生自动输入待验证。"}

+ +

使用托盘“退出”完全停止语音、蓝牙与 Hook。语音键会记住明确开启的选择;Hook 接收仍需手动开启。

+
+ ; +} diff --git a/ahakey-desktop/src/contracts.test.ts b/ahakey-desktop/src/contracts.test.ts new file mode 100644 index 00000000..ac782d71 --- /dev/null +++ b/ahakey-desktop/src/contracts.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { acceptCaption, previewSettings, sameSettings, mergeSavedDraft, effectiveKeys, mergeIncomingDraft } from "./contracts"; + +describe("native UI boundary", () => { + it("adopts tray changes without erasing unrelated pending edits", () => { + const base=structuredClone(previewSettings); + const tray={...base,provider:"local" as const,activeProfile:"chatgpt-app"}; + expect(mergeIncomingDraft(base,base,tray)).toEqual(tray); + const draft={...base,captionBottomOffset:44}; + expect(mergeIncomingDraft(draft,base,tray)).toEqual({...tray,captionBottomOffset:44}); + }); + it("keeps a newer tray selection when an older UI save returns", () => { + const submitted={...previewSettings,captionBottomOffset:32}; + const newer={...submitted,provider:"doubao" as const}; + expect(mergeSavedDraft(newer,submitted,submitted).provider).toBe("doubao"); + expect(mergeSavedDraft({...newer,captionBottomOffset:20},submitted,submitted).captionBottomOffset).toBe(20); + }); + it("exposes all four keys and preserves legacy user mappings", () => { + const profile={...previewSettings.profiles[3],accept:"Tab"}; + const keys=effectiveKeys(profile); + expect(keys.map(k=>k.action)).toEqual(["voice","shortcut","shortcut","shortcut"]); + expect(keys[1].shortcut).toBe("Tab"); + keys[3].shortcut="Ctrl+Shift+V"; + expect(effectiveKeys({...profile,keys})[3].shortcut).toBe("Ctrl+Shift+V"); + expect(effectiveKeys(profile)[3].shortcut).toBe("Backspace"); + }); + it("defaults to WeChat and hold, not Windows toggle", () => { + expect(previewSettings.provider).toBe("wechat"); + expect(previewSettings.triggerMode).toBe("hold"); + }); + it("does not roll back edits or reversions while an earlier save completes", () => { + const original = structuredClone(previewSettings); + const submitted = { ...original, captionBottomOffset: 32 }; + const saved = { ...submitted, savedDevice: "device" }; + expect(mergeSavedDraft(original, submitted, saved).captionBottomOffset).toBe(20); + expect(mergeSavedDraft({ ...submitted, captionBottomOffset: 40 }, submitted, saved).captionBottomOffset).toBe(40); + expect(mergeSavedDraft(submitted, submitted, saved)).toEqual(saved); + }); + it("does not replace a new utterance with a delayed previous event", () => { + const current = { + phase: "listening" as const, + text: "new session", + sequence: 9, + }; + expect( + acceptCaption(current, { + phase: "final", + text: "old result", + sequence: 8, + }), + ).toBe(current); + expect( + acceptCaption(current, { + phase: "final", + text: "new result", + sequence: 10, + }).text, + ).toBe("new result"); + }); + it("detects profile edits without changing the stable defaults", () => { + const copy = structuredClone(previewSettings); + expect(sameSettings(copy, previewSettings)).toBe(true); + copy.profiles[3].accept = "Tab"; + expect(sameSettings(copy, previewSettings)).toBe(false); + expect(previewSettings.profiles[3].accept).toBe("Enter"); + }); +}); diff --git a/ahakey-desktop/src/contracts.ts b/ahakey-desktop/src/contracts.ts new file mode 100644 index 00000000..d9c84d80 --- /dev/null +++ b/ahakey-desktop/src/contracts.ts @@ -0,0 +1,154 @@ +export type Provider = "local" | "doubao" | "wechat" | "windows-native"; +export type TriggerMode = "hold" | "toggle"; +export type Phase = "idle" | "preview" | "listening" | "transcribing" | "final" | "error"; +export interface Profile { + id: string; + name: string; + accept: string; + reject: string; + lightEffects: number[]; + keys?: KeyBinding[]; +} +export interface KeyBinding { action: "voice" | "shortcut" | "disabled"; shortcut: string; label: string } +export function effectiveKeys(profile: Profile): KeyBinding[] { + return profile.keys?.length === 4 ? structuredClone(profile.keys) : [ + { action: "voice", shortcut: "", label: "Voice" }, + { action: "shortcut", shortcut: profile.accept, label: "Accept" }, + { action: "shortcut", shortcut: profile.reject, label: "Cancel" }, + { action: "shortcut", shortcut: "Backspace", label: "Backspace" }, + ]; +} +export interface Settings { + schemaVersion: number; + provider: Provider; + triggerMode: TriggerMode; + activeProfile: string; + captionsEnabled: boolean; + captionBottomOffset: number; + profiles: Profile[]; + microphone: string | null; + savedDevice: string | null; + cloudAppId: string; + cloudResourceId: string; + autoInsert: boolean; + minimizeToTray: boolean; + lightBrightness: number; + voiceKeysEnabled: boolean; +} +export interface Caption { + phase: Phase; + text: string; + sequence: number; +} +export interface Snapshot { + version: string; + platform: string; + settings: Settings; + settingsChangeId: string | null; + settingsPath: string; + settingsError: string | null; + settingsNotice: string; + nativeKeyTestSupported: boolean; + nativeKeyTestEnabled: boolean; + keyObservation: { events: number; key: string; pressed: boolean }; + keyWriteNotice: string; + foregroundCaptionSupported: boolean; + speechEngineReady: boolean; + bleReady: boolean; + device: { transport: "usb" | "ble" | null; name: string | null; status: HardwareStatus | null }; + usb: { supported: boolean; present?: boolean | null; status: HardwareStatus | null; error: string | null }; + caption: Caption; + modelInstalled: boolean; + modelDirectory: string; + cloudConfigured: boolean; + autoInsertSupported: boolean; + speech: { phase: Phase; message: string; recording: boolean }; + download: { busy: boolean; progress: number; message: string }; + bleError: string | null; + bleRecovery: { enabled: boolean; connecting: boolean; attempt: number; retryAfterSeconds: number }; + devices: DeviceInfo[]; + ble: { generation?: number; phase: "disconnected" | "connecting" | "ready" | "error"; device: DeviceInfo | null; error: string | null; + status: { batteryLevel: number; signal: number; firmwareMain: number; firmwareSub: number; workMode: number; lightMode: number; lightBrightness: number } | null } | null; + hookPort: number | null; + hookLastEvent: string | null; +} +export interface HardwareStatus { batteryLevel: number; signal: number; firmwareMain: number; firmwareSub: number; workMode: number; lightMode: number; lightBrightness: number } +export function deviceConnectionLabel(snapshot: (Pick & Partial>) | null): string { + if (!snapshot) return "连接本机服务…"; + const source=snapshot.device?.transport; + if (!source || !snapshot.device.status) { + if (snapshot.usb?.error) return snapshot.usb.present === true + ? "USB 已识别 · 状态读取失败" + : "本机服务运行 · USB 通信异常"; + return "本机服务运行 · 等待键盘"; + } + return `${source === "usb" ? "USB" : "蓝牙"} 已连接 · 语音键${snapshot.nativeKeyTestEnabled ? "已开启" : "未开启"}`; +} +export interface DeviceInfo { id: string; name: string; rssi: number | null; isCandidate: boolean } +export const defaultLights = [11, 5, 1, 1, 1, 6, 6, 7, 0]; + +// Renderer-only preview defaults. The native process is authoritative when available. +export const previewSettings: Settings = { + schemaVersion: 1, + provider: "wechat", + triggerMode: "hold", + activeProfile: "codex-cli", + captionsEnabled: true, + captionBottomOffset: 20, + microphone: null, savedDevice: null, cloudAppId: "", cloudResourceId: "volc.bigasr.sauc.duration", + autoInsert: true, minimizeToTray: true, lightBrightness: 35, + voiceKeysEnabled: true, + profiles: [ + { id: "claude-code", name: "Claude Code", accept: "Y", reject: "N", lightEffects: [...defaultLights] }, + { + id: "claude-desktop", + name: "Claude Desktop", + accept: "Enter", + reject: "Escape", + lightEffects: [...defaultLights], + }, + { id: "codex-cli", name: "Codex CLI", accept: "Y", reject: "N", lightEffects: [...defaultLights] }, + { + id: "chatgpt-app", + name: "ChatGPT App", + accept: "Enter", + reject: "Escape", + lightEffects: [...defaultLights], + }, + ], +}; + +export function phaseLabel(phase: Phase): string { + return { + idle: "等待输入", + preview: "字幕预览", + listening: "正在聆听", + transcribing: "正在识别", + final: "已完成", + error: "发生错误", + }[phase]; +} + +export function acceptCaption(current: Caption, incoming: Caption): Caption { + return incoming.sequence >= current.sequence ? incoming : current; +} + +export function sameSettings(left: Settings, right: Settings): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +// A slow save must never roll back a newer edit made while IPC was in flight. +export function mergeSavedDraft(current: Settings, submitted: Settings, saved: Settings): Settings { + return mergeIncomingDraft(current, submitted, saved); +} +export function mergeIncomingDraft(current: Settings, base: Settings, incoming: Settings): Settings { + const next = structuredClone(current); + for (const key of Object.keys(incoming) as (keyof Settings)[]) { + if (JSON.stringify(current[key]) === JSON.stringify(base[key])) { + (next as unknown as Record)[key] = structuredClone(incoming[key]); + } + } + next.savedDevice = incoming.savedDevice; + next.voiceKeysEnabled = incoming.voiceKeysEnabled; + return sameSettings(next, current) ? current : next; +} diff --git a/ahakey-desktop/src/main.tsx b/ahakey-desktop/src/main.tsx new file mode 100644 index 00000000..f9064ff3 --- /dev/null +++ b/ahakey-desktop/src/main.tsx @@ -0,0 +1,567 @@ +import React, { useEffect, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { + AudioLines, + Bluetooth, + Check, + ChevronRight, + Cloud, + Command, + Cpu, + Keyboard, + Layers, + Mic, + Monitor, + Radio, + Settings2, + ShieldCheck, + SlidersHorizontal, + Sparkles, + Subtitles, + X, +} from "lucide-react"; +import { + acceptCaption, + Caption, + phaseLabel, + previewSettings, + Profile, + sameSettings, + mergeSavedDraft, + mergeIncomingDraft, + Settings, + Snapshot, + deviceConnectionLabel, +} from "./contracts"; +import "./styles.css"; +import { DevicePanel, DeviceInformation, EnginePanel, HookPanel, VoiceControls } from "./LivePanels"; +import { FourKeysPanel } from "./FourKeysPanel"; +import { ProviderPicker } from "./ProviderPicker"; +import { DisplayPanel } from "./DisplayPanel"; +import { RoutingPanel } from "./RoutingPanel"; + +type Page = "voice" | "device" | "hooks" | "display" | "settings"; +const native = isTauri(); +const pages = [ + { + id: "voice" as const, + label: "语音", + icon: Mic, + description: "让想法,自然成为文字。", + }, + { + id: "device" as const, + label: "设备", + icon: Keyboard, + description: "连接键盘,让操作近在手边。", + }, + { + id: "hooks" as const, + label: "按键与灯效", + icon: Sparkles, + description: "给每个应用,恰到好处的反馈。", + }, + { + id: "display" as const, + label: "屏幕与卡片", + icon: Monitor, + description: "管理图片素材和服务额度。", + }, + { + id: "settings" as const, + label: "设置", + icon: Settings2, + description: "按你的习惯,调整每一个细节。", + }, +]; + +function useNative() { + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(""); + const refreshRef = useRef<() => Promise>(async () => {}); + const [caption, setCaption] = useState({ + phase: "idle", + text: "", + sequence: 0, + }); + useEffect(() => { + if (!native) return; + let disposed = false; + const cleanup: (() => void)[] = []; + (async () => { + const off = await listen("caption-update", ({ payload }) => { + if (!disposed) setCaption((current) => acceptCaption(current, payload)); + }); + if (disposed) { + off(); + return; + } + cleanup.push(off); + const offError = await listen("native-error", ({ payload }) => { + if (!disposed) setError(payload); + }); + if (disposed) { + offError(); + return; + } + cleanup.push(offError); + let refreshing: Promise | null = null; + let dirty = false; + const refresh = (): Promise => { + dirty = true; + if (refreshing) return refreshing; + refreshing = Promise.resolve().then(async () => { + do { + dirty = false; + const next = await invoke("get_snapshot"); + if (!disposed) { setSnapshot(next); setCaption(current => acceptCaption(current, next.caption)); if(next.settingsError) setError(next.settingsError); } + } while (dirty && !disposed); + }).catch(e => {if(!disposed) setError(String(e)); throw e;}).finally(() => {refreshing = null;}); + return refreshing; + }; + refreshRef.current = refresh; + const offRuntime = await listen("runtime-update", () => {void refresh().catch(() => {});}); + if (disposed) { offRuntime(); return; } + cleanup.push(offRuntime); + await refresh(); + })().catch((e) => { + if (!disposed) setError(String(e)); + }); + return () => { + disposed = true; + cleanup.forEach((stop) => stop()); + }; + }, []); + return { snapshot, setSnapshot, caption, error, setError, refresh: () => refreshRef.current() }; +} + +function CaptionWindow() { + const { caption } = useNative(); + return ( +
+
+
+

+ {caption.text || "等待按键 · 此窗口不会获取输入焦点"} +

+
+ ); +} + +function Badge({ + children, + good = false, +}: { + children: React.ReactNode; + good?: boolean; +}) { + return ( + + + {children} + + ); +} + +function App() { + const { snapshot, caption, error, setError, refresh } = useNative(); + const [page, setPage] = useState("voice"); + const [draft, setDraft] = useState( + structuredClone(previewSettings), + ); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(""); + const [saveFailed, setSaveFailed] = useState(false); + const saving = useRef(false); + const latestDraft = useRef(draft); + latestDraft.current = draft; + const heading = useRef(null); + const activePage = pages.find((item) => item.id === page)!; + const savedSignature = useRef(""); + const nativeBase = useRef(null); + const ownChangeId = useRef(null); + const saveSequence = useRef(0); + useEffect(() => { + if (snapshot) { + const signature = JSON.stringify(snapshot.settings); + if (signature !== savedSignature.current) { + const base = nativeBase.current; + const own = snapshot.settingsChangeId !== null && snapshot.settingsChangeId === ownChangeId.current; + setDraft(current => !base ? structuredClone(snapshot.settings) : own ? { ...current, savedDevice:snapshot.settings.savedDevice, voiceKeysEnabled:snapshot.settings.voiceKeysEnabled } : mergeIncomingDraft(current,base,snapshot.settings)); + nativeBase.current = structuredClone(snapshot.settings); + savedSignature.current = signature; + } + } + }, [snapshot?.settings, snapshot?.settingsChangeId]); + const changed = snapshot ? !sameSettings(draft, snapshot.settings) : false; + const activeProfile = draft.profiles.find( + (item) => item.id === draft.activeProfile, + )!; + const patch = (value: Partial) => { + setDraft((current) => ({ ...current, ...value })); + setNotice(""); + setSaveFailed(false); + }; + + useEffect(() => { + if (!snapshot || !changed || busy || saveFailed || snapshot.settingsError) return; + const timer = setTimeout(() => { void save(); }, 350); + return () => clearTimeout(timer); + }, [draft, snapshot?.settings, changed, busy, saveFailed]); + + async function save() { + if (saving.current) return; + saving.current = true; + const submitted = structuredClone(latestDraft.current); + const base = structuredClone(nativeBase.current ?? submitted); + const changeId = `ui-${Date.now()}-${++saveSequence.current}`; + ownChangeId.current = changeId; + setBusy(true); + setError(""); + setNotice(""); + try { + const settings = await invoke("save_settings", { + settings: submitted, + base, changeId, + }); + setDraft(current => mergeSavedDraft(current, submitted, settings)); + await refresh(); + setSaveFailed(false); + setNotice("已自动保存并应用到本机。"); + } catch (e) { + setSaveFailed(true); + setError(String(e)); + } finally { + saving.current = false; + setBusy(false); + } + } + async function test(pressed: boolean) { + try { + await invoke("test_caption", { pressed }); + } catch (e) { + setError(String(e)); + } + } + async function keyTest() { + if (!snapshot) return; + setBusy(true); + try { + const enabled = !snapshot.nativeKeyTestEnabled; + await invoke("set_key_test", { enabled }); + await refresh(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + } + function updateProfile(profile: Profile) { + patch({ + profiles: draft.profiles.map((item) => + item.id === profile.id ? profile : item, + ), + }); + } + + return ( +
+ + 跳到主要内容 + + + +
+
+ + 工作空间 + + + {snapshot + ? deviceConnectionLabel(snapshot) + : native + ? "连接本机服务…" + : "仅界面预览"} + + +
+
+
+
+

+ {activePage.label} +

+

{activePage.description}

+
+ RUST +
+ {error && ( +
+ {error} + +
+ )} + {!native && ( +
+ 当前是浏览器中的设计预览。保存设置和桌面字幕需要打开原生客户端。 +
+ )} + + {page === "voice" && ( + <> +
+

语音识别

选择后自动生效 · 托盘也可切换
+ patch({provider})} windows={!snapshot||snapshot.platform==="windows"}/> +
+ + + +
+

语音键监听默认开启,手动关闭后会记住。监听按键不会自动录音,USB 和蓝牙输入均可使用。

+
+ +
+

+

{draft.provider==="wechat"||draft.provider==="windows-native"?"AhaKey 实时字幕用于本地 / 豆包识别;当前输入法使用自己的语音浮窗。":"跟随目标输入窗口所在屏幕,显示在任务栏上方,不抢输入焦点。"}

+ {(draft.provider==="local"||draft.provider==="doubao"||caption.text)&&

{caption.text||"语音开始后在这里查看文字,也可先预览浮窗位置。"}

} +
+
多屏显示说明

语音开始时使用捕获的目标窗口定位,后续字幕更新继续跟随该窗口。这里的预览在设置窗口所在屏幕;托盘预览优先使用最近的工作窗口,无目标时使用鼠标屏幕。微信 / Windows 自带浮窗由各自输入法控制。

+
+ + )} + + {page === "device" &&
+ +
+

实体按键

分别设置四个按键的语音、快捷键或禁用动作。

+ +
+ + +
} + + {page === "hooks" && ( + <> +
+
+

应用配置

+ 本机偏好 +
+

+ 区分终端与桌面应用。保存后可将映射写入设备;这是按键配置,不会自动聚焦应用或批准操作。 +

+
+ {draft.profiles.map((profile, index) => ( + + ))} +
+

选择模式后,在下面逐一编辑四个按键;本机保存和写入键盘是两个明确步骤。

+
+ + + + )} + + {page === "display" && } + {page === "settings" && ( + <> +
+

+

+
+
+ 按键触发方式 +

原生 F17 / F18 语音键;微信模式由客户端发送开始和结束快捷键。

+
+ +
+
+
+ 桌面字幕 +

在目标屏幕底部显示,不获取输入焦点。

+
+ +
+
+
+ 字幕与任务栏的距离 +

以目标屏幕的缩放比例换算位置。

+
+ +
+
+ +
+

关于此预览版

+
+
+
运行环境
+
+ {snapshot?.platform ?? "浏览器预览"} · Rust + Tauri 2 + + React +
+
+
+
配置文件
+
+ {snapshot?.settingsPath ?? "仅原生客户端可保存"} +
+
+
+
目标屏幕定位
+
+ {snapshot?.foregroundCaptionSupported + ? "Windows 前台窗口所在屏幕" + : "此平台暂使用主屏幕,前台适配器待实现"} +
+
+
+
发行通道
+
开发预览 · 独立于 JavaFX 日常版
+
+
+
+ + )} +
+
+
+ {busy ? "正在保存并应用…" : changed ? saveFailed ? "保存失败,当前修改尚未生效" : "等待自动应用…" : notice ? ( + <> +
+ +
+
+
+ ); +} + +const isCaption = new URLSearchParams(location.search).has("caption"); +document.documentElement.classList.toggle("caption-page", isCaption); +createRoot(document.getElementById("root")!).render( + + {isCaption ? : } + , +); diff --git a/ahakey-desktop/src/styles.css b/ahakey-desktop/src/styles.css new file mode 100644 index 00000000..6d425240 --- /dev/null +++ b/ahakey-desktop/src/styles.css @@ -0,0 +1,1014 @@ +:root { + font-family: Inter, "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif; + font-synthesis: none; + color: #1e2925; + background: #f5f6f3; + --ink: #1e2925; + --muted: #5a6962; + --accent: #176b58; + --deep: #173d36; + --surface: #fff; + --canvas: #f5f6f3; + --border: #dce3dc; + --soft: #edf3ed; + --danger: #a82e32; + --focus: #277fca; + --motion: 160ms; + font-size: 14px; + line-height: 1.6; +} +.display-page { display: grid; gap: 20px; } +.quota-editor { border: 0; padding: 0; margin: 0; min-width: 0; } +.display-preview-row { display: grid; grid-template-columns: minmax(160px, 320px) minmax(0, 1fr); gap: 24px; align-items: center; } +.display-preview-row canvas { width: 100%; aspect-ratio: 2; image-rendering: pixelated; border: 1px solid var(--border); border-radius: 10px; background: var(--deep); } +.display-preview-controls, .quota-fields { display: grid; gap: 12px; min-width: 0; } +.display-preview-controls label, .quota-fields label { display: grid; gap: 6px; min-width: 0; } +.quota-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 16px; margin-block: 20px; } +.quota-account { min-width: 0; border: 1px solid var(--border); border-radius: 12px; padding: 16px; overflow-wrap: anywhere; } +.quota-account .section-heading { gap: 8px; } +.quota-windows > div { display: grid; gap: 4px; margin-block: 12px; } +.quota-windows strong { font-size: 24px; } +.quota-windows small { font-size: 12px; color: var(--muted); font-weight: normal; } +.quota-windows meter { width: 100%; height: 12px; accent-color: var(--accent); } +.quota-settings-row { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; } +.quota-settings-row input[type="number"] { width: 110px; margin-left: 8px; } +.mapping-fields { display: grid; gap: 10px; padding: 12px; background: var(--soft); border-radius: 8px; } +.quota-fields { padding-top: 12px; } +@media (max-width: 850px) { .display-preview-row { grid-template-columns: minmax(0, 1fr); } .display-preview-row canvas { max-width: 320px; } } +.surface > label { display: grid; gap: 8px; margin-top: 16px; min-width: 0; } +.surface input:not([type="checkbox"]):not([type="range"]), .surface select { min-width: 0; max-width: 100%; } +.surface input:not([type="checkbox"]):not([type="range"]) { padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--ink); } +.surface > .button-row { margin-top: 16px; } +.surface progress { width: 100%; margin-top: 16px; accent-color: var(--accent); } +.light-mappings { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); column-gap: 24px; } +.light-mappings label { display: grid; gap: 6px; min-width: 0; flex: 1; } +.device-results { overflow-wrap: anywhere; } +.four-key-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 20px 0; } +.physical-key { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; min-height: 112px; padding: 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); cursor: pointer; overflow-wrap: anywhere; } +.physical-key.chosen { border-color: var(--accent); background: var(--soft); box-shadow: inset 0 0 0 1px var(--accent); } +.physical-key span, .physical-key small { color: var(--muted); } +.key-edit-fields, .key-write-area { margin-top: 20px; } +.key-write-area { padding-top: 18px; border-top: 1px solid var(--border); } +.key-write-area label { display: flex; flex-direction: row; align-items: flex-start; gap: 10px; } +.key-write-area input[type="checkbox"] { flex: 0 0 18px; width: 18px; height: 18px; margin-top: 3px; } +@media (max-width: 850px) { .four-key-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input, +select { + font: inherit; +} +button, +select, +input[type="checkbox"] { + cursor: pointer; +} +button { + transition: + background var(--motion), + border-color var(--motion), + color var(--motion); +} +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 3px; +} +h1:focus { + outline: none; +} +button { + color: inherit; +} +svg { + flex-shrink: 0; +} +h1, +h2, +h3, +p { + margin: 0; +} +h2 { + font-size: 17px; + font-weight: 650; + display: flex; + align-items: center; + gap: 9px; +} +h3 { + font-size: 17px; +} +.app-shell { + display: flex; + min-height: 100vh; +} +.sidebar { + width: 224px; + background: #fbfcf9; + border-right: 1px solid var(--border); + padding: 30px 20px 24px; + display: flex; + flex-direction: column; + position: fixed; + inset: 0 auto 0 0; +} +.brand { + display: flex; + align-items: center; + gap: 12px; + color: var(--deep); + font-size: 23px; + font-weight: 750; + letter-spacing: -0.8px; +} +.brand span { + display: block; + font-size: 9px; + letter-spacing: 3.7px; + line-height: 1.6; + font-weight: 650; +} +.brand-mark { + width: 42px; + height: 46px; + background: var(--deep); + color: #f0f8ed; + display: grid; + place-items: center; + border-radius: 13px; +} +.section-eyebrow { + color: var(--muted); + margin: 44px 12px 14px; + font-size: 11px; + letter-spacing: 1px; +} +nav { + display: grid; + gap: 6px; +} +.nav-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + border: 1px solid transparent; + border-radius: 9px; + padding: 12px 14px; + background: transparent; + text-align: left; + font-weight: 550; + color: var(--muted); +} +.nav-item:hover { + background: var(--soft); +} +.nav-item.selected { + color: var(--deep); + background: #e5ede4; + font-weight: 650; +} +.nav-indicator { + width: 5px; + height: 5px; + background: var(--accent); + border-radius: 50%; + margin-left: auto; +} +.sidebar-bottom { + margin-top: auto; + padding: 24px 10px 0; +} +.build-label { + display: flex; + gap: 8px; + align-items: center; + font-size: 12px; + font-weight: 650; +} +.sidebar-bottom p { + color: var(--muted); + font-size: 11px; + margin: 10px 0 20px; + line-height: 1.9; +} +.version { + display: flex; + justify-content: space-between; + color: var(--muted); + border-top: 1px solid var(--border); + padding-top: 15px; + font-size: 11px; +} +.workspace { + flex: 1; + margin-left: 224px; + min-width: 0; +} +.topbar { + min-height: 67px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 36px; + background: #fafbf8; + gap: 16px; +} +.topbar > span { + display: flex; + align-items: center; + gap: 12px; + color: var(--muted); + font-size: 12px; +} +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 30px; + padding: 4px 10px; + font-size: 11px; + white-space: nowrap; + flex-shrink: 0; +} +.status-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #79857e; +} +.badge.good { + color: var(--accent); + border-color: #cddfce; +} +.good .status-dot { + background: var(--accent); +} +main { + max-width: 1056px; + padding: 32px 36px 112px; + margin: auto; +} +.page-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 26px; +} +h1 { + font-size: 29px; + letter-spacing: -0.7px; + font-weight: 650; + line-height: 1.5; +} +.page-heading p { + color: var(--muted); + margin-top: 4px; + font-size: 13px; +} +.preview-chip { + font-size: 10px; + letter-spacing: 1.3px; + font-weight: 650; + color: var(--muted); + padding: 5px 9px; + border: 1px solid var(--border); + border-radius: 5px; +} +section + section { + margin-top: 24px; +} +.hero-panel { + background: var(--deep); + color: #f2f7ed; + display: flex; + border-radius: 16px; + min-height: 236px; + padding: 28px 30px; + overflow: hidden; +} +.hero-copy { + flex: 1; + z-index: 1; +} +.overline { + font-size: 9px; + letter-spacing: 2.5px; + color: #b8d1c2; +} +.hero-panel h2 { + font-size: 30px; + line-height: 1.4; + letter-spacing: 0.5px; + font-weight: 500; + margin: 12px 0 10px; +} +.hero-panel p { + font-size: 12px; + line-height: 1.9; + color: #d0e0d3; +} +.hero-meta { + display: flex; + align-items: center; + gap: 7px; + margin-top: 20px; + font-size: 10px; + color: #cadccb; +} +.hero-art { + width: 210px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 19px; +} +.sound-ring { + display: grid; + place-items: center; + width: 140px; + height: 140px; + border: 1px solid #4c7061; + border-radius: 50%; + outline: 1px solid #38594c; + outline-offset: 17px; + color: #d7efb8; + background: #244b3f; +} +.art-label { + color: #c7d9ca; + font-size: 9px; + letter-spacing: 0.5px; + margin-top: 14px; +} +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 14px; +} +.section-heading > span:not(.badge) { + font-size: 11px; + color: var(--muted); +} +.provider-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.provider-card { + padding: 22px; + text-align: left; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; +} +.provider-card:hover, +.profile-card:hover { + border-color: #88a795; + background: #fafcf8; +} +.provider-card.chosen, +.profile-card.chosen { + border-color: var(--accent); + background: #f5faf2; + box-shadow: inset 0 0 0 1px var(--accent); +} +.card-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 15px; +} +.icon-tile { + width: 41px; + height: 41px; + display: grid; + place-items: center; + background: #edf2e8; + color: var(--deep); + border-radius: 10px; +} +.radio-mark { + width: 18px; + height: 18px; + border: 1px solid #8c9c91; + border-radius: 50%; + display: grid; + place-items: center; +} +.chosen .radio-mark { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.provider-card p { + color: var(--muted); + font-size: 12px; + margin-top: 7px; +} +.card-note { + display: block; + margin-top: 17px; + font-size: 11px; + color: var(--muted); + border-top: 1px solid var(--border); + padding-top: 12px; +} +.surface { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + padding: 24px; +} +.section-description { + margin-top: 9px; + font-size: 12px; + color: var(--muted); + line-height: 1.9; +} +.caption-example { + border: 1px solid var(--border); + background: var(--canvas); + border-radius: 10px; + padding: 16px 18px; + margin: 20px 0 16px; + min-height: 94px; +} +.caption-example > div { + display: flex; + align-items: center; + gap: 8px; + color: var(--accent); + font-size: 10px; + margin-bottom: 7px; +} +.caption-example p { + font-size: 13px; +} +.button-row { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.primary, +.secondary, +.text-button { + min-height: 38px; + padding: 8px 16px; + border-radius: 7px; + font-size: 12px; + font-weight: 550; +} +.primary { + background: var(--accent); + border: 1px solid var(--accent); + color: white; +} +.primary:not(:disabled):hover { + background: var(--deep); +} +.secondary { + border: 1px solid var(--border); + background: white; +} +.secondary:not(:disabled):hover { + background: var(--soft); + border-color: #91a998; +} +.text-button { + background: transparent; + border: 1px solid transparent; + color: var(--muted); +} +.text-button:not(:disabled):hover { + background: var(--soft); +} +.key-test { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + border-top: 1px solid var(--border); + padding-top: 18px; + margin-top: 20px; +} +.key-test strong, +.setting-row strong { + font-size: 13px; + font-weight: 600; +} +.key-test p, +.setting-row p { + color: var(--muted); + font-size: 11px; + margin-top: 4px; +} +.key-test button { + flex-shrink: 0; +} +.save-bar { + position: fixed; + bottom: 0; + left: 224px; + right: 0; + padding: 16px 36px; + border-top: 1px solid var(--border); + background: #fafbf8; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + z-index: 5; +} +.save-bar > div { + display: flex; + gap: 8px; + align-items: center; + color: var(--muted); + font-size: 11px; +} +.unsaved-dot { + width: 6px; + height: 6px; + background: #946d12; + border-radius: 50%; +} +.device-empty { + min-height: 330px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + text-align: center; + gap: 15px; +} +.device-empty p { + color: var(--muted); + font-size: 13px; + line-height: 1.9; +} +.large-icon { + background: var(--soft); + padding: 22px; + border-radius: 22px; + color: var(--accent); + margin-bottom: 6px; +} +.feature-row { + display: flex; + gap: 16px; + margin-top: 26px; + color: var(--accent); +} +.feature-row strong { + font-size: 13px; + color: var(--ink); +} +.feature-row p { + color: var(--muted); + font-size: 12px; + margin-top: 4px; +} +.profile-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-top: 22px; +} +.profile-card { + position: relative; + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 7px; + padding: 19px; + border: 1px solid var(--border); + background: white; + border-radius: 10px; + text-align: left; +} +.profile-card svg { + margin: 3px 0 5px; + color: var(--accent); +} +.profile-card > span:last-child { + color: var(--muted); + font-size: 11px; +} +.profile-number { + position: absolute; + top: 15px; + right: 17px; + color: var(--muted); + font-size: 11px; +} +.mapping-title { + margin-top: 26px; + margin-bottom: 13px; + display: flex; + align-items: baseline; + gap: 12px; +} +.mapping-title span { + color: var(--muted); + font-size: 11px; +} +.two-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +label { + display: flex; + flex-direction: column; + gap: 7px; + font-size: 12px; +} +select, +input[type="number"] { + border: 1px solid #aab9ae; + background: #fff; + color: var(--ink); + border-radius: 6px; + min-height: 39px; + padding: 7px 10px; +} +.hint { + color: var(--muted); + font-size: 11px; + margin-top: 14px; +} +.setting-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 28px; + padding: 22px 0; + border-bottom: 1px solid var(--border); +} +.setting-row:last-child { + padding-bottom: 0; + border-bottom: 0; +} +.setting-row select { + max-width: 205px; + font-size: 12px; + flex-shrink: 0; +} +.switch-label { + flex-direction: row; + align-items: center; + white-space: nowrap; +} +input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent); +} +.number-label { + flex-direction: row; + align-items: center; + color: var(--muted); +} +.number-label input { + width: 76px; +} +.about-list { + margin: 18px 0 0; + font-size: 12px; +} +.about-list > div { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: 12px; + margin-top: 15px; +} +dt { + color: var(--muted); +} +dd { + margin: 0; +} +.file-path { + overflow-wrap: anywhere; + font-family: ui-monospace, monospace; + font-size: 11px; +} +.message { + border: 1px solid #cddcce; + background: #ecf4ec; + border-radius: 8px; + padding: 13px 15px; + margin: 0 0 20px; + font-size: 12px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} +.message.error { + color: var(--danger); + border-color: #e1babc; + background: #fff2f2; +} +.message button { + border: 0; + background: transparent; + min-width: 28px; + min-height: 28px; + padding: 3px; + display: grid; + place-items: center; +} +.skip-link { + position: fixed; + top: -100px; + left: 12px; + z-index: 20; + background: #fff; + color: var(--accent); + padding: 12px; +} +.skip-link:focus { + top: 12px; +} +.caption-page, +.caption-page body { + background: var(--deep); + color: #f4f8ee; + overflow: hidden; +} +.caption-root { + padding: 15px 21px; + user-select: none; +} +.caption-label { + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + color: #e0efd7; +} +.caption-label > span { + color: #bcd3c4; + margin-left: 6px; + font-size: 10px; +} +.caption-label > .caption-phase { + margin-left: auto; +} +.caption-root p { + margin-top: 10px; + font-size: 16px; + line-height: 1.45; + overflow-wrap: anywhere; +} +@media (max-width: 900px) { + .sidebar { + width: 190px; + padding-left: 14px; + padding-right: 14px; + } + .workspace { + margin-left: 190px; + } + .save-bar { + left: 190px; + padding-left: 24px; + padding-right: 24px; + } + .topbar { + padding: 14px 24px; + } + main { + padding: 26px 24px 116px; + } + .hero-art { + width: 155px; + } + .hero-panel { + padding: 25px; + } + .sound-ring { + width: 110px; + height: 110px; + } + .hero-panel h2 { + font-size: 26px; + } + .hero-meta { + max-width: 210px; + } + .provider-card { + padding: 18px; + } +} +@media (max-width: 720px) { + .sidebar { + width: 76px; + padding: 24px 9px; + } + .brand { + justify-content: center; + } + .brand > div:last-child, + .section-eyebrow, + .sidebar-bottom, + .nav-indicator { + display: none; + } + nav { + margin-top: 30px; + } + .nav-item { + flex-direction: column; + padding: 10px 2px; + gap: 5px; + font-size: 10px; + text-align: center; + } + .workspace { + margin-left: 76px; + } + .save-bar { + left: 76px; + } + .hero-art { + display: none; + } + .hero-panel h2 { + font-size: 27px; + } + .hero-meta { + max-width: none; + } + .section-heading { + align-items: flex-start; + } + .section-heading > span:not(.badge) { + max-width: 130px; + text-align: right; + } + .setting-row { + gap: 16px; + flex-wrap: wrap; + } +} +@media (max-width: 480px) { + main { + padding: 22px 16px 132px; + } + .topbar { + padding: 12px 16px; + flex-wrap: wrap; + gap: 8px; + } + .page-heading { + align-items: flex-start; + } + .preview-chip { + display: none; + } + .provider-grid, + .two-columns, + .profile-grid { + grid-template-columns: 1fr; + } + .hero-panel { + padding: 23px; + } + .surface { + padding: 18px; + } + .save-bar { + padding: 12px 16px; + flex-wrap: wrap; + gap: 8px; + } + .save-bar > div { + font-size: 10px; + } + .key-test { + flex-wrap: wrap; + } + .section-heading { + flex-wrap: wrap; + } + .about-list > div { + grid-template-columns: 1fr; + gap: 4px; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition: none !important; + animation: none !important; + scroll-behavior: auto !important; + } +} + +/* Compact desktop density: preserve readable type and consistent control gaps. */ +.device-page { display: grid; gap: 16px; min-width: 0; } +.device-page > section + section { margin-top: 0; } +.device-key-entry { display: flex; align-items: center; justify-content: space-between; gap: 12px 20px; flex-wrap: wrap; } +.device-key-entry .secondary { flex-shrink: 0; } +.device-key-entry .section-description { margin-bottom: 0; } +.reconnect-status { padding: 10px 12px; background: var(--soft); border-radius: 8px; font-size: 13px; line-height: 1.55; margin: 12px 0; } +.sidebar { width: 184px; padding: 20px 14px 18px; } +.brand { font-size: 21px; gap: 9px; } +.brand-mark { width: 36px; height: 40px; border-radius: 10px; } +.section-eyebrow { margin: 26px 10px 10px; } +.nav-item { padding: 10px 11px; gap: 9px; } +.workspace { margin-left: 184px; } +.topbar { min-height: 48px; padding: 10px 22px; gap: 10px; } +main { padding: 18px 22px 80px; } +h1 { font-size: 24px; } +h2 { font-size: 16px; } +.page-heading { margin-bottom: 16px; } +.page-heading p { margin-top: 2px; } +.surface { padding: 16px; border-radius: 10px; } +section + section { margin-top: 16px; } +.section-heading { margin-bottom: 10px; gap: 10px; } +.section-description { margin-top: 5px; font-size: 13px; line-height: 1.55; } +.primary, .secondary, .text-button { min-height: 36px; padding: 7px 12px; font-size: 13px; } +.button-row { gap: 8px; } +.surface > .button-row { margin-top: 12px; } +.hint { margin-top: 10px; font-size: 12px; line-height: 1.55; } +.setting-row { padding: 14px 0; gap: 16px; } +.about-list { margin-top: 12px; } +.about-list > div { margin-top: 10px; } +.save-bar { left: 184px; padding: 9px 22px; gap: 12px; } +.provider-switcher { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; } +.provider-option { display: flex; flex-direction: column; align-items: stretch; gap: 5px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); text-align: left; min-width: 0; } +.provider-option:hover { background: var(--soft); border-color: var(--accent); } +.provider-option.selected { border-color: var(--accent); background: var(--soft); box-shadow: inset 0 0 0 1px var(--accent); } +.provider-option strong { font-size: 13px; font-weight: 650; } +.provider-option > span:last-child { color: var(--muted); font-size: 11px; } +.provider-option-top { display: flex; justify-content: space-between; min-height: 22px; color: var(--accent); } +.voice-quickbar { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; margin-top: 12px; } +.voice-quickbar label { flex-direction: row; align-items: center; gap: 7px; color: var(--muted); } +.voice-quickbar select { min-height: 34px; padding: 5px 8px; font-size: 12px; max-width: 168px; } +.voice-quickbar .secondary { margin-left: auto; } +.compact-details { margin-top: 12px; font-size: 12px; color: var(--muted); } +.compact-details summary { cursor: pointer; padding: 3px 0; } +.compact-details p { margin-top: 8px; line-height: 1.65; } +.caption-example { min-height: 64px; margin: 10px 0; padding: 10px 12px; } +.caption-example > div { margin-bottom: 4px; font-size: 11px; } +.profile-grid { grid-template-columns: repeat(4,minmax(0,1fr)); margin-top: 14px; gap: 10px; } +.profile-card { padding: 12px; gap: 5px; } +.profile-card strong { font-size: 13px; } +.profile-card svg { width: 18px; height: 18px; margin: 0 0 3px; } +.profile-number { top: 10px; right: 10px; } +.physical-key { min-height: 90px; padding: 12px; gap: 7px; } +.four-key-grid { margin: 14px 0; } +@media (max-width: 850px) { + .provider-switcher, .profile-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } + .voice-quickbar .secondary { margin-left: 0; } +} +@media (max-width: 720px) { + .sidebar { width: 76px; padding: 18px 9px; } + .workspace { margin-left: 76px; } + .save-bar { left: 76px; } + .nav-item { padding: 9px 2px; gap: 5px; font-size: 11px; } + .topbar { flex-wrap: wrap; } + main { padding-left: 16px; padding-right: 16px; } +} +@media (max-width: 480px) { + .provider-switcher, .profile-grid, .two-columns { grid-template-columns: 1fr; } + .save-bar { padding: 9px 16px; } +} +.routing-fields { border: 0; padding: 0; margin: 16px 0; min-width: 0; display: grid; gap: 14px; } +.routing-transport { display: grid; gap: 7px; margin-top: 16px; } +.connection-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 12px 0; font-size: 13px; color: var(--ink); } +.routing-transport select { width: 100%; min-width: 0; } +.routing-fields label { display: grid; gap: 7px; min-width: 0; } +.routing-fields select { width: 100%; min-width: 0; } +.routing-links { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 16px; } +.routing-links > div { border: 1px solid var(--border); border-radius: 12px; padding: 12px; display: grid; align-content: start; gap: 6px; min-width: 0; } +.routing-links > div.selected { border-color: var(--accent); } +.routing-links span, .routing-links small { font-size: 12px; } +.routing-links .host-reported { font-size: 14px; font-weight: 600; overflow-wrap: anywhere; } +.routing-links .host-alias { display: grid; gap: 4px; min-width: 0; font-size: 12px; } +.routing-links .host-alias input { width: 100%; min-width: 0; box-sizing: border-box; } +.routing-panel .button-row { flex-wrap: wrap; } +@media (max-width: 480px) { .routing-links { grid-template-columns: 1fr; } } diff --git a/ahakey-desktop/tsconfig.json b/ahakey-desktop/tsconfig.json new file mode 100644 index 00000000..87954720 --- /dev/null +++ b/ahakey-desktop/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/ahakey-desktop/vite.config.ts b/ahakey-desktop/vite.config.ts new file mode 100644 index 00000000..4862870d --- /dev/null +++ b/ahakey-desktop/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + clearScreen: false, + server: { + host: "127.0.0.1", + port: 1420, + strictPort: true, + watch: { ignored: ["**/src-tauri/**", "**/output/**", "**/crates/**", "**/.playwright-cli/**"] }, + }, + build: { target: "es2022" }, +}); From 1648b2aece1361c5e1f35fb4b36c339bb964a687 Mon Sep 17 00:00:00 2001 From: Lihao Date: Mon, 14 Sep 2026 02:58:54 -0700 Subject: [PATCH 10/21] feat(runtime): wire desktop commands and system tray lifecycle Connect the preceding feature modules through shared Tauri state, commands and tray controls. --- ahakey-desktop/src-tauri/Cargo.lock | 6842 +++++++++++++++++ ahakey-desktop/src-tauri/Cargo.toml | 48 + ahakey-desktop/src-tauri/Info.plist | 6 + ahakey-desktop/src-tauri/build.rs | 3 + .../src-tauri/capabilities/default.json | 7 + ahakey-desktop/src-tauri/icons/icon.icns | Bin 0 -> 42448 bytes ahakey-desktop/src-tauri/icons/icon.ico | Bin 0 -> 7054 bytes ahakey-desktop/src-tauri/icons/icon.png | Bin 0 -> 7051 bytes ahakey-desktop/src-tauri/icons/icon.svg | 1 + ahakey-desktop/src-tauri/src/backend.rs | 1009 +++ ahakey-desktop/src-tauri/src/main.rs | 139 + ahakey-desktop/src-tauri/src/state.rs | 175 + ahakey-desktop/src-tauri/src/tray.rs | 371 + ahakey-desktop/src-tauri/tauri.conf.json | 45 + .../src-tauri/tauri.macos.conf.json | 8 + 15 files changed, 8654 insertions(+) create mode 100644 ahakey-desktop/src-tauri/Cargo.lock create mode 100644 ahakey-desktop/src-tauri/Cargo.toml create mode 100644 ahakey-desktop/src-tauri/Info.plist create mode 100644 ahakey-desktop/src-tauri/build.rs create mode 100644 ahakey-desktop/src-tauri/capabilities/default.json create mode 100644 ahakey-desktop/src-tauri/icons/icon.icns create mode 100644 ahakey-desktop/src-tauri/icons/icon.ico create mode 100644 ahakey-desktop/src-tauri/icons/icon.png create mode 100644 ahakey-desktop/src-tauri/icons/icon.svg create mode 100644 ahakey-desktop/src-tauri/src/backend.rs create mode 100644 ahakey-desktop/src-tauri/src/main.rs create mode 100644 ahakey-desktop/src-tauri/src/state.rs create mode 100644 ahakey-desktop/src-tauri/src/tray.rs create mode 100644 ahakey-desktop/src-tauri/tauri.conf.json create mode 100644 ahakey-desktop/src-tauri/tauri.macos.conf.json diff --git a/ahakey-desktop/src-tauri/Cargo.lock b/ahakey-desktop/src-tauri/Cargo.lock new file mode 100644 index 00000000..78b8225a --- /dev/null +++ b/ahakey-desktop/src-tauri/Cargo.lock @@ -0,0 +1,6842 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "ahakey-ble" +version = "0.1.0" +dependencies = [ + "btleplug", + "futures", + "serde", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "uuid", + "windows 0.62.2", + "windows-future 0.3.2", +] + +[[package]] +name = "ahakey-cloud" +version = "0.1.0" +dependencies = [ + "flate2", + "futures-util", + "keyring", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-tungstenite", + "tokio-util", + "uuid", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "ahakey-desktop" +version = "1.1.5" +dependencies = [ + "ahakey-ble", + "ahakey-cloud", + "ahakey-speech", + "chrono", + "keyring", + "libc", + "reqwest 0.12.28", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-global-shortcut", + "tauri-plugin-single-instance", + "tempfile", + "tokio", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "ahakey-speech" +version = "0.1.0" +dependencies = [ + "anyhow", + "cpal", + "reqwest 0.12.28", + "sha2", + "sherpa-onnx", + "tempfile", + "tokio", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bluez-async" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ae4213cc2a8dc663acecac67bbdad05142be4d8ef372b6903abf878b0c690a" +dependencies = [ + "bitflags 2.13.1", + "bluez-generated", + "dbus", + "dbus-tokio", + "futures", + "itertools 0.14.0", + "log", + "serde", + "serde-xml-rs", + "thiserror 2.0.20", + "tokio", + "uuid", +] + +[[package]] +name = "bluez-generated" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9676783265eadd6f11829982792c6f303f3854d014edfba384685dcf237dd062" +dependencies = [ + "dbus", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "btleplug" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af9a6252c1fad599d8c45507e668a9af18cb5535ef6345000697fd035d6d8e8" +dependencies = [ + "async-trait", + "bitflags 2.13.1", + "bluez-async", + "dashmap", + "dbus", + "futures", + "jni 0.22.4", + "log", + "objc2", + "objc2-core-bluetooth", + "objc2-foundation", + "once_cell", + "static_assertions", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "uuid", + "windows 0.62.2", + "windows-future 0.3.2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.5+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.20", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-bluetooth" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b30b9eacc37434a61377866f68b27acef9fa5496f25cc9ca8b2549032e0394" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni 0.21.1", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.8", + "serde", + "sha2", + "zbus 4.4.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde-xml-rs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2215ce3e6a77550b80a1c37251b7d294febaf42e36e21b7b411e0bf54d540d" +dependencies = [ + "log", + "serde", + "thiserror 2.0.20", + "xml", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sherpa-onnx" +version = "1.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4ef11f6b23c916a8e36c3cf65201f796d0e6aa30182a18d4f798d0b7f62547" +dependencies = [ + "serde", + "serde_json", + "sherpa-onnx-sys", +] + +[[package]] +name = "sherpa-onnx-sys" +version = "1.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "566aad07d0924a7ed897cc035cc67ad71cdfddb959717dbf445031d5d4b58d4d" +dependencies = [ + "bzip2 0.4.4", + "tar", + "ureq", + "zip", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk 0.9.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk 0.9.0", + "ndk-sys 0.6.0+11769913", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0cb5c412a5071b69bab6a6df1583cbb89460d4a83b6a24769b08d15b6b1e1" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.20", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus 5.19.0", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.5+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.5+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk 0.9.0", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2 0.5.2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.14.2", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.20", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow 1.0.4", +] diff --git a/ahakey-desktop/src-tauri/Cargo.toml b/ahakey-desktop/src-tauri/Cargo.toml new file mode 100644 index 00000000..29ac2cb7 --- /dev/null +++ b/ahakey-desktop/src-tauri/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "ahakey-desktop" +version = "1.1.5" +description = "AhaKey unified desktop client" +edition = "2021" +rust-version = "1.85" + +[features] +default = [] +custom-protocol = ["tauri/custom-protocol"] + +[build-dependencies] +tauri-build = { version = "2.5", features = [] } + +[dependencies] +tauri = { version = "2.11.2", features = ["tray-icon"] } +tauri-plugin-single-instance = "2" +tauri-plugin-global-shortcut = "2" +tokio = { version = "1", features = ["sync", "time", "rt-multi-thread", "macros", "process", "io-util"] } +ahakey-speech = { path = "../crates/speech" } +ahakey-ble = { path = "../crates/ble" } +ahakey-cloud = { path = "../crates/cloud" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +chrono = { version = "0.4", default-features = false, features = ["std"] } +zeroize = "1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +keyring = { version = "3", features = ["windows-native"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_System_LibraryLoader", "Win32_UI_Accessibility"] } + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3", features = ["apple-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] } + +[dev-dependencies] +tempfile = "3" + +[profile.release] +strip = true +lto = "thin" +codegen-units = 1 diff --git a/ahakey-desktop/src-tauri/Info.plist b/ahakey-desktop/src-tauri/Info.plist new file mode 100644 index 00000000..41c56c52 --- /dev/null +++ b/ahakey-desktop/src-tauri/Info.plist @@ -0,0 +1,6 @@ + + + + NSMicrophoneUsageDescriptionAhaKey records speech only when you start voice input. + NSBluetoothAlwaysUsageDescriptionAhaKey connects to your keyboard to read battery status and configure keys and lights. + diff --git a/ahakey-desktop/src-tauri/build.rs b/ahakey-desktop/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/ahakey-desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/ahakey-desktop/src-tauri/capabilities/default.json b/ahakey-desktop/src-tauri/capabilities/default.json new file mode 100644 index 00000000..02ca71c2 --- /dev/null +++ b/ahakey-desktop/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "desktop", + "description": "Local first-party interface and caption events only", + "windows": ["main", "caption"], + "permissions": ["core:event:default"] +} diff --git a/ahakey-desktop/src-tauri/icons/icon.icns b/ahakey-desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..862e0ccd573dc4781c4f8374716c89e452e3d611 GIT binary patch literal 42448 zcmeFZcUY6l_9#3_Xi@|VMG%PF4k{p3YHVPksVE2vC<;PAKzdCqAV{;(M2e_@h#*an z7L=x-bfrlVq&Mj$B=4O!Z1>T<_u1$CzWd$p`Tn?h9?8s_HEY(aS!HI;L`$>F4gfr@ zwRDi)4FKjc76=I3dhhKVndF=7!XSGC2 zX-6!5_*uJ`i`~B#&6lC(zRR6l6(D7iot|EhvZ~q*Ncs06aBws@Di)540R(>)ECCP# zQ~=?=3g+B-e-+$;!ZRtxm=w5wRn9Qdfj)q(|yC6 zD!Bc+;Sm8U7}In2A>{zZ82&040>A%P|`z zkgt+-eC1fLprxm0uK38YRxKB+el6N9L+yN*J9%kCqDt!Is{wMD6DRIN$^mj%IEKF* zMgce|*bt$#*#Lop{PCf{S^rUC6bQw?*8f!fwekNCwLdXf{O9?ApJ!A|0G4KwdjJr+ z=dY`+!No4f4glafeCWV&09OV5$HoG`rd=BsA=2@~2lnc@wvM*z1nM+#u7>X3Ic}&CwhCCcC-p&u2(i|E9nm{4_^0GeBM{W%qL_2E%wH+?x!%2A#wo_?RwY6F&+mk)iM4#CN zKFI6SYgg^`s2+cqi5ECjC9j;}y~sc6JT+-+e%PUJ?!q0}?6tZHo9=%8M7eWzVO-s8 zv1x0kn@en%VCh_NeTube-WFoc_}HeF2S=mxwS|*B4c_hF_h9qQd+I&!(m&U56W3wl zCZoOb8{8x_23LCPX>i+}l#B@6WGP$kxNjQ|5Uc$fOZu(qycRw7pan(mIrwt7VL@N~ zaJSvYvoH1WJb86Qv^lt}BJbffJ&r!9*2P4JV96ybG!pF{?dO~I6Pqa(~m zXMott(wBEe-d!ggC9;9cl~er=)V9nWLZ1Wy@6gEIO`J^J5^DsWJ#6Kc-i*qruI|)tFD&ns(}QSA$}$+UT{iAw6$?? zlf=+GCU*3;5WVKtyaqkh-#0fzzcBz_UNY!z6#|d~*grd7z%JBxu_9 zx!lg$qyxp0*VdA{SnSFNyR4MvA`?6AYMn&ZbO~q=paJyVmTBl?N=PZ+8T24G&BWe73P-b{&T8Bpuv1iNz8= zkj!GW`z!jW7=f@n@+K{c>VOdIW}L}LlPaPeG0tk5`_Q=7;N`j70l0>%UXv#BuMYNe z7vDBCoIZE(tNS+WL!Fk!=;cr*3>j+_L7jU`~#IP@gR*pk7GY_ID z7&Dyv{;FUKNv&Z5Gb=E?I;=9467yB`v>GgUv?WdiL&#PG`v`6u@O29~g4^UhCW>+S z;cNb*UKqSpj*VUY+;y+kXj0PxuK3diyyX*8dkyYJy|>7ql2>wUwJ1=v$qU)BSXs$T zb{NeJo@OQsy{En#EZQxW#g6ancCnz#d(`K1fK0)&yYqHODqcz9EiuOFuJ>uaToF>3 z2qYE6ajSU|z>{sX?)GfPz&N@niX;$`IjEWye({?w4UU z?GOd$Cd?&(*2v8yz@wrp5!db}#qx|L zE@`pmCOxV{2n;;aIv6W-U6MI&2o67RiuuXemhT#HiD(&FLPAWN&o&PuorV0M*2HaZ zmrBkF9#^A{VAPp<&=v#kckP?-npOeZmT=^46G`M0Hznq)Q!7EVcK4ttIO3?}HBo>q zo^&!Z%77+14RpJ=5jyz!y{;hY;O+?X$CUtQ8sl|EF^xu7q2Y>kL$I6VXc+PaQKmz8 z-}?-{GpV`g5Vt1?JRE-5k}$22JhPwNaoMW;FQ!^{rGhLRIK4z5WjmXbB7}n ze)vM0%x1tDVl&A2zE~(2Y_icofMg@aG^}XJ7ZZ1H8y!bJCud|5BS2>*FKXeQ_@WFp zfM;Jx&~mkKXQY`WTdjAgw5d)Zvg?3e$sx14SRLq8Erc-&O{y02qCo7MyB9V41UQ`d z>=}sLm)ejc;YWPM9lSMbEjZv4%#!=cmSV+ZdU2{w_S`rLBt3Y_Pn{oTB|G;QEWm1< zc7A)oIq)vyLeLg0nVp5~*SA9!w76?Afs>9-qOlJUR%m@N$_zjwaWi=p07*TE z2t^Wh(nObzy0t*}UNz;;Wd?yPya{)0BPZZ-GURBMfh4onV*s!c0M=M0a4hE|IS>Fs zqpdU^Lice2pnDUT3Yh}GJR8=fxK$wO>gE9cvhbpL9|Ppw_4ICs*r5Ed_I2WuX>TV^ zh?e^=4bQWRmKC{d?JhJycBw`xKCX*v4ck)nS>?VZYf?h|J!JNTP|x#_ppSz!wCT}_ z`8PtgS~qcV512sGz#8Q9M);cf8}|S+~N@ zCHf_0>eA7DKu zqMZ@$IslM+0K5M;UvMq(5c5BN5x94rF4$HyT_#bws38)_8%wqRl08ywaEHVj9J?r~ z{mrC8ZGPKqq0*{x+M>(E`^I%aENnnB<(4+RO~)oMO;t{Acy6r;amvJGu_(4kMMTZD z=f$8M9K4uEWx}?-KuXTGRWeP@gz0McaA;F!V7r>-x>dkeJ9$NtSwAv47aQf$uht;l zIbS(V*t6AKS8B`aOp0N{7A@;&{({OPjo!E8FbWx4a!OPZv6h#5?Krr@Drb!45`U@uama6Nsoik zls%W0+M5mAtZZn_xah_SIN=J^ zwS5vXS?uONPus{{e#^75KQ%x@f&bRVD4k!0rESZE0e%}qzb-fz^}p4(I6d^b`@DdG zko^MHdvVrvsVgWap#+`=!2an{iqllNo9N(~U!;WJfnyT5dp)|ZZJ}oRgM3jp*vbUR zd-d0z8l!y;PUSP0?UvmOfWWn$FyUTkX}X!!CTs)<(5?9vN~a0LOQfd14$;tnn!_V+ zTt0U}RH?Unn?;+#MyP@*1Gk6mw0HpMt}IyU$+Vtq5J$BWD$Nh=Ha%1};7@jvU@pHRpkZUunsLNE32zD$e%=h``@#sqv%PVDdd2mKV)CR4d# z9CPn(&T})f?#Mc4Oe&h+tLIrbxfqpB-MUj}Qy1N=_{jtEFzvhB0=4EN^=6G`jc1Q| zL)yuLEnW2ez?9JLGM?N_EfH=&M7}jK&BdY=cd5BecUwLRPvaU>n-j$X+!F3B_Xay; z1CTV0c`qJBl{#Q>#%yYhkc>hu!UOy;23FG-L`mDMKa!fy8kTS__u9PO8{hc#K^NTt zcI46TV-}6Qc<+w!_EgJVtCz>Qn^H(}f^Gxi#{$BO7or|)!K%EjZo#r_fI_4M9PZSpBFA-_SoqV~zLLk}{v_9r-PyG77)JLbf!~%|bBdZieSK$Kuq7AyLSh5cmlQIDd=8fp z1)Q1YM9*!+`}W_g6;Gq=DKvH+h5>S-qWpa7aNtRy>!^wfbZkDNWV}={Pt`}Zf9{|t zehyLL5r<(nU|hhTI(C*f+*crAPkFaT!05q%_K(W;3{$zKd}poE%~sX5=z#xi5c(bS zKL#NH3d5|hrn=GdU61QiX;Gb`Gc0)Mh*kq_FN4)G! zBTPIfBMvXY9TAJJ)Bz;h)+_CK1E$awb))Vw!nHwfi@RkxscYo0Sb-Rx@0G*0+G$Ol zi;d;3OX=wm!-}L&bSM(3f}>0>1@j>jb7}Mws2!(>df-p($d~5plv+L4o-ekGYl7cl zscep~jmnqL;oi-P0ZH%t2y(hq284{`W{*6rwvcgEZ;WL%6vGFaRR`_p0z;=)mtNITD|k zBny5vSpwWltpJItJ4RbikQ9cwAphH-t>OrgO)PMym+jR{Uoi;+SB#Bs)I zM*GJWs&vc^huLdELgubBK{-~8V)xMb0&nMcW!Y=NHMprGi;j9FN(@g15Z5-r-7B8u zF<)0pzsb;G>r!|2??6-XzG9c6{LHNz;!vY>qC<_sB?-hM43iurLzC1kD(}wMzud&B zD@$ z-9eVIv1EbW#$#VGHtO~gaSz)8CQembSm8cgVRDigSFz7IveyGFh+nxMp{8IVtaA$? zTmx7`(a*Fn4O{_-8Wd6v1H9EstmMj?B#eRNv%?^6r7YlIT>4gTYrxJ1jssx^o zxO-_$0l8w}8%61vC5WqEVLI6gZtqzGfS8TC326_I5quU34gF$}coqF#B!x0k#T{Mk z!Zshshb1Sqen%=2XGIO&N7oil*AnXM_W|9#u|qu#!=o#iz=2OIk=OR%(i4u^OTU36 zQ*cSfFxGIjtE>|YVWBYy8W#~E7P3!}k2*k4 za7{TTYrqzx7Yz%PEi(K-?`>&W5pBx}&_IbCsW%2Zyasd^nkp?1gC}lEz<@aH<;Fx4 zdGOFWNXOR_x)kR%{M2b94glNmVoFPDL?8)IKF8jFMNZ?2eKuUbkk$uJo%cry>%Jo? zn69_gd(|<(R6tg+B{gbfrGk4UdomzIfncmfmo>z&Q=YEAtK z82sUT(GOMw-%Ctge2ZgIEaZ4HK6Yz!;vS6YHn=U9H=5{Np8&1|Ip%L+CU39{@N&NL zJS_*NZtk7F;6gkN1Ps%4^r1IOM@KuxXfixjEz(x%L=fSAh=H-+4sCaBNTo^0-nD^( zh;!MGGu#i!VjGo1k$Bt}tqkp4cN4}I@ywQHq7V1HYETR&P z&Ao8WtZvH*45%%9gbWtR=BdQjUOdGNMSkWJsm2PJFJ72zCN<;Pz-3ZN>^HSNvc6KxNYrC%)P4n4&)<^$(efy-VUq`i4@ZYeWUC3Tr6nN{?b zLY}h%a>P^tNkR#U`2=pb0}#=lMH*2^AFr+QGB9iworMVWw7HPo381@yirdD{L>7u1 zD04^ZH9&#}=7HJc(}@lS$MC?@Di)DB>yROT;} zS0~Os4N|_p5Y7+J)(cIN;LJ+QWAX9#q}SydShC{!?+!!7Zz$qa(qkz$8!4x6%_1A% zF~pHh<-RO*Tk9rE1tVECP|&vqOFoAP27)AbhUhox0eB`5H4$elq?wCN)#fXT;%kQH-l&E~Vdt}YO}sC8 zgq3lqo(G&AtC42=E6AhK{cBJ8R^95`AJrP&{B#!}U)?@ir8BM6p_pY6LCQk z8u0d(E|lL@JWFBi`5`b;w0TIR(d3wv{@aP=MmdW+7u7JYQF?P zyZ4Yz<^`%hj@9K%dRw&>Dk^K=4YLc2XQ?$$ZHy^ z4zlY-iA~Yw#>%;eKLWyB-}F?q zmCglA;yJUgXi7LseHCE&T-o=Ss5t7Jxuz~~6Oo+>IBe^#x_ipo@T|Cvj3Ju*PdUa! z78t|%-;%7|{;&TKQ{#TSGSIFbBvM*!-@0=~ChvV#bcs_YS<$lLX0M z>MUSrzdK_0p#1A5K27&LevsQ?e!xdN(QQFK^HTMa$T^IFU*g}VF&*6jG2U7Q$UbD1DFM@G7WPru<3*3R1vi$gK03#$23a^9Fy|+*# zKTZhX1tMV+eq9tlW}QG3yvj}C#}pL6T6cFiKl3KfP5fwKg;%#1Ebznny+0}t_Dkh9 zKX*X^|L>USS3yA#KR4|46R5lUe+Tqu^0)sjFaKg}lNV%}8UBU*Go}Uq6cnVunZkkq zoHuqIDg&GV9+e1ylKCN{Yfy6Q7?N9uC&L30hM;;+1*1xUQY5H`q9rIrLZk|g00^p) zXnjNlQ(GIQf|;BQS7AOJepm%9tcaM5fCUv;zxPK4!hWgTR^hI#Rrwtg{i>}EQsIWZ zegaKS{_lYPO#b%2<>gSRBp?0r=`jLj){Jyr3J}x!(Kmus;d5XKz}BG``_~NFUAO0 zA4gkvIhh$KNlD`LK1Z8Ki7g8~-+C;jH>`X_$6c>dA8zB3H)-%{v8o1uOGy%81Lq4sTQCWVG?mcPEJ0R-4g zV7#bFwzThnDfqJAU(^^$*)|Z?dXJB6Sg|+vgrejPpF8UN#Lgxir-R7K%D0+#DoY7> zniX|VZD}?Y7g}>8gXhVSt9N|xR}X9nJk+pbVj|{*?%H}eOJVAWOYDWgZ_Rs1o$ZBl z+IclTD|Ay&6&ly(r#k8GC+J*Ht+nC)a80N4^Ap>tRiB)-dyQWWIU%{>-;+f9pC7$A zFif3Jl;7Zk-y3u7I=6RYXHEiNT$X3=s!N3|r$P5Ak3@Tm-!QIuW{)wg7z0d1{=V86JxqksM__PU z31_-@bN2Im&%xgcywleif6-TzS7x6Qlp7ur$!B#Fu`|9-&Stf4;D-G5?e(PHcfZV^8lv3}7}cXdu#HRw*gqOF|* zKAcUz$+9ZOU&yQb!xvUd-Dg~GytQP9OObk!XO6G;f4LQ=8eJ+B;6E?o{ z%&)?p?m0M_BYm^vo#?R0&2v=cb%D)hC30(mzU`8=!5tJ5N*#S&(90eY@bK`+6udc? zB#Z^VwI77q{jW`|nH02@z2L7_zyz_Fmfw|;gvH?{7J;&C1taYyDJMS_!gpnc9$WQq zw~svbU$SX_aH%?gyy0q)|89TPFKp~ui(s?5rkILEP^;DX6DJxU%$o`o*j&EM!QE<* z%&m3*R$pJa@4={n*-ZyZ<+auZkt2#9o#S|TKf5p5Y=s}6J&s!Qz`mdb6XStT!Qcj<$Un!Jt^i&BthU5nZWp%x!{XWtzQa~_ZsuP$TAPv=-@ zQM2s}Qi-Pb)kzfLdqurk1zRjw&K1ye*aB_rDd$@z$GAtG!!*oZ-l>Rh;Fz1P*((hX ziI0N$_P&jgm_2!3XXE7Kx~3+sm8$IveY+aq6?5) z&R#dw3+4dk+XSh%#(g~m4@%aKs?;{QYGeZ}*yjJbY_~f|SLFTtyZ)SHCTDlfOJl}of2qvr79 zwzR2w(nUM9da7D5A0p^|s0vb_>J@*WZ{4H>MZibnqrpTf+|82@s{N@z&^$Xw-_g3D z;`UAG0}B9wPFFv>?phQiZM(eYQY05lRZ5J{#tuBpO`O+NiSvNn&&=9jYcz@4)a%Jm zPj9q)!yG6t6y6s1I<>XY)hcBgm#(A0h=<8!zoplg*e)3+|vDd&EAZ7nxs zkr^JVuW=woH^=>zJ9Y0bSeWDBy~Gx_OT14pHC~H3chn-_;HACBU-j>=Q%U#$#pX_P zCG<$?ulNmgt>&XwjMq%L#?3eFm4>*5H2n< zDW{&pW5JFk_;Qzr3;+iS+SK0`$PpQ-^pHrH0>yt3gbccyx945BHw;BePG`cz(nr-q z<$1B-h`IKw3tEW{%@6w*Znr>lEzcrY%go9~EnH_cL>4kyctCAWpSiq*RZd$}fCD`e z#o3jvMtGiga;7hw zpXhnXpkEh%VnGctE3YlM7fZ@Qb~daM0}&ZSTE@V)8b$cL3TUd6$d}~CcBXP!M?%_C zZoTxatrf95z0a7*LGBOFPG%Ji&METY0e(x~7t(x;^0@Mv)EOxAgzCDYqTD7M3|zs4 zFQjd)2VYah=ukr3?R-35?PeJ22lY$XcfC<=!}ZjFO!~9;os=jjp}3oz2V-n&H%aON zJ2!N)il6q(Sbs}@XALJ?Xv|gH)HK&c|19M3K|KlJcMVxlv=i?+(=1Tw#xeOSy7jBE z`1X|#Xi!}ugLN^4<{2o5N=_$liX&#tR&qD)~aAcm#EAZ36FJ^3#L~@+u4-ie0gi`^X z1GnByaY_633DcGy(`-?}@N5=aP-{_5Z*hY>kORg!><)Hju?nx?0XM|qGJ!L^e!z5y z9~nKNRKh^1a>}Blk$iYemt?f|1~%}LN`!8eA#HpJMD95+`69jEuuAX77P3m_JjLmK zLU#coZ4OPyw4j&x$AWNRir0=WNqEVHxE5=|ewq>1ru2eGbs=-M^1P2&37T4oNcgR2 zNN_q5!VWw2GpAr)Iy!X)XATIQ>sAY? z&O~{|yN^F8Ug)GSl$$6 zx|1@F-$K}DY0mejrDohutkMbcc zc40Y6N?rqSl0(laX-EQe3s$^XnPHh`GzP&HJE}SaDa<%0ds$f+ZuDg*Cv{gNy6N+z z{CWFY4PmGZZ|l@erV@0FHd3J`%5q+8G3^)}TQWk0VW;l$BI0u(mV8U0MRnphG)oJp zU*xoHpKrCxn&mR%D?2AhgPCr1y>*F#u)$VRMv~M4)tZ{a0V?xQ%`kH-#i>?77CZwE z;`TcTJr*6cT>*~dOsN$-YF_9Zk>&)TSl}XVibAb}<}hN_WB3A&u*3gejP<)<9hv#nP2nN;2>{j$e~=|)^j1b z{4r=BWP#XWj64zBeBHb=WBO|RVCP2Frw}dh-oegDwQD><>FB>~1 zdmR}>N540Escx;^!F5sCungAXXVuRvcA^>_fxVvC^yDjBF%#-QEq)7;RfWH10XMwi zE!#$rnelapt&8(w3dEG`f1UC1-~h9$Kpf2Ly{ujWeY5Sae);g$8&Viqg<)sf!ma);g$0 z_WH_3$VI&nIa%<7Qgry+Rpg`c!=E>Y@aofF%$!E8qvE5Wz=Jjc+9i_sdW_%E?KD!G z15&s-dStBk^sP4b2B(}wbTptJ?PvZ$N4O{{@l zADlcP!*u4D43o7FJ9%H?+=Gn}MhN{};B|tAg;D)mx-wh|c)bkt8oONjYN!=Z^)=#C zHGm8qP5>{%6j&*WQE1si4=h8+mX&mM=USir97II57Yyi88{1SN8fZL7GC{PcMz$mV ziYGx<+vrDlr35Ydf>_hr6RW|8_hBjTH`Wx)ps0rpITB^hE<6J8_KiUL(XHj@h+F%D-(J}qDzvi<$9s(W?Jaqlg4SRsRCafyY$rKO~%EjF~ zT79#_3<3ufTL*|8)0V^wCVI)(xCXZm9#98>1($Z8;cLc7AVK>Wv)$+gJ&epKIy-`9 zZy`D4WIVQ*Ay-W=UP1$-bfEV+5OhYMGUCNdm}|4BaGBtiT8!-5PJ$)BO8p-c^orQy zmT9|JKnp`&rF~Kqe3}FX9OYb5pV8GFJ+hvJuhhjz-oJPqZbcf#khmNe`oXT&x_I`k z)`?WCz#KdI-sn9d!$Ef4h1y`GVJnKc^ew7DA0pNo5r^%$fO-4wf({T9@XPIV-ltj<8~cln63{)$^?~OWP9ua@7fk{9U>4Ypb=f| zJMfeU0uG$tX6vFlT@8W7^H$TcX|yca3<>vmtm*nSXrSnN2;!pC1|Mw%Tpt+=gZ&ni zOWBGgqp>O#6&`s-!D_qp4={2Plts1Au1c^I-aT(!*F{;(`emC%dZQtb>@N#*8Klm)(Whd!pD>g;uSk4LM}iex(R`_Yx)I1bAgE^hrt5Ot!33|rv-Iooyr z6hjdz<#7d17{Xd+VY}Tk;^a|wF!>I?dxk`qcR6Ww=+c*0%M57sqO9|Ts0q zJK#^4|DijLe}d$k{S1yrj=2R+g%FvGA6ok})M8+-+1bpH?ckD&VB~fbp+ow!tcOFO zKJzvNT0L1r=Cdp{hXr+ZkCba}YHo+jO~+)J>k#LvRsunrc2wcw60D>Gfu`8(~}& zH($<9hR3rVd2Bm&Uq$=h(k2J&+71-fZDqLRxC6TyS-YYi6z4IckhBx-zT+yHy~D?z-#!sWUYySUiePw%Vfu zY6ma$p$aJW?P0bz1wE*chlbS>l?djFu#8V`Tp(mfK{x6jAy>w(f`1WQ-@4H$P4^juHdD zn&_x{t+zL#Sh~26=8iG|4o1E;Jnne@jwJ+=*@qo>YoN_>@EsBSz*|;)$OrUHLU8JJ zGrNPX4@k;Y|8|`*6dNio#?w&9CM+bB76%Tddk$WUP?<^|qev7@EM`(jB~DX$YLvM* zTkWlGHcN;a^R#66Snw}&&d${s6m9~5N3CvNOl&H^K#kUbV$t3G{IXCm z^>e4xIVHN3?=A9+Hsb(clJJB%{0S`7DSV|vIjP~gOUR%na^X-RM#THdQF*v3}{id9ihvonzT%%Ik?%+bdAJs-J2)J0~=@waD;}2kjsH71_m1bV!SM= z4?mB>kaq(vAtBJRpQ0InNY@kTgXQBMI1D;osep5Rm1lzY=n$_vd=&;Eezt?NvChb2 zEHCF}=%b*P^7KPJpIiQ?#68w8&sLa<%1R#~c>gz*f2xcxy>s|$>WYUKIJ zFC^ZheGL)QxaH0!BlnT-tS)-ao+m|wPur!4tUX#=w3s1y zN)-?!2IQH6gCHzfXPykAMgePA)y4Cy_}Upf-0?}T_w1U1FdXhNw`O|*qVZdHG?#})Z% zJ2X@%KM5-ic#iosEN<{K{9KeTe5?p4YGsO_7ihD z3*Bl(e211Myc(0W#fFk4ecYx2@o!B@TTVdc@OfcS3| z&Vb=*MccfIMc4CFQytKqLe}_u1~uf>Us<2K|LpiVsG03Wa4giYOCy{ctFq?RHWv29 zUS2AKZU*Ht@Z8qI4;DjBsm71CGBk}mA9fkBoKq9iebeO(yF!xobmB}5eZebqolf3s zQz*K^bcR5yv!6A~j+Z^BckfPR(+uKdtOt)t3^ncidr!f8-mo{+(e8lgNl|)NaAj~> zr5TPPUi0BwsS77n#be7~+0%~L%AgvKw<@GkW>Zrpdo;~bj+2(Xf_-{E;$$F@D3t)M z84s=bX=u()*;5QXJsGC>O@Cp8^e~a1z-tTRC6t-nUg+qvg{HcN(&ox>s2L7jgu2lh z1)mpkFj! z%^-Y$tNQKD9=Lb;p|*tgAM^T>TZ6rPQ7HOQwoaD(bsn+}r%b*g7$Kz+CUI8xi>v)7 z{YB#^ooZ{yh5tTOp;Y7N#EinYCH$;g-b$T({gCRyCz1PAoD}x<>gTBO($qVJ?Xe6G z$a5mU0SaozsdiQ6)C%|G3b(y&&QNy$8l<4CfT=)tT&nbR4W53j^{06rTGFli=Tzcg9Fy-WP(S0Xr>Ep)KPfV!kONxx`I5O7I zO^UBstk^8V(LCE0>twPKd_|`l-p7CNJ!u_Z)1Xs_W6#V)X8n zJbhan+K}f3y|xBy@RJ%%3b&jfsk?tvYoGVB#gieBEKs8D9$C|vcD8Vz4ntkR#Ljkl z!x%{~?%JDC#(IG6rKTYj+7wkxI>yoA&BI824>OpI0^lL>L^`n}K%Z82@yx0S!F5srP|4x)TtnY{6M5KPR$V{g^uS7xZw&}|h?Ftkf|rRXQM*)EutMn~P| zlU9`C8_Eq%)ixT_rDR=Dx!u4{u&mkW3v9uq~jEjCb0#7mU@6dwoB=UPz^b z<~El#r>mdw$x0E?#N;S9o%z!(RrH zBU+<_;oc^FX!dJ_^wf|&ZFlIOO*=yxOYRpX!?W!z2~~!1E8nT-e?EP18!gAUxk&mI zeNfgGERSs(|02&uVa$kBkvrxZICE5g@wfZVMz1b=>~vmoDviP{6Y^W3(33m(Ccnb5 zNNS6##)Sr*v0x|SsnQai%Y$Ku(9w$Ji4U-*DaY@{cd9rc_el#2i~JdC^u9YW?pnZ= z?nZ|fA8}dG)K@ly$NH-d3xpf+y&la`8!l1@-nF=sCKiR8z*f#P?MZ`_J90##t5_jQF>7-xdi9_|&80366q!kOKB{<@NwQju5) z7pPIIQG*&3*pe4dvA89DxQz9@0LQcSH{rd%v~oT+%KW{Mlm55_Q2 zr}^`@%OlF!;ljYR`w_*Ikw9LEaBRXOWY|&1PLRl}q=M^O=wD!k%J9n`^#`q)3{Tg+ zqvZva(eEIsWn%a8!f**v!@it9dg`(pc%9~Q@rKiyTDtB&EYGZKl1Xe_de@Bk&IzXq z4<|-AeQoI#s5szANpDUU>(b$rV;PeMs3rd^yiMx-nD>F8)V=Jzu|Jj^;z41+&ySD@ zi+HsD7Y9OSK>te*fM+28lSTezy#He!;J;+~r}_O$*Mo0IcD3JWLgt^vEXLn4 zKAHwIEGT2>${t`IJFIP@Fjn9N!zg99xrxK4`vt+*%PxQTY?C(R_#@1$*yXQDk>fMD z_W6ael;y=c&%R9f#Mjk@byso!?qUz$E%JLJv1#yVZSr~j3-Qhe>Vq|g${*5=D%3-w zT$dH4_`*?>L-qWRriXop`^w-6X54$-5w(hoF}9i}?#>iyk#z9y)Qs;IB|R~*OgwK| zKK;&@ab$Kuc6do0IsW=vq=d%cctS?`Ca=1~EBX3&7;W?zbwp(WS0;Xw(#8jCG0PS3 z@9g(~1aj~nlf}KWcWXe++5k@ zJn}d;_WSQ=5QfDAp31H&8@S`6!lO0ZbCV4f5;#TBi4g`crX!5w_777>r?G73vzSAu zof9BHS18{qcTG{x%qm;-*SH{#Vc_sL8fYWWCyASCs22*956Z4?5`{P5(4(YiZ=R?` z7^jwO^c*L(xZqzIBF)Hop#wM0$dMIy1U3jbA&g_gf%fO{M8Yp*S6geW%% zuR%ovip=La=DOiNF5G*)CFe=ydVq&ee0ip5aq#NcC{lXvr{dQn21T>)uIbl{h!<=J zja}Gxc^CH9bP$#sW@;r!Cdo(0SyPBBbFc)1gx|9yrfvIm$k2wDaC#;bxH5h#MpxK% zWp12!t|4C@D4uvQ?d(i=9*sE9%7Z4d{p?>0$2IY_4rZqy6DQzPgGcCGR~XoJx>4=E zRl84gC_C-Pkdwcu!*1oLqI=d8xsMX^`S%J)P^%5V2%S{WWEHu5RLL`WOJus|KnC zaa4yhefHP020CFtsGi5+At67J;Z?W2pGumd=6VZnI{V>yKIzqb`EWj>S;1U=1BV(c zebdhAd)IHS)F56+i}nVnT^+ky3E*QywkCk(NvxSs4{|xe$gnhS1lx{y4G(Fyz`PdY zEDcaO>|5t)8Exsx0?+`IlxJ4i#lXHH0?JpyUy;LK(Yp_(+M2Nf0;*z^tp&kepgy9Z zd6{N;s^Hj-aaXTWA#QXTlEPuq;Rei+pqqmOGgV)?y`7)P^=O&Bn3}nX8V6bM`H8~? zlkj#4rc`Xu^jM&`cRZTBfd^g25Ke_$aVt4`WYp2yDL9{Fb7Ew%IqV^`wv5q}KXN`M z%rCRwa(sIMR}B?<(f1>D__SNXlMNBZbxYhRogX0vn}CKGl((++XvXfQSe7aM_W(nN z>z&q8I9G_~!Jdk02=3<%f!Q#q;(ik&tS=vz_V=0WxGZZ2Z~Re;0?NiCapQt5MtM!q z*8)sz7c!TVVZ{b2(^Z|}$*b|BOKQefdtgQ!@^oHI3{zRV24ASI1esiu*a`our=9ND z_46Z#mWJwrqxlZarMP!*_o_#@J80ic<48A7Ny2mz z%>IPCeZlWsbU`BsoiU^M3U{U_Cfhj$V3I21H#T^wGBg&u6JDx}sWsSNmHtXt%zu`m zc9?_qt?N-q9o(=aGUP_M|717e9(0Wu06%#24?|B;=HTZqaRbT@pz?+v)Y<%FKQaU1 zFC_k{A8I21Zs@=C`!88$^dm5M`3s4^9}jN{e=Q&3`Ag`)L%x0u9ca!Le&8dypF;<# z7QcrMIP>Mufg0+M&_RY>ehD3@xPJ~Es9gUD9cbfULI+CXN9Z8H@?Sy+I`Ge-16|xd zhYmbs^XJe(hJ^kcI?$#2C3K*?{WWwzQhyUVP&4~W=s+p{ZRi-->m}V+;{~q|3F8YS zQk$@Yxv{YP8F`7yj47lM@vakMMG4YL9&>9RXn1#EmLk{f-Q*J8{z{klxZPmSbaU#y zz0Uku-Z&Me){LUC^{4j8+}{7}%F#HU{zfMDlb4Tr+~I!eWBhIZo9X&HrI$DDeWn>w zSkT%!tnb4Y8R5wBStsOc!jlutH_;oRERtZj`F zB}?5Fp+6|aF-F)qo!h6z3GTiu%w#cFXnCB8n^Hg&@}pXGWX4pX~&nDB|ZU*kL~c#}KmBYDt0_9K6eL zXK~CiDHD{n;EB&3bck9OozL?oW8j@5S|bQ4pYGYJL%)iO>mr;M*}M&kE2afwcb{!j ze%K0#6aR)4D4%Uh)IrkYiO(P)?ses7dNe;PIQ%UPMn3RB{O~PUvlL{3i8esEF09|C z#tWu5LdW-eb!!Ff&7hBX`){|Z!r*Nih_}Z94iS7;L3T}qfAKV6O7wi!QuX42y zF9aZtOgdf1Sz82Lh(yT%!GY7VUXv*lJJc%B_p>}&g5bNUE&Q(;*SG*>ZI;g|>mgL; z84YQXrq%a9VR`)&N{+1V1imG5gL-)8gCDKQhlcb>Oz{OxMRwHJ2tEn7jZp;yeGA?T zy}`YV4z8EEVpZqTwj90;uL3|E##pyRO&+|1{}l=P(Mm!ZF3c=UVNAN}-+G=~2SpzD zxd9O!d0|W8lPF~F8c6(-aTg-$nw16Qd{5juk9!>oy)-$=9#=fohUlyXYg+=)|6vJ8 z?rRAU?by0?VNU~xe%`^g;V-s9=z;ba`uMEeEAyTdVPKydig!dciO^_IMc!M@u3-kj zXx@_Su4wr&=HWD};!1QLLl#iw))}x86c|;jfp6LFitjd}k2J{c!idWdFx?%s7S;eE z=wZSsHG5F|amuR=1>mh3=p?=8+zQ|&`p^tKXrYsaKry^%fIF%3Y$R6k_l9`;Q6zCC zutxQf+?S0|^x*&Wh+-Xh+=`fp{lGdlib(K+X6V%b_-KWY2LGUyg-{%UZnog2!(XyS zV5_9^$({7w5M3Cor+suRtA~_}d7>Zj=~PeV{VtNT=;ZyTI;a$=mk+!KN4l&fM5=b7 zP|l4&!HCN{0d$GCG~PN2A)tReMRqyCtHNhe_a48;LFg_ zMz?tr&Ju-~AWWvh2HN$Pxu5hY?gzvVi1aP{LLu7*J3U5G4#q79`G~ zY>;RmNk&v8NK`;Tf+CV6O3p~mIZb>$?sM+*y?gHG{sYV})2mmn?&_|pu2sFd>q`;x zZs)x*ODgF1h>V*ZF)hBC1f|D2d$wHq71u&hciD~!jIM&0xeAn{7<-~ciZWJOdBJ36 z4|+?#vrH~XY2o~Giio((Jo3*gD;nW3iIeFsv*iR?5YwBlZFjtD1HW1-v;St7>y|~b z1zqjL(C3LOfK*f!dNGg=iXNCk^BAkSfG@4k_*+4;hSK{yItjil*5Or8jq-=yHpPU} z$sAC=Hv1_FKQL0l>ZCiO3Z0Igr_qH|R4)>};LZS5cvoAT_OPO48!P{;Fg}3Ldc2`LC z0|{?HH%^D-J{362ss7(!aW41s+|z89&cH6UZ*STjVN`Zb-< z)bU%G-;v8gl9VHAx4y8?z@+^MA4WKV#T}|(lPOY9*B%gNtelew;A%J@_Q`V^1(bd^Q@)BFY? zk8M(RZ`H<_@u@k)rrQ9QQsxA_!^$e}IKN3*8xXS?F2aHuBOc4Y5E?NjBZSroft~IW z2#`0?Ut_n-iEY2zlyM921$#Z`LhG$?ia^cWJL%R!5o%QB@v|b{FU-V?I zWM7!?`bsd^pO!46SlU=|K5NwbP0?I{`QiGa^G@?s z80C}#WSON)8nGbP--m%Hr-ZqcHXygoF=K7z;2ayo-CRwM%u73~_}NZrlhRLmJn40; z98_(0y0SwS>xH=6kh3vPX@wIS8Ff+1!d;;{MH zc-O}&o8Z=P9~j_To)K>|YaL%5o{v8+%`TVg5_P?gtmoi?{l&)f;BK@fCM`}UnZr;V z=dh1;$us$Y2jd@47Kb{IwrC88_O{B|y;?YT;(0*deZT@#^1>-^+{jr@a%bi-?0UCR zZKdk8QEg7$t4U5Us0ag@^_sGx?SNYv7QHj!yDq!8SMCDxpgTWKlAg9QVMX<&f6Qus zQz!$@IMi_DyTiTpvCv*GrHH_$rLWsAY6y_+`ok4Wrq&Uf9kPO%syA%I127HiSa8G7 zXL#(Ik5y^XEMAsVj^(@7gMdjbmCkIb!b+g&sCA2;CzDqQPmSqNexnQ>r&w_0UzCk# z+!h=I-xP;@hAko{PeBdd{Z&I%5BFO~p8Tq~B?xH#zBYlujo=q+KB+sUsq!_16W3l> zs!}8wyDmZUg6eC)acauFUlx>@LzH%`1^KVq=$mQ6%>YnVcCAymA>cvw3}Tmui|pf zvBTGaB%}vYk&lpHQ6Z8k%4a!P1CHB(#6N8o^8Wi_@DJ5vBKyKPl2*)?j}S>{z}X&< z$!)KIw-ip98wM;a;)Y}0Wq*46u3RQxmZDH>L}Ed=LJWiw*Q`rU2U1uKlPzHv&m zje;Z0E~1YsUt;_9o`X1rOi|t)FO0Rr02p6h`wI7xt6TWo88Dbo|Ef?a^zp_(=?lZR zTsC!PAPYM=oQ*zNkC}t)qUXJ4ZiESsegOM=pb$P1rn;=U_&FatX_Cj)Hc+mrQGfZsEZjszwR73Uu>+=H|}u>&Lp{(`C-4!0=oC7*1$B$yowu z|K9x8D_=tQGNC*c)cuhl0@1&tFhIU~T0_6!#`!Ku9VHsXcTx;?DvrXon` z*)|NH`c90@yB7{h(aP~5Sa9M%MJcTBp3cVHozJKu!J)P z+T(!OWD_(lp;@rzeDi_hxZ@UH zkqN|Vf^uM(=b4Jl*2D@xE*1f*G1?OWXI();3`7D&P!#(J21QSTU;&qSO*Fr z=&K!hZ%rjI$<75g!D(G49|P38WR5~R15ka0ABKFfyhfvpmmcE&URzr)_Ck9IJY%VJ zpVUJl3F}>F-T_4BgQ9C6g}|BJa#m(S&0})j47J2Oum#u;8q4I1XG`v63BjRrlmj6T z2U$UAM+04kI00CX>!a11U>p0x9EnO}47=$ORRNtGcsG%^$ zh*1xH<>8ItREq2e>*1xH<>8ItREq2e>*1 zxH^BL4;|p@9N_94JHXZXy*~$dzLcem|K$&?ZJbAYP@9pLI5;Ob-^ z;OZRU>Kx$e9N_94;OZRU>Kx$e9N_8zN9oT`a^Oe~liGg|Ne(7lM7s>eBZV8E8zGdhbxJm zKjpKnyWYe_PG$&&pFG!fw$%O?%@rDUk-5xHE$JgJCh|POk4_Hz`n3H(g#-qcZ+@xT zO&*iBKT~U_^0{FbGg`2j{EFLUkr}&R?Hz*^Vy%ch2EK*? z?}VB#%1aujX#2HN>8;P(FK-7VOKs^x-F%S~;wj?38$xzzs_G{Mc_Q*J-KZW#pB1I9 z^=KexxO+Rt)g!%pbIzRp>jk&@^L(GTeVWg<+%%;Z@A@)tX8W*dC038-{#}UEtIM@R0Mbz;q?)$NB6(vV!zi|%9YZrGKIvIjrZfn_(uQa#$^!o7y zN&k7LON?>qkf5F8$cR&eLomK$V1a`d1ZY1+Zd$ya%CDxCkYxS8Qx zbC_Ss(tgi2b6pzPG`t(g3%iuJ<~8bajVo?0%QX>M#((wET~mYgl7i}cjjVdAC%AK% zmv1io44Qv2y6@#E)VXhE%EaZLJ7?`*PHRN4H1)VGHx@E|i|a|FXTiYU_`IHNTk290 z)X0V3nbGLa&1W5E9$4{nbrH7UR~y}g_Ha@6@x_fg=0`r|rj~n628YMDC9C#+fL`+M z*w#FdOsAW~s!?;Q3S>!sNfw%$@UWCU_N;&5an=&2RdKC5adn>1VQYA#x2vI2U7~wi z8Qa7WprWyrG%;UY?&kpQdyd&vZ;Xc`=L{D&mcQUPm%k{+`yG-YUK`^Zc4)v7k(kEtdvdtoh+IX5iM~5n4Uuq5cQ&Htu){!lo1m z@+X+my|+dN1#)&*+TY{qY7J<5yZc1r4bmJu)oBXX=-$c{6_=uann@!r%p%vKvxitu zaMMgVj}G?r&|7Y(`!Bbo(L-XpV&icl*!gcA`>&>6W510E<)|L|WKX?z58wQxjdhxj z=F;x+kRh!2_zqfJEbZ3zvWMp@R_T?X7qqC+vT!8Q7BXA@s4LGA)3=07Z3}y%o=AUD zovGiw1rkf$E!(4etEe{8=gfrl6t%bG6RSz^S5I+8jGO9H#lECd^RLrPpQpnhJ7w66 zvyT>LXK~9b!0kdNG_IjR?`Pk^5%cs=RyUhvPCKi9jkV(k&cKURH(WNip+-!Jb~NfO zyJc~^=+D)CU&C-z$Z^D37E^gt38drhdB3vB$> zI>7~BJbx3GqX%$5-#$Oir$BYhXmmFu2Fi`o6&z$UbjC=eKHQpUDqs`}@H;z79Dl9k zw?u3z$TkQqyt^EgmynqiJexLhRKst5$epxr8!y!ms6mhdDXnF<8lSi503eEJ zuPJ(w5_;d?E*WoUZ-aFRh4qkb`*FfhLJuGQ#HIdDi3M*|?MYCAU12G!O^Dx9BxUL8 z-&v@vPC7W^w9)I;&<4Q-g2m-n-AE)g>e>e#i*@(T~;p_FD({WOSVcN7Rw`aACnFBIg?0*AgTgAFee z$zZd~f1=?xS#0S32^<5~sE(!25Y+1VDd3f`QI=K6qA1i|Shvjqy$Tk{PgD4_037kp zo&TY0pxQq=`XBoK|7D@t-xxb%_0lZnn`~W))F`37waE|a`x5zS&>Yojz}jU)WH_J( zhXQ8&R||crzlZDeDLGz=dtY#mR6)PN`uv&Ou!KgJHf-+9_zK=`a-J??#R47cDc4f(i``~&{t%+zuVWd>Hco`DSBwNm}%KZtJm^@G-YXWquCBY`fO;nVBR1& z*66Vj^EnunH|Q1ZD?kt8%@R6SF@LOpr+HOKcQl2+R|D6o@7k_zk>K7>PAWPd{eV6@ z__sRQWJP7HjGHOYhbz}uVd~+QBT@JBB#I*w;ir4|MU+ztV0R_;Va72f{4~L95)hm5 zFzuzUN1&W?dKqOJHpoanPrmD8wm^E=B>7`PuW(8SyOFx~m|>VSKkcK1a+fVFw`Ei8 z)kLb()7{%4fiI35pjoq`9Q*^&)Xz-mgDxhlY|d|?vEX<~_n~nzmE~ve_r_}6>Bi~2 zo8*l4F(bC)dcV>=120NI^3FN**YxCmLRnwuFgg~7Hl#0uDYrz0!}0QmV|EDSdPZFX zruNGmGCV)e87)wACw&l`GrGwE8QuASe|QGZ7eoJeBM#!z@cA4tR6Oo29DzP9W(^Gw zmpLE87W7_^I?70s$k<%6r#Mh%z2lv3dh%wTbg!dXjWD_RRZqISEg9pzvsN>j+?N!1 z{Y|b;Bs^Wsi`;}1*z{8{$sI^8JV8I5fH&Npml|b$`6l!-6tlO2X;HvBNR_l1m@s19 z+K97ibZl)jDEnPvB}VxMJk%2`YFywf7A1G7nLs^%G~7W}MN z?^A1}(#;0%&`7D7i|#v3^t>V92SYj#ZT(sm1^L?@7*>069gC#20P;_f)z3VPKI*k_ za~1cUsl-UuR8Dxu&o=Vcy-sWEqVe6w66X%Xcb$e^WHBXca#A$I=Jv;>r*-`;BDJd! zcF(0!l)Wy|*N&_b(gf9!6?=+7l7Yh1m*~cwSnyn4vld;@Co6qKljn4e*~FPIXB@Yu z83V!-Hl=Zhj+FP#Y9-U?S@ci7V9WV3T${oevi#%K&=c1Q*b9lA@17f!qYIB49Q8+M ztCEW&pkKjW%ualf5<`o)FJIetER@{*#?JIIn%!oVFI{o+y7MCjs^Ht;GTcsaPF3P= zsYojP5r(PljzL{M{L)$WY3zIs&drNdc?SN{-rU+(WN_dm?_-xm-T-Z$g8ge7JdMyN zL2A{qm3CH^WjRT#!;r76O2E`NlHg~#bj6KMl)6gFVNFzzYQUTM{&T5Qd~?vJ%uzcg zOlSFLLpGz5D2va9*41w-FY-ZhkH=efVw(;JD!`jn8l+VM*5gKRdl9M`v7hGUt$dGT zN-e)-VKr-pxK;=FFFIJaxITLq17lN65&4UEKQKclOS{ohDOPCQzPIbWw_@qWMFB|G zccL(*CRb)&zF4A^o*Flk<-{&3qL;Kk`JnZ-*bku{2>kvFlhay8>A-h5@Es0( zhXdc?z;`(C9VlMKf$wnOI~@292fo9B?{MHd{4c(PA_UQ%`tRQzC?*8L{{HSzhqP*e zAey;Rg zfzVyb#3daHeN=KUL@&@~N<=x(S!lBt*)oO%N7~5TTd+yAUjeCDJ2Tdty+ArZA zp08dk40t|^UyjW>V|OWLc1UfwpY3RDSA9fVM6hPzGkQpc81OV_CgYeK6PZ7O^GLR^ z^1|aQGCXH#ASL9n7MkqmkFn!RE&H=Lsl>*JwR;+79Uj{+Wa~@|#IL3%2sztP&uyg5 zn}@~ayFBV*tQ0-Ne@Q$~dg%o3T!k$WMP9q|z@Dkiu&`e-lb7f*xY{DLz;2o1ELQO~ zp2tLay_x&``2n-tOWvMNxLRn<$6{P-m3?W@Z31DzO5L_1C)8#rN1YQ@$XfD=KSXMn zB{S>+6`L*o?1Tt9WM51mQbbpy-1tG^y`yaLJ}v=?-A);E_zE{DY0FhvmOAOJLCUbi zm0^iPrRH!)-@e}GFNtn(kGAztM~;pYJfvyFk)!;u>o#Pe!ppe&%jxepX|{p~STA(& zTI3E|Md5pVF?DC$+ybrJ)&+gDjz>YUXd2OnLDO)Bw8d%t<5bXJgWo5L+ec!DG+%1* z!;9LF+LDpBWNVoxjp*^uEj+JSuRh76^;1NCV7(AK>jnZwOxZk2o#``jD-7zt@yt!? zb{{)G)W4hmEii56;j__zWf~t`Xu~gjQVqc%z8V+hG`6pIn{AGJPOdAN+{(3jwRCx} zqMfT{cP>^@<1k%doDM;?pypH4{`DO>{H4Tik}yMc(p+^a3>#yYJ7CcczMTEBUWf45 zf>2#5KBNITStfQ>pW}HPPoh%S=&@0$E>L1;r=E3N;AzyuwpSB32bAfAv1`d7-2a$1 zHIxNM{JSD-VXmm7pOlJYal^Y#t7vs3m%!P9N%!B$Yp_2G{?~%@|GVfqu}US6n#QcG zE}G_w%}+Y0Ks(r}fYg$(7s%>Ns=dxr5(3$r=v0Lj30+8?NooPj6=6X|__3M7j z&R{@Xu=T_Rp|qqUB31Z__-y@;FVv9NU}99q{zYMs0Ya--B@V*g^^$-=O)DmF=*38p z$1QJtC*Se@=lM|OElWa*WTDz(k2vF&+^v> z$^3mS!AK574s@cYzb`@$H6GQ4@~HYX(X?4q_b?gk&$3nfGP^H|{1y3?{}(&9v&R4t zPo>SRWyWA)MuV8V8%Ag{f2kd@yZRkj0Ar*50vDxv0>48r)Lz`&PSU)|Wyvc$fU)}N zWO?c(Q*1_oRZ2{Iz)sQjcJI)aIHoCh_HDePj*soKzH;)d#+iyUe)DxVvy+Kwao?Sf zC{9r&)knUvC%Mv|)Qfd;oqSi8&!O4e=`BGse~4IQxTwx<6HTrAQ$`}5#nd8SpHSKg z>qW~ZlO>ZDXX=DcvT~GeO!XCzlwBtmN?n$=(po;a{}gp&9h*+cn6+%~^$3_BoNA2f zv?{Z>lA4mHb^Nb)n~U&tP6h*H*Mo48FPnZzf!@(6YsxHDAFuY0l`st(qy*I1=3G8} z;*Oi1deX9U{F(qa)|jnr`=}D{XYF8vuQLp%HlHA+^)DUWF*~+2i*J_O)GU36&k=SH zbM~DGoY~ol?LE(@>c<=j@x{}G3G*G^ctWqMG8tZ4P!sPm#s;A`{A#MMv<5=_HWcnYK3kyB-~Sj2YbzA|5DN=@ zjO_o^me9_&mNrpCMRgP=LNyskj$qJiLD!GhPo#Q|BM&LaYiG;5tk;&vkOaK8e zB7*g0!b_?&K_ySv3iNCmLn0}+qjYP({0dG;tbc6qipQ4r%wpyT~R5*nb^g4h4v`! zy|(Ozm93~BYq7kueb@skpH?fv1sn33RJ&>%(&wNX|1h-~@67Ze>!a`ezOdtr^&1 z{zqL(N6QrO8qY$XG4#tSDH+8*UZPdOy4KYyV_m5@ROM#jh{*}agv54d7v<5)&4VnK zDN@2#*o>d#c>-I=?G!nV@~oAhTThtqCVZLX626$jNFtIq*&C;+w|lcw$GN#h-DKQt X)Q~QFuuE_#5FK;)3{v~TpIrPua@><; literal 0 HcmV?d00001 diff --git a/ahakey-desktop/src-tauri/icons/icon.ico b/ahakey-desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ce64ee0e13dbf559ba4f026fc4a63b0f903cbb88 GIT binary patch literal 7054 zcmbtZ2~-o=wyiKLD&PbH5}Kh=kU;@)fF#-~t%$+bD6JsDc0h;%;t+-;6$C{=zzLBt zsBMGV5X1&VhJcF5AcF`fLlS0CLqY;1A(hmxgzmq3z5o7yYrS8KRdw#Ur|!Myo^$rT z6#xJQXaJ_B0CJfE7gPY?BqHhR&bRNO0H7Dq85qpBS0nN{H2`pMm~SsYpBP|>q^_m4aNq3lEqw2*g!b5T*#0HET!d&f5K z2o$$QHP_n%1)y*T(4V5KRL?Qae@iJR#lwKmQe5MSHPP5T%JwHTbe(@+fjXfc)g{*F!Ctee5 zB*|yda%UP4LgxBVCLVi&b>RKOUz++Py`Z=|ice(DP93m=8)suB_7)Rek?Z__E9@0i zC83+a^}Ra&Wh2o(LuI4PYg4{HuZdIaH~hn?U_4s<5|85aV+Iq&E)N7e{=0bA8|JcF z=BwE*4K%+nv=Mp2+qC2>SR)1g@jB!(m!Tu9FBc+0dVP(^H5JusktbxM3u#8Cl|`+_ z@cUWUNs&*x2M95ZpuIJG>2KqAi(?qlW@0nCOm~{@!2BTOPe% z!rM8*)K3L#Z|7XpgjeFDdRA9zfPMpF7HpNamGSzEzWX+*d4BQA| zAf7r}28i#|CAK-e@>)IioGP=1xil5LEQ>X-p3$Vnz&LOD>YV96wu(vHyQ|;YWWbm> z4oMtpCeGHK3zBDQ$<8-74pv;wsRh^H4^)WiVagcb&&=7#pI-HF{z7CccN!Ho777m# zy8l7gHNFb6S{Ah{X|88JHn@TG+0ZIkPKOTV4Jj`#I!2uzAXi;LwQB2bZW@dYg}LmC zR2#8-Oz<5&TI`KFB%OfWJNNA<+#Yc0UnHF(t#y@2hZe#|0RTn#&y!BYPt?PJdYr(! zNWz)v&YelXjODQ9L+q{#3|S+CdzV?qjM-DU@AFH^6`qDkGc)E}eB3}u1j90O3R#yfJj-j4vEU=DxOi5$TdQX6 zxyW9Z$O}2WP0V5Q&1cD(Wt7`KW97Q8X?O{rG@vfIqXX*aXs3c`>(&t=b5t{88wMs) zX=B^@e$-JP_{P|*6)01)^XnT5q|WF>+Ku;gN9+{(out%1<9f2bB>At{P5!C_sn`Rl zW5?XC_mQY+Y}sw*4}+KQFQnG<54)oAI)TXzUFs2elv8^FUWvH^b?*VZaTCDEBquvHyXfIlt!XaGa z5cuKtk{_!jiI@b?{E~icQ`N9}WO*9dLe$IZ;j~f*j3T%`k!$7ZYW6ke=%{5#p}S(G zn(lzB;J(YVs*+lT(jKVQ=BpA!Yb%?4tCqmGl(nx}J^51vDjynqAfP8Uf_B#SH5HUM z`e#KH{fOZoLI*EP6m^RDC%>XkYt-idn%7^;9G;ABeGZ1bL5dd%Cs>O-6#(Em5k%tS z7}9<88bkxDAdq6^#$BR>gt1b@K9SbQd_{ozZ%cfTx*oNe{3UsczRho8EnZ09!SC4i zCeYk&3hn75A z4QOFreqk|Xo)9q>O% zsdz&g@3O4tmVeJYwjb;TZR_yMpcXf#tiDW8soOeI%aHZdrnO=7jWVYxK=I~&gY*@u7~bs3 z%Gsxt%hBSH(6`EzY(^+6g*@&FY2Fv01ESB>%1X;NMr=cF;&h(aSn(`HLpznM09skY zmrW#XnV(oa0B6L1P!H0~6)=rznI9G-)2p&(Jj(}qeuCT;cO~Ds_(dmxme%kd_ViA4 zoxw6u2^UfQd?l z`|}~Iaw$vL$3rSuNRLC}Nte!GN2yrRSFjmedcL1n`Jv#g&LEh_T=1G8WZn%)qOnK8 zwfE^u^kq(iOX1$D9?PjpWS*Rj4arhvyob$}62e<~Py=4G#&u)0q1Z?+5@$x9COgw2 zelsFS4-ldnC(72>i|Kp)p6n+`TiEi~ObxA^38hfDpJ0T%F*cWJI8(xdxLAl-n9B#G zWh`oIdOT(@-e|~hYC}yHyx@)HHTy$%l60zNWgzch&f=+gmJBdtoPoMHnyU77U;z`= zZ#~_yoGMkYrEjD^WWFm#^HIsH#fEn;RiraMLQNjiB^tM-(i3Dm=&kkj3nmm`DZ_IhB|)uitE5d+O5{=|XN_RypL88ETg z!(KRErhFqy(R;!~wsQ25gxOd*y&;8~82FICS2>IGXn};uds(2#K0nA`MUroz0fo_A zA8L^=CL4D59{f%O&7#?cz;WtEn2p?M^NaYLY$t%*9-Pj~e_9$vl+zEFbowezuJGES zvLypU5Z01hY3QE-=Pj~dfoy_NWuFJ;78|;0KjZ=qH$^BY4E9X^syr*OISQSkxC3C$ z)ow~2o}}jYZU~jXOPf->t6U7cLk(7?C~Ogn)1hbh zC+M>%$m)Rr+lGvzCL*o`KR-jlhUJA&c|BrdL?w64MS{@XrAalh+Aw&%$C=>dp!+%ac0=rxS z^I~6Hinea@6VSPwum;(ZC27!BJ!mTh8Ut-K<2{fzSs}uoF>)a@6){GXN>Kry`@pF( z{NM%F)R2TB&q%1Bi(#x@EsR6M5_V(lz%67Hr3jiKlz_}| z`ivd030cB}xkM&qDMOfyZUs@u?*FsB;vBzBR>#I%>yE5X83P@xVWz?}4zmFnRl3^P zP7=DE&mJDa!oHg~`Gq6%Dnv=>r9#SkpT0WdA=v!v+T-#JIPbW8%k_&AuXL}(%*%y?Sku0m@;XsS(KRM5I@VNy3e(*VJVU6-6;U@lVS&fYlj+u5A z$y?}<11*9^Bn+*94I|ddL#3I~AP-N-u+cIJ*uT+Vfk?c$%$=Vj&g3LV*I}h<6e(=2`Qqt37>YpGt9o01!*kc+-ES<@nHzgn%uPh5!_x&!S#L#U7-XLB zfSWXVk1zSZ0Sx(=M!vR{0Ol8-k9=oqOO*iD!|nIHeqdykCVwnzYYA>!{R;0Qzr9ZV zYYdj*mhp=n`>*cSFEx6*)AOZ@^cnFV85cX89v}Pg>)oWG2k6A_w}04J8Z@Z>5A~(y z_u_(s7KdztOHSH)*yRwbDdD*=C3gCC zA@MZB-6|(}9r5_?QcI?TFJ!d`J^kSJ?z>sd9S>Fo8JmQf5Esxa1vSZMT1#)A8pe)? zkj`nG_;_Qd@$V!TQCa7{XgoC!-2iU>hFz=iX-AIMu^)oCGt?}*X;0vz zQPO}-woSAEeTu%-&C+Dm^}@YA)vVx+R%$ip>){9IB17-iVMx(WHdz07%ize(!l*~x z+ipyUZ?pe;JbJP4ag^?kjB3`?%dd0PYF=jhf?tw#Tr_IMHQys=*ke7h%HK0+jgL~s5&oM^0PxL*=9H6$t|X?lWNqzw4%{=G4P;)h&D9*DxvH()x;o#sHVZvIa6VxD zcQ-zLiOv7E@@R3*IT>#PY74|K@A^<*;kNGM%w}4{GU^uWbmqWBbvc;`NG`HZRSP)R zFyr{I0&cBT$gOorBQJE2d9#gr4CZ|0%LmR0DG3hidGTKgHJgcx176;XZ_$Y@HgV_- z)W@oFKl}D9*YLnB{B$8t@C(TVHS7#ISuVef&Kkn7BG*BN7W%YD-Y6S?~DBLEx7O+ONhI7}P zHu0uSa)0WcwbViKS_lmf^cvP{)Y_QE5y6+74{Kfk^_LFh9 zG;pl4L<}Yn@Zf3C| zSg%C8PPCG~4e+|O8P*AL{6=pQRZ9!L64kT-4R05cJiY68{}N8?(iT)3I-yhUPhN?_ zp(7$X=5mzMnk#}T@j4Yi7oZ_0I^;J)1w;Zk3q-s;uA<)ukILVV$0Q=FnZqG*(l%VUk}he&rcA^j-3HU&4aczI zlCP~R=$pX-W>hX>r_qnrsag@AQibL5?5oSfix9&#*U&PEH1$;#tmcLli=PihC-CZK zw8wx(lGWOkR}AqX$yO$mO(v12Ue6poIVG*jYAe7B_k)MFAZ`&R*+cOotiwZM4} zi46(~sDAMD0whk=UJvmA5~;`SRsbSCTcYTT2TVVXn`f!!{_Z@NFr#7vtC&71#aGV} zZET+73wP1i0<#TzKpZ7p4fn~a44(`X;Y}2pqjcAAx6Of9m~u+^tBtvoaOtoXz$;e$ zr*!(;mJS5f@C&Q?g-AB#Z!-wiT?lyU2J2oij8ma`#dxz>yRIIxs=9{w_sN=Str~`LOjMcC3*8#c*`ZV^wS`IuZ*K*jbt_yg17C3$Q z;Up#jf1-g_>=P5o@`$$vqY(r-kn(puG4nkx2SdhGKWnjUgNMm#zOkSa_o}t}XB6j&) zc5F5>_t5u;3+%g2`!UVn;`^PsPJu8OIm>6Vvbr;44*vRdSCUGuD+zAEtQL};e}4Ko ztH@GoBwdQ1nXqFmzW3|3{lFdZB-rFkBifa;?E6`p7TzKobJf&sQO5;|WEG%Wgzc*K zI}Wtbb`sA3keqw{-qmACkf%Yu6u$qsy5b#h4|*EhfYH9>ut^)VA=iOE{gb2#Q@X<^RC97;K=4G!S5zA)ls8nb>aRAb^r(xf@!IlHSEUV`%^bhoZ7sX# zvzm;;5M_bn_bw*Jt;7YquDQ0aayk|6Fy~dS(vEVgQ8}{Z-0W~hl1|P_m9~x+_?5W* z*s*6EQS7?ydMcujzOeOR3E$C9z3)hnh5d*-g^;wf%JkGtyX?)Ry;?Q>Jcw@MhIuJ= ziL;F{Q`@V_30Wf5Cyfz4056Vunl@YiBzP>i z!{|!DMA=-6GaFO>$4}QR4??uv87uQ{?kf*2ve>tR68v~8TznlEyh2(WVwAM5_v>8k ztJYbdsXRiML?YJK%+h+SankR-GkWM_U@PH+uaR z>hK%6Nu^`1i=Z9nwk2C{8?JLJ_1B3{+jEAE#(@{Vu}&=l+siBQx?Uy8gvz30)sHIn z7y*v4oTk~-2E7<(mBuag_FJe^cUk@61O70n6i`tWdS{98FZ3f`ST))QF05lwwCvil`s~K~d>s+pqwlpr{}u#{#Hm zC{mUlHEU(&o3iGXwdF49`OD@5fVBDU z9s2?B*c1`!wBLNkwu8P+gB+`{gAH+7Bdz(xS4y;=uWND{|ZrPB$@%0A#u=rDI8?9CwxSL&8&NN%LXT78R zmN4!gQJQAv_sIo_V*tPNFF7Kb>m0!+v+J;Gvgx({-UTLUVFpQKYr zyUVv2rUlO`4qh4`^E_?t-92^ko^FP`b%Ju_xB9|+$7hDkoM#)qNm}7B4xwU@UX}vPR2Y1PO z9nyXjrhG=X=7OoZ9IUNsXV6!bpRf=ycDfWEd<{P$YiEvVm~*{WA5S?#kpuB2*Yx)j z$9O*$7cUMLT_`laAzPJCy&JJ;V2zoiLc)S6%S_uu-CJP&s_ScNb#j!^y~-b+w-#8< z!bAbDY!@-|xOvQg(f4JHItm`KBvVOL=sEux?Mn4LeKe8O)cRZ(#B1V}&8MFHFcG0T z*P$vrQOFzf6RvZzBvV2XZfZYXvBbAD2?6bfJB3RJ2gLULgtaN3Qtl0Wxk}wblZ14c zah^B7IbBn!ei4!NnWS6QoX8kemq`Mk9D;#!5heQ8g$ zSbcPhLne@w;?~xyuSwqf%D&Af(|huI;Bs^`nFih3$|>H;%fIjIGX3zptVlR`DNkX( z03I=F!_5qX@-DhYfZX=90EgW16OCz#NKcJIQTT-C>&@$`l4=}Ie}700Ua$XsNbpdm z7ss>{y=q((JlgZho33|$fVmJMrF;!+a-k=Gkwxqk=dE`S3(TLNfxGoS7;ZMziz}}c z;F!T0Vj&x04iw#iJOS_AQttE*g)Iei!8 z>W`~t_jg{tYNdp$nju(g(V~CkyItrLItS3mFl0M5l9@M-zO5v}&w02mD+!%4t?6Im5CwVgXwoB#1N%Y{+Hf@jceb{r?hf?Ttr&} zYH}Aq`m*C2b2dKKKcBp$$ev*R+x5n?pCj@UL0eCQuxJSiav63@?@p%m49^)wpAL`G zdyU^c+nQx?lCi3Q0dPC=4&%_VCWFpDxdCdDDE+z zZH!%DySF`cDgl}vtI$Et$WV*P@!KH&QkJT3!qmfT)4{C>e8XfzMc9=qslmxj6M1Xy#`sV$0%LFZ93h#V06Dgy+^ z_@MLpETPCqNMKt>;FUKEamY$Cad%|t_1-TIm8ZO!Tf^Z=|C8?t;}XaRC9&+rUArO@ zAkx$AG?2cWLp&#yAxR*C^wr^?uSMq^0e^N4XnNk!os%dRhVKlzG-U=&@gf{0q$3Pb z%t@2dwp#S;Xzn<}Rvw;iR1<5tK&14aY}Qc$sR+B?+*-Rw)+w0gLtc~GVzZXO`t@ol zqp~3N@WmE`wR-ubM~h{fC z)nz$Ouff=5nha|@{e$=!2f#e>oiPbu<-ZAwW{cZfb#+*Cv`zOSL>$M^zNwJNrw(JBovBRh<;bU9?@$sDp{j z_zef(Mgn&{5%y($MZy56b)K6j7}Zo7!1@L_DmlW<91jTrb0H*MRtZ%sepoPl-3I+~ zHI?hl2+xh?EY%oeeH=YNF1aP>pOzpO7x<`g^BvIj#*g)%RWqBSRm;B_-9ICdl<+hP zojjn_@j4>>Yj34+VsLc&gOaDk4FWTU2uVGQ(QjXsoW~QSvwrHc@LV4UdPaE;W?fS~Ssw z07!XBtRv2ik6gxnM}wuR6&o4_pTwnQ@E}J>sT(dezEoy6o^tMtERG8B`~C}GE@rDz z0M4DV7maQ`J60=QtXq%A1l&J4D11%x^PmD^YiBY^UB{BX)`6^avj>~cGy)##n#Ket z3)3_UoIfdH^^kQ|qL&@vDgbtSz_$M@17-^f#`#YNs_eR>dFlld#af?ccdZCp!r;6A z$QY=wxkq1ehcT;d+3E1vWLkYP-(caeaMowEv~Fp*xFl#N-?kJrTX}@Bj5lxYpOSZ= zjXOBZ7BC8oR+#v9yy^AA4rj|5je2tOHf;86(q@@B5HEB4A2hUvaZJuET?iqTujVB= z^U$#=MywCdq;^y5blC)L32Pp<56QtSlR9<7AOSV{GDo34WC#LA-?fxCh|7T^V&J+G zu#s&Tb!2wR`DQ_ONw?Z&S1(MNB;-Y~)nw{{mY5sidjcdeJy}OS-Tt;n7e|Df8slRT zm1(6`Ecu3m3w|wSOtIc}WngP=9>PXKM&4#>dcNThL ze}~G6|L7qtg;}7wxqgMiELZV_aJ6j7?Z7(T+t6KlinmwCTK%oEEIo-hxVe(tK69yn z_wk6!(Z2WG6C@j@b2I$l*-2kPTXY#sTje!vh#sRNP4RE zHuZhPu3Z>%ocr@sK4wWHLp@ z13^IeD7(@NT?yKb@ueE!{0%#7jyaDnR?^YSM&;lp&c<7WW%%^Pw6GrB<>ThnqE z+gq0hFYo;NvI6F}Xr5Z=gHxl+SGT|q`kZq5CgmMz`~Nckdy4;M6bu}I!?5#j4FA}y z|H$P3_K|Z}{Xa-@lzb7xY_SX+*>|Qv^mRDGpWubQ#}u#LB~kTKc2DkP9l7GaXhoxo z=l z2608!@ng@j6@I}B36>1vv z{t z$VQelmJ(_F68h0{+IDq3v2Q@JaMt5Y!`Xa;c4@XGP?utr!eWzvfV|;`bsaI7pg%|Y zlsxOXbJI`t#lFIUyP>o-umg*fVA905bokb#*r5Ja3=dQ(vvutB%EG$ZjG1EEUx{qE zjtmF-xLBD|M`Ma<{Q|5a61U#41I_RZV!f`#<@?TsQGcD~RpB4mTwN*t;>~zGDPC@* ztl!g;)zCUySK=#3O?}j_Pp=nYD%p^?hv<_x9Wgq^5*?WH<79FG<*;V%CWY1wi>5fa zYu8JUDtZfwrxcp`3b_mXxe_=?`f`(+dXY64u(A%QdR8l_!1fz!H}nG>;uRl=5#)gK zM2%b#C4oWp+IVM?-xyZ7N%Bf>jCbO2@bMQjmLa|3^OkpGo`Mmuc#Sb)+0QH95L50i zVemvvRt~#8uat1XJk6c6{qbq$P_(AK&V61f+8m2o_x(g0j))>;Wl1=|nErJr{0kWP zD@&GEJyARFf4VFcE2ic_qOs;+(*e3(zchA#b<9@@)W;3tSf!T{)KQq+m2ugcDET)| ziYb68#n>Bni;-{CGSwe_&H@v3+V^L8mfNo>x9>z=@TD)s8S-!iE9wyPU{In~|5zwZ zz5%OVDdNu*+P?D~`g)rL+>E~x9T8KuPeE?F+Dhh}Q(hEdh$1ABo}#~8vKPTz(z>98 zn|tJJgOv4hx}bx>b&$zZqzh_iYy!}}Y|fR!LjDa-yx@;3k*E4sl<5!-*4{qlQ}Qiw zb4f=;c=54@nb(t;xk=~uC}_p3khU?IWM5FX3H-eK2bOHIbv`-#IjR^Jl$!6En#)uR z#$bn({S=L!+edWW?g>c;P)2dP&Mmz+YcMomWg2E4>h+WNo>-2XBGCze%r6eYu7|{JkE$?+lCpLzY6tpTFfULz%h+2m+RffEQ};MOS7*#Awe;Ccq+3U zZjg*sFHOmiy%stP4PKrBX3aduu_m~?buqwN4>Je)R?u1Y@&V@Pr@}SLMNy9n3kS+& z<0^b4r^6KRNK(@X`Z;luZ0kQL&z`8F*3_7S^)^OdM{WP03=wwJ%b@Sif$Q@Hldh{E z$q`(2Y#FwTe048kDeOT!T9_%`X1Yon>o(RmfotW(1R^(tM#LdWb+f3FIC4Gwx*5#9 z;2N?V7K5tYo7$N|RUJh*`0-P5fl&1T)D}J-sIdpRB{m`-p|LOrN1-Vv;DCugTbJmt z1)jKvTZNp#LdvDZiu?(?1%Rzl)*1**XrPU~eD;;Lqelts>npFGWOZY&&ie!T)gx#! zuI*#RHZvSJl604Se;Yd>qi5hk0CtLpF!r~ter;{8e+KKfSvZ`2m_S6@@2PAtY2YQ` zDCSXdF^eF?mDr{*I}|I9JVhuBwZ_D)I7fA?EZ<@`+WK1poDaW{t15=9^a>5SbpACf z3v1m(9X;toI|?M5#fdV%*)Gm{XI`~#PuN`88ylhLc*`6R%fcRwAT?Z5=ywO?S;qVX zZ1Lzk$Fo5bPw?!I7pf}^PXs1Ez&WPGixT-gTV4O`inX$`jJF$m$S`^Axgo{eo4{uO zDMSaWg{Z#q=dLT>i3$KA-a{tchX{^4G1<1nM&nB9tKFaH4&v3K9%0Z)Pr2t&i*+BK znx%DZ&XkHT?CM|Pc4}dNJ^`#h#-kE4x;!C;9?O^Z99017ngCJjVe*gcI1@u%zljNM z^&xsZdj3xX90>gdR`&WC!)-&&ezztrp=7g~dxM)L@JKTY~@P>rb`4GV6|MY@d3xT_N`D$H_`)B1k-0b zS{vl?P$%c7>CSC!3}M$R@0m0XHSkb(i0eUAkdA0>Kyk@(=}ci)NGp_al6oFU%hK06|!@+Hgz;_e^8Bl>WYC7ar07ZN$5e{h?5UPk**S(qqHL&c+ohv1iBy5nuMK z(p`%i;-7ytt9tUf7vm8pbXgcAVQ&bYLkCcZz#M0tfoyravG;Js-1s^d3D6E}=0~R%IY{*#W^efHaW%AP;m0*sus+kR zr^CxSE9h+m>M=vA>+{s4qfhP)Y!YKTnoNH#cv{&v^}+Z-EPnc3mqV~?Ky>i};}am& zvIuQFcOLy?aJ&5Bkn-Ez+hd#J8egmjk?TJ-qh}I{@*Y)^;goxSY~_^a4C~wF(v@$Hkj^F$ zLA#S&&%od8-#ahtd8pZ&MZnZLB=Tcy8i=E-n8E2B8mFjP-#nA!3cv~Ovr0e7zeSMn zIo!pnORC{-`YB2sRhbG5_E`b9pIt}G)D)2Q6QY19vz*>J2p>zW4s+YD1ju_)(phQh zkLR12Ni`E|#2qV^h?YF=XK^Iid&^^;pH&YHlvybr8$KY-cHbFXy*o>)=x_nnmiDk^ z)>>XVeUCHfEW>_Kp}loc`177=JKgyPKhpy_jnOyVb1A^=s6^ij=is3G+h7uFmE=it zmqgQTu$<1(=@Z}{`J+AUh1o7Y%Lk`x={$1CiQcyTSz^=-1CgMv;Gsgd-Si3$%IeT% z`LOE%Q47W)C+ex29CTASuW-ok2Y-694xD2^)r+pHN7b66Bj&sBQv+>7rRh$z@xk-b z3Vlu9Vt6WRupl#Pogc~9lm=4W(fQ$KnXk)YrzAloV!raA`+-E#lAFHPp>W@A)mO%3 zx>?%i+5(_RLC?fOn00>l{h0sTptA>*<4)TfW}ANn>Qwi{c!itQ$ul%3=k~dl@H?}G zApWha`x#At@KXBX>M&*6d?I+ObIT(S2iqRk@X)cHYyKziBO;_B!mZodRck)7lT<=r zQ*@uohNFhO3MU-tX5!Rq`m>8O4QKR)wAc};1QbXAx!Qfnr>L`l{mge- L?s#kJc;!C;XB#i` literal 0 HcmV?d00001 diff --git a/ahakey-desktop/src-tauri/icons/icon.svg b/ahakey-desktop/src-tauri/icons/icon.svg new file mode 100644 index 00000000..a68b4be2 --- /dev/null +++ b/ahakey-desktop/src-tauri/icons/icon.svg @@ -0,0 +1 @@ + diff --git a/ahakey-desktop/src-tauri/src/backend.rs b/ahakey-desktop/src-tauri/src/backend.rs new file mode 100644 index 00000000..55797ba9 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/backend.rs @@ -0,0 +1,1009 @@ +use crate::{ + caption::{self, Caption}, + settings::{self, Settings}, + state::{DownloadStatus, Runtime, Snapshot}, +}; +use std::{ + sync::{atomic::Ordering, Arc}, + time::Duration, +}; +use tauri::{Emitter, Manager}; +#[cfg(not(windows))] +use tauri_plugin_global_shortcut::GlobalShortcutExt; +use tauri_plugin_global_shortcut::{Code, Shortcut}; +pub fn require_main(w: &tauri::WebviewWindow) -> Result<(), String> { + if w.label() == "main" { + Ok(()) + } else { + Err("仅主窗口允许此操作".into()) + } +} +pub fn pulse(app: &tauri::AppHandle) { + crate::tray::sync(app); + let _ = app.emit("runtime-update", ()); +} +pub async fn snapshot(app: &tauri::AppHandle) -> Result { + let s = app.state::(); + let ble = s.ble.lock().await.as_ref().map(|b| b.status()); + let usb = s.usb.lock().unwrap().clone(); + let device = crate::device::DeviceView::from_transports(&usb, ble.as_ref()); + // Match the supervisor's lock order and drop both guards before assembling + // the snapshot. Struct-field temporaries otherwise retain the error lock. + let (ble_recovery, ble_error) = { + let recovery = s.ble_recovery.lock().unwrap(); + let error = s.ble_error.lock().unwrap(); + (recovery.status(), error.clone()) + }; + let (settings, settings_change_id) = { + let settings = s.settings.lock().unwrap(); + ( + settings.clone(), + s.settings_change_id.lock().unwrap().clone(), + ) + }; + let (hook_port, hook_last_event) = { + let h = s.hook.lock().unwrap(); + ( + h.as_ref().map(|v| v.port), + h.as_ref().and_then(|v| v.last.lock().unwrap().clone()), + ) + }; + let result = Ok(Snapshot { + version: app.package_info().version.to_string(), + platform: std::env::consts::OS.into(), + settings, + settings_change_id, + settings_path: s.settings_path.to_string_lossy().into_owned(), + settings_error: s.settings_error.clone(), + settings_notice: s.settings_notice.lock().unwrap().clone(), + foreground_caption_supported: cfg!(windows), + speech_engine_ready: true, + model_installed: s.model_store().is_installed(), + model_directory: s.model_store().directory().to_string_lossy().into_owned(), + native_key_test_supported: true, + native_key_test_enabled: s.key_enabled.load(Ordering::SeqCst), + key_observation: s.key_observation.lock().unwrap().clone(), + key_write_notice: s.key_write_notice.lock().unwrap().clone(), + ble_ready: ble + .as_ref() + .is_some_and(|b| b.phase == ahakey_ble::ConnectionPhase::Ready), + ble, + usb, + device, + ble_error, + ble_recovery, + devices: s.devices.lock().unwrap().clone(), + caption: s.caption.lock().unwrap().clone(), + speech: s.speech.lock().unwrap().clone(), + download: s.download.lock().unwrap().clone(), + cloud_configured: s.credentials().has_token().unwrap_or(false), + hook_port, + hook_last_event, + auto_insert_supported: cfg!(windows), + }); + result +} +#[tauri::command] +pub async fn get_snapshot(app: tauri::AppHandle) -> Result { + snapshot(&app).await +} +#[tauri::command] +pub async fn save_settings( + window: tauri::WebviewWindow, + settings: Settings, + base: Option, + change_id: Option, +) -> Result { + require_main(&window)?; + apply_settings( + window.app_handle(), + SettingsEdit::Ui { + settings: Box::new(settings), + base: base.map(Box::new), + change_id, + }, + ) + .await +} +pub enum SettingsEdit { + Ui { + settings: Box, + base: Option>, + change_id: Option, + }, + Provider(settings::Provider), + Profile(String), + ToggleCaptions, +} +pub async fn apply_settings( + app: &tauri::AppHandle, + edit: SettingsEdit, +) -> Result { + let state = app.state::(); + let _gate = state.settings_gate.lock().await; + let previous = state.settings.lock().unwrap().clone(); + let (mut settings, change_id) = match edit { + SettingsEdit::Ui { + settings, + base, + change_id, + } => { + settings.validate()?; + if change_id.as_ref().is_some_and(|id| id.len() > 128) { + return Err("无效设置请求标识".into()); + } + let next = if let Some(base) = base { + Settings::merge_changes(&settings, &base, &previous)? + } else { + *settings + }; + (next, change_id) + } + SettingsEdit::Provider(provider) => { + let mut next = previous.clone(); + next.provider = provider; + (next, None) + } + SettingsEdit::Profile(profile) => { + let mut next = previous.clone(); + next.active_profile = profile; + (next, None) + } + SettingsEdit::ToggleCaptions => { + let mut next = previous.clone(); + next.captions_enabled = !next.captions_enabled; + (next, None) + } + }; + settings.validate()?; + if previous.provider != settings.provider + || previous.trigger_mode != settings.trigger_mode + || previous.microphone != settings.microphone + { + invalidate_keys(app); + crate::voice::cancel(app).await; + } + { + let mut current = state.settings.lock().unwrap(); + settings.saved_device = current.saved_device.clone(); + settings.voice_keys_enabled = current.voice_keys_enabled; + settings::save(&state.settings_path, &settings)?; + *current = settings.clone(); + *state.settings_change_id.lock().unwrap() = change_id; + } + if !settings.captions_enabled { + if let Some(caption) = app.get_webview_window("caption") { + let _ = caption.hide(); + } + } else if !previous.captions_enabled { + let current = state.caption.lock().unwrap().clone(); + if current.phase != "idle" { + let _ = display_caption(app, ¤t.phase, ¤t.text, false); + } + } + let client = state.ble.lock().await.clone(); + let device_change = previous.active_profile != settings.active_profile + || previous.light_brightness != settings.light_brightness; + let mut notice = "本机偏好已自动保存并生效".to_string(); + if device_change { + if let Some(client) = + client.filter(|c| c.status().phase == ahakey_ble::ConnectionPhase::Ready) + { + let result = async { + if previous.active_profile != settings.active_profile { + let mode = settings + .profiles + .iter() + .position(|p| p.id == settings.active_profile) + .unwrap_or(0) as u8; + client.set_work_mode(mode).await?; + } + if previous.light_brightness != settings.light_brightness { + client + .set_light_brightness(settings.light_brightness) + .await?; + } + client.query_status().await + } + .await; + notice = match result { + Ok(()) => "本机偏好已保存;模式 / 亮度已应用到键盘".into(), + Err(e) => format!("本机已保存,但设备更新失败:{e}"), + }; + } else { + notice = "本机已保存;蓝牙写入通道未就绪,设备模式 / 亮度未更新。USB 状态读取不受影响" + .into(); + } + } + *state.settings_notice.lock().unwrap() = notice; + pulse(app); + Ok(settings) +} +pub fn position_caption(app: &tauri::AppHandle, offset: u32) -> Result<(), String> { + let window = app.get_webview_window("caption").ok_or("字幕窗口未就绪")?; + let state = app.state::(); + #[cfg(windows)] + let p = { + use windows_sys::Win32::{ + Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromPoint, MonitorFromWindow, MONITORINFO, + MONITOR_DEFAULTTONEAREST, + }, + UI::WindowsAndMessaging::{GetCursorPos, IsWindow}, + }; + let target = *state.caption_target.lock().unwrap(); + let monitor = if let Some(target) = target.filter(|h| unsafe { IsWindow(*h as _) != 0 }) { + unsafe { MonitorFromWindow(target as _, MONITOR_DEFAULTTONEAREST) } + } else { + let mut cursor = windows_sys::Win32::Foundation::POINT { x: 0, y: 0 }; + if unsafe { GetCursorPos(&mut cursor) } == 0 { + return Err("无法读取鼠标所在屏幕".into()); + } + unsafe { MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST) } + }; + let mut info: MONITORINFO = unsafe { std::mem::zeroed() }; + info.cbSize = std::mem::size_of::() as u32; + if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 { + return Err("无法读取目标屏幕".into()); + } + let scale = window + .available_monitors() + .map_err(|e| e.to_string())? + .into_iter() + .find(|m| m.position().x == info.rcMonitor.left && m.position().y == info.rcMonitor.top) + .map(|m| m.scale_factor()) + .ok_or("无法读取目标屏幕缩放比例")?; + caption::place( + info.rcWork.left, + info.rcWork.top, + info.rcWork.right, + info.rcWork.bottom, + scale, + offset, + ) + }; + #[cfg(not(windows))] + let p = { + let m = window + .primary_monitor() + .map_err(|e| e.to_string())? + .ok_or("无显示器")?; + let a = m.work_area(); + caption::place( + a.position.x, + a.position.y, + a.position.x + a.size.width as i32, + a.position.y + a.size.height as i32, + m.scale_factor(), + offset, + ) + }; + // Move first: WM_DPICHANGED may resize the window during a monitor change. + window + .set_position(tauri::PhysicalPosition::new(p.x, p.y)) + .map_err(|e| e.to_string())?; + window + .set_size(tauri::PhysicalSize::new(p.width, p.height)) + .map_err(|e| e.to_string())?; + *state.caption_placement.lock().unwrap() = Some(p); + Ok(()) +} +pub fn display_caption( + app: &tauri::AppHandle, + phase: &str, + text: &str, + auto_hide: bool, +) -> Result { + let state = app.state::(); + if phase != "idle" { + let offset = state.settings.lock().unwrap().caption_bottom_offset; + position_caption(app, offset)?; + } + let next = { + let mut c = state.caption.lock().unwrap(); + c.sequence += 1; + c.phase = phase.into(); + c.text = text.into(); + c.clone() + }; + app.emit("caption-update", &next) + .map_err(|e| e.to_string())?; + if let Some(w) = app.get_webview_window("caption") { + if phase == "idle" || !state.settings.lock().unwrap().captions_enabled { + let _ = w.hide(); + } else { + let _ = w.set_ignore_cursor_events(true); + let _ = w.show(); + } + } + if auto_hide { + let app = app.clone(); + let sequence = next.sequence; + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_secs(6)).await; + let state = app.state::(); + if state.caption.lock().unwrap().sequence == sequence { + if let Some(w) = app.get_webview_window("caption") { + let _ = w.hide(); + } + } + }); + } + Ok(next) +} +#[tauri::command] +pub fn test_caption( + window: tauri::WebviewWindow, + pressed: bool, + target_window: Option, +) -> Result { + require_main(&window)?; + let app = window.app_handle(); + if app.state::().recording.load(Ordering::SeqCst) { + return Err("录音期间不运行字幕测试".into()); + } + if pressed { + #[cfg(windows)] + { + *app.state::().caption_target.lock().unwrap() = + target_window.or_else(|| window.hwnd().ok().map(|h| h.0 as usize)); + } + #[cfg(not(windows))] + { + let _ = target_window; + } + let offset = app + .state::() + .settings + .lock() + .unwrap() + .caption_bottom_offset; + position_caption(app, offset)?; + } + display_caption( + app, + if pressed { "preview" } else { "idle" }, + if pressed { + "字幕位置测试 · 未录音" + } else { + "字幕测试完成" + }, + pressed, + ) +} +pub fn preview_caption(app: &tauri::AppHandle) -> Result<(), String> { + let state = app.state::(); + let existing = state.caption.lock().unwrap().clone(); + let active = state.recording.load(Ordering::SeqCst) + || state.speech.lock().unwrap().phase == "transcribing"; + if active && existing.phase != "idle" { + display_caption(app, &existing.phase, &existing.text, false)?; + } else { + if !active { + *state.caption_target.lock().unwrap() = crate::platform::last_external_window(); + } + display_caption( + app, + "preview", + "字幕位置预览 · 本地 / 云端识别时跟随目标输入窗口", + true, + )?; + } + Ok(()) +} +#[tauri::command] +pub fn caption_diagnostics(window: tauri::WebviewWindow) -> Result { + require_main(&window)?; + let app = window.app_handle(); + let state = app.state::(); + let caption = app.get_webview_window("caption").ok_or("字幕窗口不可用")?; + let requested = state.caption_placement.lock().unwrap().clone(); + #[cfg(windows)] + let foreground = + Some( + unsafe { windows_sys::Win32::UI::WindowsAndMessaging::GetForegroundWindow() } as usize, + ); + #[cfg(not(windows))] + let foreground: Option = None; + Ok( + serde_json::json!({"requested":requested,"actualPosition":caption.outer_position().map_err(|e|e.to_string())?,"actualSize":caption.outer_size().map_err(|e|e.to_string())?,"focused":caption.is_focused().map_err(|e|e.to_string())?,"visible":caption.is_visible().map_err(|e|e.to_string())?,"foregroundWindow":foreground,"monitors":caption.available_monitors().map_err(|e|e.to_string())?.into_iter().map(|m|serde_json::json!({"position":m.position(),"size":m.size(),"scale":m.scale_factor()})).collect::>()}), + ) +} +#[tauri::command] +pub fn dismiss_caption(window: tauri::WebviewWindow) -> Result<(), String> { + require_main(&window)?; + display_caption(window.app_handle(), "idle", "", false)?; + Ok(()) +} +#[tauri::command] +pub async fn start_speech(window: tauri::WebviewWindow) -> Result<(), String> { + require_main(&window)?; + crate::voice::start(window.app_handle().clone(), None, None, None).await +} +#[tauri::command] +pub async fn finish_speech(window: tauri::WebviewWindow) -> Result<(), String> { + require_main(&window)?; + crate::voice::finish(window.app_handle().clone()).await +} +#[tauri::command] +pub async fn cancel_speech(window: tauri::WebviewWindow) -> Result<(), String> { + require_main(&window)?; + invalidate_keys(window.app_handle()); + crate::voice::cancel(window.app_handle()).await; + pulse(window.app_handle()); + Ok(()) +} +#[tauri::command] +pub async fn set_key_test(window: tauri::WebviewWindow, enabled: bool) -> Result<(), String> { + require_main(&window)?; + let app = window.app_handle(); + let state = app.state::(); + let _settings_gate = state.settings_gate.lock().await; + if state.key_enabled.load(Ordering::SeqCst) == enabled + && state.settings.lock().unwrap().voice_keys_enabled == enabled + { + return Ok(()); + } + if enabled && crate::platform::java_running() { + return Err("JavaFX AhaKey 仍在运行,请退出它后启用 Rust 语音键,避免双重触发".into()); + } + if enabled { + if !state.key_enabled.load(Ordering::SeqCst) { + register_voice_keys(app)?; + } + } else { + state.key_enabled.store(false, Ordering::SeqCst); + unregister_voice_keys(app); + invalidate_keys(app); + crate::voice::cancel(app).await; + } + state.key_enabled.store(enabled, Ordering::SeqCst); + let persisted = { + let mut settings = state.settings.lock().unwrap(); + let mut next = settings.clone(); + next.voice_keys_enabled = enabled; + settings::save(&state.settings_path, &next).map(|()| { + *settings = next; + }) + }; + if let Err(error) = persisted { + state.key_enabled.store(false, Ordering::SeqCst); + unregister_voice_keys(app); + crate::voice::cancel(app).await; + pulse(app); + return Err(format!("语音键已停用,但无法保存开关:{error}")); + } + pulse(app); + Ok(()) +} +fn unregister_voice_keys(app: &tauri::AppHandle) { + #[cfg(windows)] + { + app.state::() + .windows_voice_hook + .lock() + .unwrap() + .take(); + } + #[cfg(not(windows))] + { + let _ = app.global_shortcut().unregister_all(); + } +} +fn register_voice_keys(app: &tauri::AppHandle) -> Result<(), String> { + #[cfg(windows)] + { + let (hook, mut events) = crate::windows_voice_keys::VoiceKeyHook::start()?; + let fault = hook.fault.clone(); + *app.state::().windows_voice_hook.lock().unwrap() = Some(hook); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = events.recv().await { + if fault.load(Ordering::Acquire) { + break; + } + let code = if event.vk == 0x80 { + Code::F17 + } else { + Code::F18 + }; + key_event_target( + &app, + Shortcut::new(None, code).id(), + event.pressed, + event.target, + ); + } + if fault.load(Ordering::Acquire) { + app.state::() + .key_enabled + .store(false, Ordering::SeqCst); + invalidate_keys(&app); + unregister_voice_keys(&app); + crate::voice::cancel(&app).await; + let _ = app.emit("native-error", "语音键监听中断,已停止语音;请重新启用监听"); + pulse(&app); + } + }); + } + #[cfg(not(windows))] + { + for code in [Code::F17, Code::F18] { + if let Err(error) = app.global_shortcut().register(Shortcut::new(None, code)) { + unregister_voice_keys(app); + return Err(error.to_string()); + } + } + } + Ok(()) +} +pub fn key_event(app: &tauri::AppHandle, id: u32, pressed: bool) { + key_event_target(app, id, pressed, crate::platform::foreground()); +} +fn key_event_target(app: &tauri::AppHandle, id: u32, pressed: bool, target: Option) { + let state = app.state::(); + if !state.key_enabled.load(Ordering::SeqCst) + || state.closing.load(Ordering::SeqCst) + || state.key_write_busy.load(Ordering::SeqCst) + { + return; + } + { + let mut observed = state.key_observation.lock().unwrap(); + observed.events += 1; + observed.key = if id == Shortcut::new(None, Code::F17).id() { + "F17" + } else { + "F18" + } + .into(); + observed.pressed = pressed; + } + pulse(app); + let down = { + let mut keys = state.keys_down.lock().unwrap(); + if pressed { + keys.insert(id); + } else { + keys.remove(&id); + } + !keys.is_empty() + }; + let mode = state.settings.lock().unwrap().trigger_mode.clone(); + let action = state.key_state.lock().unwrap().update(down, mode); + if let Some(start) = action { + let epoch = state.key_epoch.load(Ordering::SeqCst); + let sequence = state.key_sequence.fetch_add(1, Ordering::SeqCst) + 1; + let accepted = state + .key_actions + .lock() + .unwrap() + .as_ref() + .is_some_and(|tx| tx.try_send((epoch, sequence, start, target)).is_ok()); + if !accepted { + state.key_enabled.store(false, Ordering::SeqCst); + invalidate_keys(app); + let stop_app = app.clone(); + tauri::async_runtime::spawn(async move { + crate::voice::cancel(&stop_app).await; + pulse(&stop_app); + }); + unregister_voice_keys(app); + let _ = app.emit("native-error", "语音键队列已满,已安全停止,请重新启用"); + } + } +} +pub fn invalidate_keys(app: &tauri::AppHandle) { + let s = app.state::(); + s.key_epoch.fetch_add(1, Ordering::SeqCst); + s.keys_down.lock().unwrap().clear(); + s.key_state.lock().unwrap().stop(); +} +pub fn initialize_keys(app: &tauri::AppHandle) { + let (tx, mut rx) = tokio::sync::mpsc::channel(32); + *app.state::().key_actions.lock().unwrap() = Some(tx); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + while let Some((epoch, sequence, start, target)) = rx.recv().await { + let s = app.state::(); + if !s.key_enabled.load(Ordering::SeqCst) || s.key_epoch.load(Ordering::SeqCst) != epoch + { + continue; + } + let reject_stale_start = { + let settings = s.settings.lock().unwrap(); + start + && settings.provider == settings::Provider::Wechat + && settings.trigger_mode == settings::TriggerMode::Hold + }; + let result = if start { + crate::voice::start( + app.clone(), + target, + Some(epoch), + reject_stale_start.then_some(sequence), + ) + .await + } else { + crate::voice::finish(app.clone()).await + }; + if let Err(error) = result { + let _ = app.emit("native-error", error); + } + } + }); +} +#[tauri::command] +pub async fn microphone_devices(window: tauri::WebviewWindow) -> Result, String> { + require_main(&window)?; + tauri::async_runtime::spawn_blocking(|| { + ahakey_speech::input_devices().map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn prepare_model( + window: tauri::WebviewWindow, + source: Option, +) -> Result<(), String> { + require_main(&window)?; + let app = window.app_handle().clone(); + let state = app.state::(); + if state.recording.load(Ordering::SeqCst) { + return Err("请先停止录音".into()); + } + let cancel = ahakey_speech::CancellationToken::new(); + { + let mut active = state.transfer.lock().unwrap(); + if active.is_some() { + return Err("模型操作正在进行".into()); + } + *active = Some(cancel.clone()); + } + *state.download.lock().unwrap() = DownloadStatus { + busy: true, + progress: 0.0, + message: "正在准备模型…".into(), + }; + pulse(&app); + let dir = state.model_store().directory().to_path_buf(); + let worker_app = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + let store = ahakey_speech::ModelStore::new(dir); + let progress = |fraction: f64| { + let s = worker_app.state::(); + let mut p = s.download.lock().unwrap(); + let changed = (p.progress - fraction).abs() >= 0.01 || fraction >= 1.0; + p.progress = fraction; + drop(p); + if changed { + pulse(&worker_app) + } + }; + if let Some(path) = source { + store.import_from(std::path::Path::new(&path), &cancel, progress) + } else { + store.download(&cancel, progress) + } + }) + .await + .map_err(|e| e.to_string()) + .and_then(|r| r.map_err(|e| e.to_string())); + state.transfer.lock().unwrap().take(); + { + let mut p = state.download.lock().unwrap(); + p.busy = false; + p.message = match &result { + Ok(_) => "模型已校验,可开始识别".into(), + Err(e) => format!("模型操作未完成:{e}"), + }; + } + pulse(&app); + result.map(|_| ()) +} +#[tauri::command] +pub fn cancel_model(window: tauri::WebviewWindow) -> Result<(), String> { + require_main(&window)?; + if let Some(token) = window.state::().transfer.lock().unwrap().as_ref() { + token.cancel() + } + Ok(()) +} +#[tauri::command] +pub fn save_cloud_token(window: tauri::WebviewWindow, token: String) -> Result<(), String> { + require_main(&window)?; + window + .state::() + .credentials() + .save(&token) + .map_err(|e| e.to_string())?; + pulse(window.app_handle()); + Ok(()) +} +#[tauri::command] +pub fn clear_cloud_token(window: tauri::WebviewWindow) -> Result<(), String> { + require_main(&window)?; + window + .state::() + .credentials() + .clear() + .map_err(|e| e.to_string())?; + pulse(window.app_handle()); + Ok(()) +} +pub async fn ble_client(app: &tauri::AppHandle) -> Result, String> { + let state = app.state::(); + let _gate = state.ble_initialization.lock().await; + if let Some(client) = state.ble.lock().await.as_ref() { + return Ok(client.clone()); + } + let client = ahakey_ble::BleClient::new().await.map_err(|e| { + let m = e.to_string(); + *state.ble_error.lock().unwrap() = Some(m.clone()); + m + })?; + let mut events = client.subscribe(); + let app_events = app.clone(); + tauri::async_runtime::spawn(async move { + loop { + match events.recv().await { + Ok(ahakey_ble::BleEvent::Devices(devices)) => { + *app_events.state::().devices.lock().unwrap() = devices; + pulse(&app_events); + } + Ok(ahakey_ble::BleEvent::State(_)) => pulse(&app_events), + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + }); + *state.ble.lock().await = Some(client.clone()); + *state.ble_error.lock().unwrap() = None; + Ok(client) +} +#[tauri::command] +pub async fn scan_devices( + window: tauri::WebviewWindow, +) -> Result, String> { + require_main(&window)?; + if crate::platform::java_running() { + return Err("请先退出 JavaFX AhaKey,再由 Rust 管理蓝牙".into()); + } + let state = window.state::(); + let _gate = state.ble_connection_gate.lock().await; + ble_client(window.app_handle()) + .await? + .scan(Duration::from_secs(4)) + .await + .map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn connect_device(window: tauri::WebviewWindow, id: String) -> Result<(), String> { + require_main(&window)?; + if id.is_empty() || id.len() > 4096 { + return Err("设备标识无效".into()); + } + crate::recovery::connect(window.app_handle(), id).await +} +pub async fn restore_device_preferences(app: &tauri::AppHandle, client: &ahakey_ble::BleClient) { + let state = app.state::(); + let _gate = state.settings_gate.lock().await; + let settings = state.settings.lock().unwrap().clone(); + let Some(status) = client.status().status else { + return; + }; + let mode = settings + .profiles + .iter() + .position(|p| p.id == settings.active_profile) + .unwrap_or(0) as u8; + let result = async { + if mode != status.work_mode { + client.set_work_mode(mode).await?; + } + if settings.light_brightness != status.light_brightness { + client + .set_light_brightness(settings.light_brightness) + .await?; + } + client.query_status().await + } + .await; + *state.settings_notice.lock().unwrap() = match result { + Ok(()) => "已连接;保存的模式 / 亮度已应用,未覆盖完整键位配置".into(), + Err(e) => format!("已连接,但保存的设备偏好应用失败:{e}"), + }; +} +#[tauri::command] +pub async fn disconnect_device(window: tauri::WebviewWindow) -> Result<(), String> { + require_main(&window)?; + crate::recovery::disconnect(window.app_handle()).await +} +struct KeyWriteGuard<'a>(&'a std::sync::atomic::AtomicBool); +impl Drop for KeyWriteGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::SeqCst); + } +} + +fn profile_keys( + profile: &settings::Profile, + mode: usize, +) -> Result<[ahakey_ble::KeyConfig; 4], String> { + let bindings = if profile.keys.is_empty() { + crate::keys::defaults(&profile.accept, &profile.reject) + } else { + profile.keys.clone() + }; + let keys: Vec<_> = bindings + .iter() + .map(|key| { + Ok(ahakey_ble::KeyConfig { + hid_codes: key.hid(mode)?, + description: key.label.clone(), + }) + }) + .collect::>()?; + keys.try_into().map_err(|_| "需要四个按键定义".into()) +} +#[tauri::command] +pub async fn write_current_keys( + window: tauri::WebviewWindow, + confirmed: bool, +) -> Result<(), String> { + require_main(&window)?; + if !confirmed { + return Err("请先确认覆盖当前模式的四个按键;其他模式和灯效不会修改".into()); + } + let app = window.app_handle(); + let state = app.state::(); + let settings = state.settings.lock().unwrap().clone(); + settings.validate()?; + if state.recording.load(Ordering::SeqCst) { + return Err("请先结束语音,再写入按键".into()); + } + if state.key_write_busy.swap(true, Ordering::SeqCst) { + return Err("按键写入正在进行".into()); + } + let _write_guard = KeyWriteGuard(&state.key_write_busy); + invalidate_keys(app); + crate::voice::cancel(app).await; + let mode = settings + .profiles + .iter() + .position(|p| p.id == settings.active_profile) + .ok_or("未选择模式")?; + let keys = profile_keys(&settings.profiles[mode], mode)?; + *state.key_write_notice.lock().unwrap() = + format!("正在写入 {} 的四键…", settings.profiles[mode].name); + pulse(app); + let result = async { + ble_client(app) + .await? + .save_keys(mode as u8, &keys) + .await + .map_err(|e| e.to_string()) + } + .await; + *state.key_write_notice.lock().unwrap() = match &result { + Ok(()) => format!( + "{} 四键已发送并保存;请按实物键验证。其他模式和灯效未修改", + settings.profiles[mode].name + ), + Err(e) => format!("四键写入未完成,部分写入可能已生效:{e};重连后可重试"), + }; + pulse(app); + result +} +#[tauri::command] +pub async fn write_profiles(window: tauri::WebviewWindow) -> Result<(), String> { + require_main(&window)?; + let app = window.app_handle(); + let state = app.state::(); + let settings = state.settings.lock().unwrap().clone(); + settings.validate()?; + if state.recording.load(Ordering::SeqCst) { + return Err("请先结束语音,再写入按键".into()); + } + if state.key_write_busy.swap(true, Ordering::SeqCst) { + return Err("按键写入正在进行".into()); + } + let _write_guard = KeyWriteGuard(&state.key_write_busy); + invalidate_keys(app); + crate::voice::cancel(app).await; + let profiles: Vec<_> = settings + .profiles + .iter() + .enumerate() + .map(|(i, p)| { + Ok(ahakey_ble::ProfileConfig { + keys: profile_keys(p, i)?, + light_effects: p.light_effects.to_vec(), + }) + }) + .collect::>()?; + let profiles: [ahakey_ble::ProfileConfig; 4] = + profiles.try_into().map_err(|_| "需要四个模式")?; + let active = settings + .profiles + .iter() + .position(|p| p.id == settings.active_profile) + .unwrap_or(0) as u8; + ble_client(app) + .await? + .save_profiles(&profiles, active, settings.light_brightness) + .await + .map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn test_light(window: tauri::WebviewWindow, effect: u8) -> Result<(), String> { + require_main(&window)?; + if effect > 16 { + return Err("不支持的灯效".into()); + } + ble_client(window.app_handle()) + .await? + .set_light_effect(effect) + .await + .map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn set_hooks(window: tauri::WebviewWindow, enabled: bool) -> Result<(), String> { + require_main(&window)?; + let app = window.app_handle().clone(); + let state = app.state::(); + if !enabled { + state.hook.lock().unwrap().take(); + pulse(&app); + return Ok(()); + } + if state.hook.lock().unwrap().is_some() { + return Ok(()); + } + let home = app + .path() + .home_dir() + .map_err(|e| e.to_string())? + .join(".ahakey/hooks"); + let target = app.clone(); + let server = crate::hooks::start(home, move |_name, event| { + let app = target.clone(); + tauri::async_runtime::spawn(async move { + let client = app.state::().ble.lock().await.clone(); + if let Some(client) = client { + let _ = client.set_ide_state(event).await; + } + pulse(&app); + }); + })?; + *state.hook.lock().unwrap() = Some(server); + pulse(&app); + Ok(()) +} +pub fn request_shutdown(app: &tauri::AppHandle) { + let state = app.state::(); + if state.closing.swap(true, Ordering::SeqCst) { + return; + } + state.ble_recovery.lock().unwrap().pause(); + state.key_enabled.store(false, Ordering::SeqCst); + unregister_voice_keys(app); + let foreground_hook = state.foreground_hook.swap(0, Ordering::SeqCst); + let _ = app.run_on_main_thread(move || { + crate::platform::stop_foreground_watch(foreground_hook as usize) + }); + state.hook.lock().unwrap().take(); + if let Some(cancel) = state.transfer.lock().unwrap().as_ref() { + cancel.cancel() + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + crate::voice::cancel(&app).await; + let client = app.state::().ble.lock().await.take(); + if let Some(client) = client { + let _ = tokio::time::timeout(Duration::from_secs(10), client.disconnect()).await; + } + app.exit(0); + }); +} diff --git a/ahakey-desktop/src-tauri/src/main.rs b/ahakey-desktop/src-tauri/src/main.rs new file mode 100644 index 00000000..ca135980 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/main.rs @@ -0,0 +1,139 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod backend; +mod caption; +mod device; +mod device_routing; +mod hooks; +mod host_notes; +mod input; +mod keys; +mod platform; +mod quota; +mod recovery; +mod settings; +mod state; +mod tray; +mod voice; +#[cfg(windows)] +mod windows_voice_keys; +use backend::*; +use std::sync::atomic::Ordering; +use tauri::Manager; +use tray::{quick_action, tray_status}; +fn main() { + tauri::Builder::default() + .manage(quota::Service::default()) + .plugin(tauri_plugin_single_instance::init(|app, _, _| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + })) + .plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, shortcut, event| { + key_event( + app, + shortcut.id(), + event.state() == tauri_plugin_global_shortcut::ShortcutState::Pressed, + ); + }) + .build(), + ) + .setup(|app| { + let path = app.path().app_config_dir()?.join("settings.json"); + let (settings, error) = match settings::load_for_launch(&path) { + Ok(s) => (s, None), + Err(e) => (settings::Settings::default(), Some(e)), + }; + let restore_voice_keys = settings.start_voice_keys(error.is_some()); + app.manage(state::Runtime::new( + settings, + path, + error, + app.path().app_data_dir()?, + )); + initialize_keys(app.handle()); + app.state::() + .foreground_hook + .store(platform::watch_foreground() as u64, Ordering::SeqCst); + if restore_voice_keys { + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + if let Some(window) = handle.get_webview_window("main") { + if let Err(error) = set_key_test(window, true).await { + *handle + .state::() + .settings_notice + .lock() + .unwrap() = format!("语音键未开启:{error}"); + pulse(&handle); + } + } + }); + } + tray::install(app)?; + recovery::start(app.handle()); + device::start(app.handle()); + Ok(()) + }) + .on_window_event(|window, event| { + if window.label() == "main" { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let state = window.state::(); + if state.tray.load(Ordering::SeqCst) + && state.settings.lock().unwrap().minimize_to_tray + { + let _ = window.hide(); + } else { + request_shutdown(window.app_handle()); + } + } + } + }) + .invoke_handler(tauri::generate_handler![ + get_snapshot, + device_routing::get_device_routing, + device_routing::set_device_routing, + device_routing::get_device_pairing, + device_routing::select_configuration_transport, + device_routing::get_device_policy, + device_routing::reset_device_pairing, + device_routing::get_device_hosts, + device_routing::get_host_aliases, + device_routing::set_host_aliases, + device_routing::report_this_host, + device_routing::manage_device_pairing, + quota::get_cards, + quota::save_cards, + quota::save_quota_key, + quota::clear_quota_key, + quota::refresh_quota, + save_settings, + test_caption, + caption_diagnostics, + quick_action, + tray_status, + dismiss_caption, + set_key_test, + start_speech, + finish_speech, + cancel_speech, + microphone_devices, + prepare_model, + cancel_model, + save_cloud_token, + clear_cloud_token, + scan_devices, + connect_device, + disconnect_device, + recovery::test_ble_link_loss, + write_profiles, + write_current_keys, + test_light, + set_hooks + ]) + .run(tauri::generate_context!()) + .expect("Unable to launch AhaKey Studio"); +} diff --git a/ahakey-desktop/src-tauri/src/state.rs b/ahakey-desktop/src-tauri/src/state.rs new file mode 100644 index 00000000..cc60be90 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/state.rs @@ -0,0 +1,175 @@ +use crate::{caption::Caption, settings::Settings, voice::Active}; +use ahakey_ble::{BleClient, BleSnapshot, DeviceInfo}; +use serde::Serialize; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, AtomicU64}, + Arc, Mutex, + }, +}; +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechStatus { + pub phase: String, + pub message: String, + pub recording: bool, +} +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadStatus { + pub busy: bool, + pub progress: f64, + pub message: String, +} +pub type KeyAction = (u64, u64, bool, Option); +#[derive(Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyObservation { + pub events: u64, + pub key: String, + pub pressed: bool, +} +pub struct Runtime { + pub settings: Mutex, + pub settings_gate: tokio::sync::Mutex<()>, + pub settings_notice: Mutex, + pub settings_change_id: Mutex>, + pub tray_controls: Mutex>, + pub foreground_hook: AtomicU64, + pub settings_path: PathBuf, + pub settings_error: Option, + pub data_dir: PathBuf, + pub caption: Mutex, + pub caption_target: Mutex>, + pub caption_placement: Mutex>, + pub key_enabled: AtomicBool, + #[cfg(windows)] + pub windows_voice_hook: Mutex>, + pub key_observation: Mutex, + pub key_write_notice: Mutex, + pub key_write_busy: AtomicBool, + pub keys_down: Mutex>, + pub key_state: Mutex, + pub generation: AtomicU64, + pub recording: AtomicBool, + pub key_epoch: AtomicU64, + pub key_sequence: AtomicU64, + pub key_actions: Mutex>>, + pub voice: Mutex>, + pub voice_gate: tokio::sync::Mutex<()>, + pub speech: Mutex, + pub ble: tokio::sync::Mutex>>, + pub ble_initialization: tokio::sync::Mutex<()>, + pub ble_connection_gate: tokio::sync::Mutex<()>, + pub ble_recovery: Mutex, + pub devices: Mutex>, + pub ble_error: Mutex>, + pub usb: Mutex, + pub usb_gate: tokio::sync::Mutex<()>, + pub transfer: Mutex>, + pub download: Mutex, + pub hook: Mutex>, + pub closing: AtomicBool, + pub tray: AtomicBool, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Snapshot { + pub version: String, + pub platform: String, + pub settings: Settings, + pub settings_path: String, + pub settings_error: Option, + pub foreground_caption_supported: bool, + pub settings_notice: String, + pub settings_change_id: Option, + pub speech_engine_ready: bool, + pub model_installed: bool, + pub model_directory: String, + pub native_key_test_supported: bool, + pub native_key_test_enabled: bool, + pub key_observation: KeyObservation, + pub key_write_notice: String, + pub ble_ready: bool, + pub device: crate::device::DeviceView, + pub usb: crate::device::UsbSnapshot, + pub ble: Option, + pub ble_error: Option, + pub ble_recovery: crate::recovery::Status, + pub devices: Vec, + pub caption: Caption, + pub speech: SpeechStatus, + pub download: DownloadStatus, + pub cloud_configured: bool, + pub hook_port: Option, + pub hook_last_event: Option, + pub auto_insert_supported: bool, +} +impl Runtime { + pub fn new( + settings: Settings, + path: PathBuf, + error: Option, + data_dir: PathBuf, + ) -> Self { + let recovery = crate::recovery::Recovery::new(settings.saved_device.clone()); + Self { + settings: Mutex::new(settings), + settings_gate: tokio::sync::Mutex::new(()), + settings_notice: Mutex::new(String::new()), + settings_change_id: Mutex::new(None), + tray_controls: Mutex::new(None), + foreground_hook: AtomicU64::new(0), + settings_path: path, + settings_error: error, + data_dir, + caption: Mutex::new(Caption::idle()), + caption_target: Mutex::new(None), + caption_placement: Mutex::new(None), + key_enabled: AtomicBool::new(false), + #[cfg(windows)] + windows_voice_hook: Mutex::new(None), + key_observation: Mutex::new(KeyObservation::default()), + key_write_busy: AtomicBool::new(false), + key_write_notice: Mutex::new("本次运行尚未写入四键;本机保存不等于键盘已更新".into()), + keys_down: Mutex::new(Default::default()), + key_state: Mutex::new(Default::default()), + generation: AtomicU64::new(0), + recording: AtomicBool::new(false), + voice: Mutex::new(None), + voice_gate: tokio::sync::Mutex::new(()), + key_epoch: AtomicU64::new(0), + key_sequence: AtomicU64::new(0), + key_actions: Mutex::new(None), + speech: Mutex::new(SpeechStatus { + phase: "idle".into(), + message: "按需启用识别;没有自动录音".into(), + recording: false, + }), + ble: tokio::sync::Mutex::new(None), + ble_initialization: tokio::sync::Mutex::new(()), + ble_connection_gate: tokio::sync::Mutex::new(()), + ble_recovery: Mutex::new(recovery), + devices: Mutex::new(vec![]), + ble_error: Mutex::new(None), + usb: Mutex::new(crate::device::UsbSnapshot::default()), + usb_gate: tokio::sync::Mutex::new(()), + transfer: Mutex::new(None), + download: Mutex::new(DownloadStatus { + busy: false, + progress: 0.0, + message: String::new(), + }), + hook: Mutex::new(None), + closing: AtomicBool::new(false), + tray: AtomicBool::new(false), + } + } + pub fn model_store(&self) -> ahakey_speech::ModelStore { + ahakey_speech::ModelStore::new(self.data_dir.join("models/sensevoice-int8-2024-07-17")) + } + pub fn credentials(&self) -> ahakey_cloud::CredentialStore { + ahakey_cloud::CredentialStore::new(self.data_dir.join("credentials")) + } +} diff --git a/ahakey-desktop/src-tauri/src/tray.rs b/ahakey-desktop/src-tauri/src/tray.rs new file mode 100644 index 00000000..d891ccb5 --- /dev/null +++ b/ahakey-desktop/src-tauri/src/tray.rs @@ -0,0 +1,371 @@ +use crate::{backend, settings::Provider, state::Runtime}; +use tauri::{ + menu::{CheckMenuItem, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}, + Emitter, Manager, +}; + +// A transparent monochrome waveform lets macOS supply menu-bar contrast. +#[cfg(any(target_os = "macos", test))] +fn macos_tray_icon() -> tauri::image::Image<'static> { + let mut rgba = vec![0; 44 * 44 * 4]; + for (x, y, w, h) in [ + (8, 17, 4, 10), + (14, 11, 4, 22), + (20, 7, 4, 30), + (26, 11, 4, 22), + (32, 17, 4, 10), + ] { + for row in y..y + h { + for col in x..x + w { + rgba[(row * 44 + col) * 4 + 3] = 255; + } + } + } + tauri::image::Image::new_owned(rgba, 44, 44) +} + +#[derive(Clone)] +pub struct Controls { + providers: Vec<(Provider, CheckMenuItem)>, + profiles: Vec<(String, CheckMenuItem)>, + captions: CheckMenuItem, + voices_menu: Submenu, + profiles_menu: Submenu, + last: Option<(Provider, String, bool)>, +} +fn provider_label(provider: &Provider) -> &'static str { + match provider { + Provider::Wechat => "微信输入法", + Provider::WindowsNative => "Windows 听写", + Provider::Local => "本地 SenseVoice", + Provider::Doubao => "豆包云端", + } +} +fn provider_id(provider: &Provider) -> &'static str { + match provider { + Provider::Wechat => "wechat", + Provider::WindowsNative => "windows-native", + Provider::Local => "local", + Provider::Doubao => "doubao", + } +} +#[derive(Debug, PartialEq)] +enum Action { + Provider(Provider), + Profile(String), + Captions, + Preview, + Open, + Quit, +} +fn parse(id: &str) -> Option { + Some(match id { + "provider:wechat" => Action::Provider(Provider::Wechat), + "provider:windows-native" => Action::Provider(Provider::WindowsNative), + "provider:local" => Action::Provider(Provider::Local), + "provider:doubao" => Action::Provider(Provider::Doubao), + "captions" => Action::Captions, + "caption-preview" => Action::Preview, + "open" => Action::Open, + "quit" => Action::Quit, + _ => { + let profile = id.strip_prefix("profile:")?; + if !crate::settings::default_profiles() + .iter() + .any(|p| p.id == profile) + { + return None; + } + Action::Profile(profile.into()) + } + }) +} +fn show_main(app: &tauri::AppHandle) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } +} +pub async fn apply_action(app: &tauri::AppHandle, id: &str) -> Result<(), String> { + let action = parse(id).ok_or("不支持的快捷操作")?; + let result = match action { + Action::Open => { + show_main(app); + Ok(()) + } + Action::Quit => { + backend::request_shutdown(app); + Ok(()) + } + Action::Provider(provider) => { + if !cfg!(windows) && matches!(provider, Provider::Wechat | Provider::WindowsNative) { + return Err("此平台不支持该输入法".into()); + } + backend::apply_settings(app, backend::SettingsEdit::Provider(provider)) + .await + .map(|_| ()) + } + Action::Profile(profile) => { + backend::apply_settings(app, backend::SettingsEdit::Profile(profile)) + .await + .map(|_| ()) + } + Action::Captions => { + let settings = + backend::apply_settings(app, backend::SettingsEdit::ToggleCaptions).await?; + if settings.captions_enabled { + backend::preview_caption(app) + } else { + Ok(()) + } + } + Action::Preview => { + let enabled = app + .state::() + .settings + .lock() + .unwrap() + .captions_enabled; + if !enabled { + backend::apply_settings(app, backend::SettingsEdit::ToggleCaptions).await?; + } + backend::preview_caption(app) + } + }; + sync_inner(app, true); + result +} +pub fn install(app: &tauri::App) -> tauri::Result<()> { + let settings = app.state::().settings.lock().unwrap().clone(); + let mut providers = vec![]; + for provider in [ + Provider::Wechat, + Provider::WindowsNative, + Provider::Local, + Provider::Doubao, + ] { + if !cfg!(windows) && matches!(provider, Provider::Wechat | Provider::WindowsNative) { + continue; + } + let item = CheckMenuItem::with_id( + app, + format!("provider:{}", provider_id(&provider)), + provider_label(&provider), + true, + settings.provider == provider, + None::<&str>, + )?; + providers.push((provider, item)); + } + let voices_menu = Submenu::with_items( + app, + "语音识别", + true, + &providers + .iter() + .map(|(_, i)| i as &dyn IsMenuItem) + .collect::>(), + )?; + let profiles = settings + .profiles + .iter() + .map(|p| { + Ok(( + p.id.clone(), + CheckMenuItem::with_id( + app, + format!("profile:{}", p.id), + &p.name, + true, + p.id == settings.active_profile, + None::<&str>, + )?, + )) + }) + .collect::>>()?; + let profiles_menu = Submenu::with_items( + app, + "Profile", + true, + &profiles + .iter() + .map(|(_, i)| i as &dyn IsMenuItem) + .collect::>(), + )?; + let captions = CheckMenuItem::with_id( + app, + "captions", + "桌面字幕", + true, + settings.captions_enabled, + None::<&str>, + )?; + let preview = MenuItem::with_id(app, "caption-preview", "预览字幕位置", true, None::<&str>)?; + let open = MenuItem::with_id(app, "open", "打开 AhaKey Studio", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + let separator = PredefinedMenuItem::separator(app)?; + let separator2 = PredefinedMenuItem::separator(app)?; + let menu = Menu::with_items( + app, + &[ + &open, + &separator, + &voices_menu, + &profiles_menu, + &captions, + &preview, + &separator2, + &quit, + ], + )?; + *app.state::().tray_controls.lock().unwrap() = Some(Controls { + providers, + profiles, + captions, + voices_menu, + profiles_menu, + last: None, + }); + { + #[cfg(target_os = "macos")] + let icon = macos_tray_icon(); + #[cfg(not(target_os = "macos"))] + let icon = tauri::include_image!("icons/icon.png"); + let tray = tauri::tray::TrayIconBuilder::with_id("main-tray") + .icon(icon) + .icon_as_template(cfg!(target_os = "macos")) + .menu(&menu) + .tooltip("AhaKey Studio · 语音 / Profile / 字幕") + .on_menu_event(|app, event| { + let app = app.clone(); + let id = event.id.as_ref().to_owned(); + tauri::async_runtime::spawn(async move { + if let Err(error) = apply_action(&app, &id).await { + sync_inner(&app, true); + let _ = app.emit("native-error", error); + show_main(&app); + } + }); + }) + .build(app)?; + debug_assert_eq!(tray.id().as_ref(), "main-tray"); + app.state::() + .tray + .store(true, std::sync::atomic::Ordering::SeqCst); + } + sync_inner(app.handle(), true); + Ok(()) +} +pub fn sync(app: &tauri::AppHandle) { + sync_inner(app, false) +} + +#[cfg(test)] +mod icon_tests { + #[test] + fn macos_bundle_overlay_matches_tauri_schema() { + let mut base: serde_json::Value = + serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + let overlay: serde_json::Value = + serde_json::from_str(include_str!("../tauri.macos.conf.json")).unwrap(); + for (key, value) in overlay["bundle"].as_object().unwrap() { + base["bundle"][key] = value.clone(); + } + let _: tauri::Config = serde_json::from_value(base).unwrap(); + } + #[test] + fn menu_bar_template_is_visible_monochrome_and_transparent() { + let icon = super::macos_tray_icon(); + assert_eq!((icon.width(), icon.height()), (44, 44)); + let pixels: Vec<_> = icon.rgba().chunks_exact(4).collect(); + assert!(pixels.iter().all(|p| p[..3] == [0, 0, 0])); + assert!(pixels.iter().any(|p| p[3] == 255)); + assert!(pixels.iter().any(|p| p[3] == 0)); + assert_eq!(pixels[0][3], 0); + } +} +fn sync_inner(app: &tauri::AppHandle, force: bool) { + let app2 = app.clone(); + let _ = app.run_on_main_thread(move || { + let state = app2.state::(); + let settings = state.settings.lock().unwrap().clone(); + let key = ( + settings.provider.clone(), + settings.active_profile.clone(), + settings.captions_enabled, + ); + let controls = { + let mut controls = state.tray_controls.lock().unwrap(); + let Some(c) = controls.as_mut() else { return }; + if !force && c.last.as_ref() == Some(&key) { + return; + } + c.last = Some(key); + c.clone() + }; + for (provider, item) in &controls.providers { + let _ = item.set_checked(*provider == settings.provider); + } + for (id, item) in &controls.profiles { + let _ = item.set_checked(*id == settings.active_profile); + } + let _ = controls.captions.set_checked(settings.captions_enabled); + let _ = controls + .voices_menu + .set_text(format!("语音识别 · {}", provider_label(&settings.provider))); + if let Some(profile) = settings + .profiles + .iter() + .find(|p| p.id == settings.active_profile) + { + let _ = controls + .profiles_menu + .set_text(format!("Profile · {}", profile.name)); + } + }); +} +#[tauri::command] +pub async fn quick_action(window: tauri::WebviewWindow, action: String) -> Result<(), String> { + backend::require_main(&window)?; + apply_action(window.app_handle(), &action).await +} +#[tauri::command] +pub fn tray_status(window: tauri::WebviewWindow) -> Result { + backend::require_main(&window)?; + let controls = window + .state::() + .tray_controls + .lock() + .unwrap() + .clone() + .ok_or("托盘菜单不可用")?; + let providers=controls.providers.iter().map(|(p,item)|Ok(serde_json::json!({"id":provider_id(p),"checked":item.is_checked().map_err(|e|e.to_string())?}))).collect::,String>>()?; + let profiles = controls + .profiles + .iter() + .map(|(p, item)| { + Ok(serde_json::json!({"id":p,"checked":item.is_checked().map_err(|e|e.to_string())?})) + }) + .collect::, String>>()?; + Ok( + serde_json::json!({"providers":providers,"profiles":profiles,"captions":controls.captions.is_checked().map_err(|e|e.to_string())?}), + ) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_known_actions_are_dispatched() { + assert_eq!( + parse("provider:wechat"), + Some(Action::Provider(Provider::Wechat)) + ); + assert_eq!( + parse("profile:chatgpt-app"), + Some(Action::Profile("chatgpt-app".into())) + ); + assert_eq!(parse("captions"), Some(Action::Captions)); + assert!(parse("profile:arbitrary").is_none()); + assert!(parse("run anything").is_none()); + } +} diff --git a/ahakey-desktop/src-tauri/tauri.conf.json b/ahakey-desktop/src-tauri/tauri.conf.json new file mode 100644 index 00000000..5ef8ff7b --- /dev/null +++ b/ahakey-desktop/src-tauri/tauri.conf.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "AhaKey Studio", + "version": "1.1.5", + "identifier": "ai.ahakey.studio", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://127.0.0.1:1420", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "AhaKey Studio", + "width": 960, + "height": 680, + "minWidth": 680, + "minHeight": 560 + }, + { + "label": "caption", + "url": "index.html?caption=1", + "title": "AhaKey 字幕", + "width": 560, + "height": 106, + "visible": false, + "decorations": false, + "resizable": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "focus": false, + "focusable": false + } + ], + "security": { + "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; img-src 'self' data:; style-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-src 'none'" + } + }, + "bundle": { + "active": false, + "icon": ["icons/icon.png", "icons/icon.icns", "icons/icon.ico"] + } +} diff --git a/ahakey-desktop/src-tauri/tauri.macos.conf.json b/ahakey-desktop/src-tauri/tauri.macos.conf.json new file mode 100644 index 00000000..6ada3808 --- /dev/null +++ b/ahakey-desktop/src-tauri/tauri.macos.conf.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "active": true, + "targets": ["app"], + "macOS": { "infoPlist": "Info.plist" } + } +} From 5e6ccb12a6ad878fda7e0aca8a6f5188f75fe7e7 Mon Sep 17 00:00:00 2001 From: Lihao Date: Mon, 14 Sep 2026 02:58:54 -0700 Subject: [PATCH 11/21] build(client): add packaging checks, Windows CI and usage guides Document platform limits and add the build and packaging verification workflow. --- .github/workflows/rust-client.yml | 64 +++++++++++ ahakey-desktop/FIRMWARE.md | 74 ++++++++++++ ahakey-desktop/README.md | 131 ++++++++++++++++++++++ ahakey-desktop/scripts/build-windows.ps1 | 37 ++++++ ahakey-desktop/scripts/packaging.test.mjs | 25 +++++ 5 files changed, 331 insertions(+) create mode 100644 .github/workflows/rust-client.yml create mode 100644 ahakey-desktop/FIRMWARE.md create mode 100644 ahakey-desktop/README.md create mode 100644 ahakey-desktop/scripts/build-windows.ps1 create mode 100644 ahakey-desktop/scripts/packaging.test.mjs diff --git a/.github/workflows/rust-client.yml b/.github/workflows/rust-client.yml new file mode 100644 index 00000000..537b571a --- /dev/null +++ b/.github/workflows/rust-client.yml @@ -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 diff --git a/ahakey-desktop/FIRMWARE.md b/ahakey-desktop/FIRMWARE.md new file mode 100644 index 00000000..cd52f213 --- /dev/null +++ b/ahakey-desktop/FIRMWARE.md @@ -0,0 +1,74 @@ +# Community firmware for AhaKey X1 + +## Identity and compatibility + +- Hardware: **AhaKey X1, CH582M**. Do not flash a generic WCH development board, + a different AhaKey model or an unidentified hardware revision. +- Firmware: **0.1.17**, unofficial, community, experimental. +- Companion client: AhaKey Studio **1.1.5**. The two version numbers are independent. +- Download: [fork Release](https://github.com/lgcyaxi/AhakeyAI/releases/tag/ahakey-studio-1.1.5). +- File: `AhaKey-X1-DualBLE-0.1.17.hex`. +- SHA-256: `771efe5a1d16bd8530c3cd0dd64931c400a757f6bc18ff1175418f9530dd67e8`. + +The HEX contains application code, not chip configuration or DataFlash records. +The client neither downloads nor flashes it automatically. Building the Rust +client does not need any firmware source or binary. + +## Changes and limits + +USB and two BLE hosts may remain connected; physical input goes only to the +selected destination. In host-switch mode, attaching USB replaces the upper +lever's BLE destination; the lower BLE destination stays fixed. Removing USB +restores the upper BLE slot. Approval-lever mode remains optional. + +Only an eligible selected, unpaired BLE slot is discoverable for new pairing. +Bonded peers have a separate reconnect path. USB attachment is not a command to +disconnect an existing BLE link. The client shows separate A/B status, labels +and recent faults. Client pairing resets require USB; a physical long press +resets the selected BLE slot, while selecting USB does not clear either bond. + +Known-peer encryption-handshake timeouts enter bounded recovery; invalid +identity, key-size and explicit security failures remain blocked. Recovery is +not a guarantee that an OS will reconnect without user intervention. + +On battery, about 60 seconds of suitable inactivity turns off the screen and +lights while BLE and input services continue. A key or lever wakes the display +without discarding the first input. USB power keeps presentation awake. +MCU deep sleep is disabled; battery current and total runtime are not measured. + +The maintainer reports stable current dual-host use and satisfactory standby. +This is user-reported testing, not universal interoperability certification. +Image/card preview exists in the client, but uploading custom images or quota +cards to the device is not implemented in this release. + +## Flashing and recovery + +Experimental flashing can make input unavailable, invalidate pairing, or require +manual recovery. Keep another keyboard available and follow the vendor's +[official firmware instructions](https://github.com/AhakeyAI/firmware). +Use the exact X1 target and verified HEX, not a WCH demo image. + +The tested update procedure preserves the existing chip configuration and +DataFlash: do not enable RST-as-reset, serial keyless download, or clear DataFlash +as part of this update. Do not improvise pin shorts or change protection bits. +Use the vendor's procedure to enter ISP, download, verify, and restart. + +Keep the [official X1 v1.1.0 recovery Release](https://github.com/AhakeyAI/firmware/releases/tag/AhaKey-X1-v1.1.0) +available before flashing. Its `HID_Keyboard_582m_vibe_coding.hex` SHA-256 is +`09f4b60751c0bfcb374e5c62f6be5a2b4fa180f6d7dc623b8e8ca46d1d34205a`. +Rollback may require pairing again; it does not promise to restore every setting. + +## Permission and attribution + +Hardware and board implementation: [AhaKey](https://github.com/AhakeyAI). +[Written permission, 2026-09-13](https://github.com/AhakeyAI/desktop/issues/63#issuecomment-5653215712) +allows this project's modified compiled unofficial HEX in the maintainer's fork +Releases for personal, noncommercial research. It does **not** authorize publishing +controlled source, schematics, or modifications derived from controlled source. +Commercial use or disclosure outside that scope needs additional permission. +This notice does not relicense third-party components. + +The Release includes `AhaKey-X1-0.1.17-NOTICES.txt` and +`AhaKey-X1-0.1.17-Apache-2.0.txt` with WCH, MultiButton and LwRB attribution. +Preserve these notices with the binary. This is not an official AhaKey firmware, +and no warranty of fitness or recoverability is provided. diff --git a/ahakey-desktop/README.md b/ahakey-desktop/README.md new file mode 100644 index 00000000..9d45b3c4 --- /dev/null +++ b/ahakey-desktop/README.md @@ -0,0 +1,131 @@ +# AhaKey Studio: Rust client + +Rust + Tauri 2 + React client, version 1.1.5. 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. +This is a community client for hardware by [AhaKey](https://github.com/AhakeyAI), +not an official replacement for the original desktop suite. + +## Companion firmware + +The [1.1.5 community Release](https://github.com/lgcyaxi/AhakeyAI/releases/tag/ahakey-studio-1.1.5) +also offers **unofficial experimental firmware 0.1.17 for AhaKey X1 (CH582M)**. +It adds simultaneous USB/two-BLE links, single-destination lever routing, +bonded reconnect recovery and battery-only screen/light standby. The maintainer +reports stable dual-host use; interoperability with every host is not guaranteed. +Firmware and client versions are independent. No firmware is flashed by this app. + +Flashing may lose input or pairing, or require recovery. Read the +[firmware guide](https://github.com/lgcyaxi/AhakeyAI/blob/main/ahakey-desktop/FIRMWARE.md) +and keep the official recovery HEX before proceeding. Binary distribution in +the fork Release is permitted for personal, noncommercial research under +[AhaKey's written permission](https://github.com/AhakeyAI/desktop/issues/63#issuecomment-5653215712). +Controlled firmware source and schematics are not part of this project. + +## 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: + fixed upper/lower BLE slots, with USB replacing the upper target while attached, + or preserve the approval lever. Slot labels and connection diagnostics are + separate from pairing; pairing resets in the client require USB. + Unsupported firmware does not receive fabricated successful-save feedback. + Concurrent links require compatible device firmware; they are not implemented + by the client alone. +- 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 + +### macOS app and menu-bar icons + +Use an application bundle for the Dock/Finder icon, not the bare Cargo executable. +On your Mac, first prepare the matching native speech libraries as described in +the [speech guide](crates/speech/README.md), setting `SHERPA_ONNX_LIB_DIR`. +Then run from this directory: + +```sh +pnpm install --frozen-lockfile +pnpm desktop:bundle +open 'src-tauri/target/release/bundle/macos/AhaKey Studio.app' +``` + +The macOS config embeds the existing ICNS app icon and usage descriptions. +The menu bar has a separate monochrome template icon, with contrast supplied +by macOS in light/dark mode. Failure to create the tray now reports a startup +error instead of silently skipping it. `desktop:build` remains an explicitly +unbundled developer build, not a macOS installer. + +This local-build path is not a claim of portable distribution: signed native +dylib embedding, notarization and clean-machine macOS acceptance remain pending. +Do not publish or copy the app to another machine assuming those steps are done. + +### Acceptance limits + +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. diff --git a/ahakey-desktop/scripts/build-windows.ps1 b/ahakey-desktop/scripts/build-windows.ps1 new file mode 100644 index 00000000..0a4f871b --- /dev/null +++ b/ahakey-desktop/scripts/build-windows.ps1 @@ -0,0 +1,37 @@ +[CmdletBinding()] +param([switch]$DebugBuild) +$ErrorActionPreference = 'Stop' +$clientRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Push-Location $clientRoot +$previousLib = $env:SHERPA_ONNX_LIB_DIR +$previousJobs = $env:CARGO_BUILD_JOBS +try { + $env:SHERPA_ONNX_LIB_DIR = & .\crates\speech\scripts\prepare-runtime.ps1 + $env:CARGO_BUILD_JOBS = '6' + & pnpm install --frozen-lockfile + if ($LASTEXITCODE -ne 0) { throw 'Dependency restore failed' } + & pnpm build + if ($LASTEXITCODE -ne 0) { throw 'Frontend build failed' } + $buildArgs = @('build', '--locked', '--manifest-path', 'src-tauri/Cargo.toml', '--features', 'custom-protocol') + if (-not $DebugBuild) { $buildArgs += '--release' } + & cargo @buildArgs + if ($LASTEXITCODE -ne 0) { throw 'Rust build failed' } + $profile = if ($DebugBuild) { 'debug' } else { 'release' } + $version = (Get-Content -Raw package.json | ConvertFrom-Json).version + $bundle = Join-Path $clientRoot "output/AhaKey-Studio-Rust-$version-windows-x64-$profile" + if (Test-Path -LiteralPath $bundle) { throw "Output already exists; preserve it and choose a new version or move it explicitly: $bundle" } + New-Item -ItemType Directory -Path $bundle | Out-Null + Copy-Item -LiteralPath "src-tauri/target/$profile/ahakey-desktop.exe" -Destination $bundle + $dlls = @(Get-ChildItem -LiteralPath $env:SHERPA_ONNX_LIB_DIR -Filter '*.dll' -File) + if ($dlls.Count -ne 4) { throw 'Expected all four pinned native ASR DLLs' } + foreach ($dll in $dlls) { Copy-Item -LiteralPath $dll.FullName -Destination $bundle } + Copy-Item -LiteralPath 'crates/speech/runtime/licenses' -Destination $bundle -Recurse + Copy-Item -LiteralPath 'README.md' -Destination $bundle + Compress-Archive -LiteralPath $bundle -DestinationPath "$bundle.zip" + Get-FileHash -LiteralPath "$bundle.zip" -Algorithm SHA256 + Write-Output "Launch: $bundle\ahakey-desktop.exe" +} finally { + $env:SHERPA_ONNX_LIB_DIR = $previousLib + $env:CARGO_BUILD_JOBS = $previousJobs + Pop-Location +} diff --git a/ahakey-desktop/scripts/packaging.test.mjs b/ahakey-desktop/scripts/packaging.test.mjs new file mode 100644 index 00000000..0669ce10 --- /dev/null +++ b/ahakey-desktop/scripts/packaging.test.mjs @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const read = (path) => readFileSync(new URL(path, import.meta.url)); +describe("macOS icon packaging", () => { + it("uses an app bundle command and real platform icon assets", () => { + const config = JSON.parse(read("../src-tauri/tauri.conf.json").toString()); + const mac = JSON.parse(read("../src-tauri/tauri.macos.conf.json").toString()); + const pkg = JSON.parse(read("../package.json").toString()); + expect(pkg.version).toBe(config.version); + expect(pkg.scripts["desktop:bundle"]).toContain("--bundles app"); + expect(pkg.scripts["desktop:bundle"]).not.toContain("--no-bundle"); + expect(mac.bundle.active).toBe(true); + expect(mac.bundle.targets).toEqual(["app"]); + expect(config.bundle.icon).toContain("icons/icon.icns"); + const icns = read("../src-tauri/icons/icon.icns"); + expect(icns.subarray(0, 4).toString()).toBe("icns"); + expect(icns.readUInt32BE(4)).toBe(icns.length); + const png = read("../src-tauri/icons/icon.png"); + expect([...png.subarray(0, 8)]).toEqual([137,80,78,71,13,10,26,10]); + const plist = read(`../src-tauri/${mac.bundle.macOS.infoPlist}`).toString(); + expect(plist).toContain("NSMicrophoneUsageDescription"); + expect(plist).toContain("NSBluetoothAlwaysUsageDescription"); + }); +}); From f694786ff1c13d918e4d4aa2a445e5d3ee657059 Mon Sep 17 00:00:00 2001 From: Lihao Date: Thu, 17 Sep 2026 12:20:12 -0700 Subject: [PATCH 12/21] fix(ble): confirm config writes with firmware ACK and status readback write_batch only proved GATT write success, so a rejected config could still report saved. write_confirmed now awaits the matching firmware ACK on 0x7344 (docs/ble-protocol.md section 9) and surfaces rejections and timeouts; set_work_mode and set_light_brightness additionally verify via status readback. IDE state sync stays fire-and-forget. --- ahakey-desktop/crates/ble/src/lib.rs | 106 +++++++++++++++++++++- ahakey-desktop/crates/ble/src/protocol.rs | 25 +++++ 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/ahakey-desktop/crates/ble/src/lib.rs b/ahakey-desktop/crates/ble/src/lib.rs index 439a8c7b..8d459955 100644 --- a/ahakey-desktop/crates/ble/src/lib.rs +++ b/ahakey-desktop/crates/ble/src/lib.rs @@ -51,6 +51,8 @@ pub enum BleError { NotConnected, #[error("{0}")] Invalid(String), + #[error("Firmware rejected command {0:#04x} with status {1}")] + Rejected(u8, u8), } impl From for BleError { fn from(e: btleplug::Error) -> Self { @@ -143,6 +145,8 @@ impl Drop for BleClient { } const OP_TIMEOUT: Duration = Duration::from_secs(10); +/// Firmware ACK/readback on 0x7344 for config writes should answer fast. +const ACK_TIMEOUT: Duration = Duration::from_secs(2); async fn operation( cancel: &CancellationToken, label: &'static str, @@ -607,6 +611,93 @@ impl BleClient { } Ok(()) } + /// Write config frames and wait for each matching firmware ACK on 0x7344. + /// GATT write success alone proves nothing: per docs/ble-protocol.md the + /// firmware answers with a non-zero status when it rejects a command, and + /// silently ignoring that let the UI claim a rejected save succeeded. + async fn write_confirmed(&self, frames: Vec>) -> Result<()> { + let session = self.session.lock().await; + let s = session.as_ref().ok_or(BleError::NotConnected)?; + if self.status().phase != ConnectionPhase::Ready { + return Err(BleError::NotConnected); + } + let _write_guard = s.writes.lock().await; + let mut replies = s.replies.subscribe(); + for frame in frames { + let Some(&cmd) = frame.get(2) else { + return Err(BleError::Invalid("config frame missing command byte".into())); + }; + operation( + &s.cancel, + "write command", + s.peripheral + .write(&s.command, &frame, WriteType::WithResponse), + ) + .await?; + let ack = async { + loop { + let bytes = replies + .recv() + .await + .map_err(|e| BleError::Native(e.to_string()))?; + if let Some((echo, status)) = protocol::parse_ack(&bytes) { + if echo == cmd { + return Ok::(status); + } + } + } + }; + let status = tokio::select! { biased; + _=s.cancel.cancelled()=>return Err(BleError::Cancelled), + r=timeout(ACK_TIMEOUT, ack)=>r.map_err(|_|BleError::Timeout("firmware ACK"))?, + }?; + if status != 0 { + return Err(BleError::Rejected(cmd, status)); + } + // Rejected config on an inactive host must not tear down its link. + tokio::select! {biased;_=s.cancel.cancelled()=>return Err(BleError::Cancelled),_=sleep(Duration::from_millis(50))=>{}} + } + Ok(()) + } + /// Re-read device status after a confirmed write and require the expected + /// value, so mode/brightness only report success when the device shows them. + async fn confirm_status( + &self, + expect: impl Fn(&DeviceStatus) -> bool, + what: &'static str, + ) -> Result<()> { + let session = self.session.lock().await; + let s = session.as_ref().ok_or(BleError::NotConnected)?; + if self.status().phase != ConnectionPhase::Ready { + return Err(BleError::NotConnected); + } + let _write_guard = s.writes.lock().await; + let mut replies = s.replies.subscribe(); + operation( + &s.cancel, + "query status", + s.peripheral + .write(&s.command, &protocol::QUERY_STATUS, WriteType::WithResponse), + ) + .await?; + let readback = async { + loop { + let bytes = replies + .recv() + .await + .map_err(|e| BleError::Native(e.to_string()))?; + if let Some(status) = protocol::parse_status(&bytes) { + if expect(&status) { + return Ok(()); + } + } + } + }; + tokio::select! { biased; + _=s.cancel.cancelled()=>Err(BleError::Cancelled), + r=timeout(ACK_TIMEOUT, readback)=>r.map_err(|_|BleError::Timeout(what))?, + } + } pub async fn query_status(&self) -> Result<()> { self.write_batch(vec![protocol::QUERY_STATUS.to_vec()]) .await @@ -776,14 +867,14 @@ impl BleClient { active_mode: u8, brightness: u8, ) -> Result<()> { - self.write_batch(protocol::profile_frames(profiles, active_mode, brightness)?) + self.write_confirmed(protocol::profile_frames(profiles, active_mode, brightness)?) .await } pub async fn save_keys(&self, mode: u8, keys: &[KeyConfig; 4]) -> Result<()> { - self.write_batch(protocol::key_frames(mode, keys)?).await + self.write_confirmed(protocol::key_frames(mode, keys)?).await } pub async fn set_light_effect(&self, effect: u8) -> Result<()> { - self.write_batch(vec![protocol::frame(0x91, &[effect])]) + self.write_confirmed(vec![protocol::frame(0x91, &[effect])]) .await } pub async fn set_ide_state(&self, state: u8) -> Result<()> { @@ -797,13 +888,18 @@ impl BleClient { if mode > 3 { return Err(BleError::Invalid("mode must be 0..3".into())); } - self.write_batch(vec![protocol::frame(0x92, &[mode])]).await + self.write_confirmed(vec![protocol::frame(0x92, &[mode])]) + .await?; + self.confirm_status(|s| s.work_mode == mode, "work mode readback") + .await } pub async fn set_light_brightness(&self, brightness: u8) -> Result<()> { if !(1..=100).contains(&brightness) { return Err(BleError::Invalid("brightness must be 1..100".into())); } - self.write_batch(vec![protocol::frame(0x85, &[brightness])]) + self.write_confirmed(vec![protocol::frame(0x85, &[brightness])]) + .await?; + self.confirm_status(|s| s.light_brightness == brightness, "brightness readback") .await } } diff --git a/ahakey-desktop/crates/ble/src/protocol.rs b/ahakey-desktop/crates/ble/src/protocol.rs index 73fbeadd..51d267a3 100644 --- a/ahakey-desktop/crates/ble/src/protocol.rs +++ b/ahakey-desktop/crates/ble/src/protocol.rs @@ -35,6 +35,21 @@ pub fn parse_status(bytes: &[u8]) -> Option { }) } +/// Firmware ACK frame on 0x7344: AA BB CC DD. +/// Per docs/ble-protocol.md section 9, status 0 is success and any other value +/// is a rejection. Status-query data frames (13 bytes, cmd echo 0x00) are not +/// ACKs and stay handled by parse_status. +pub fn parse_ack(bytes: &[u8]) -> Option<(u8, u8)> { + let len = bytes.len(); + if len < 6 || bytes[..2] != [0xaa, 0xbb] || bytes[len - 2..] != [0xcc, 0xdd] { + return None; + } + if parse_status(bytes).is_some() { + return None; + } + Some((bytes[2], bytes[3])) +} + pub fn frame(command: u8, payload: &[u8]) -> Vec { let mut out = vec![0xaa, 0xbb, command]; out.extend_from_slice(payload); @@ -169,6 +184,16 @@ mod tests { assert_eq!(f[38], vec![0xaa, 0xbb, 4, 0xcc, 0xdd]); } #[test] + fn ack_frames_parse_and_rejections_surface() { + assert_eq!(parse_ack(&[0xaa, 0xbb, 0x73, 0, 0xcc, 0xdd]), Some((0x73, 0))); + assert_eq!(parse_ack(&[0xaa, 0xbb, 0x92, 3, 0xcc, 0xdd]), Some((0x92, 3))); + // A status-query response is data, not an ACK. + let status = [0xaa, 0xbb, 0, 46, 50, 1, 0, 1, 0, 1, 35, 0xcc, 0xdd]; + assert!(parse_ack(&status).is_none()); + assert!(parse_ack(&[0xaa, 0xbb, 0x92, 0]).is_none()); + assert!(parse_ack(&[1, 2, 3, 4, 5, 6]).is_none()); + } + #[test] fn validate_entire_batch() { let mut p = profiles(); p[3].keys[3].hid_codes = vec![1; 10]; From 3434f92f818b5e9274bd216e6d6cab240e91ad9f Mon Sep 17 00:00:00 2001 From: Lihao Date: Thu, 17 Sep 2026 12:20:12 -0700 Subject: [PATCH 13/21] fix(cloud): scope doubao credential namespace by application identity macOS/Linux keyring used a fixed service name, so preview and release identities could read, overwrite, or delete each other's token. The namespace now follows the application identifier ({identifier}.doubao), matching the quota module's isolation. Windows stays per-directory. --- .../crates/cloud/src/credentials.rs | 54 ++++++++++++------- ahakey-desktop/src-tauri/src/main.rs | 1 + ahakey-desktop/src-tauri/src/state.rs | 7 ++- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/ahakey-desktop/crates/cloud/src/credentials.rs b/ahakey-desktop/crates/cloud/src/credentials.rs index c2941c54..48c2d9a5 100644 --- a/ahakey-desktop/crates/cloud/src/credentials.rs +++ b/ahakey-desktop/crates/cloud/src/credentials.rs @@ -28,11 +28,18 @@ impl SecretToken { #[derive(Clone)] pub struct CredentialStore { directory: PathBuf, + namespace: String, } impl CredentialStore { - pub fn new(directory: impl AsRef) -> Self { + /// `identifier` is the application identity (tauri `config().identifier`). + /// It scopes the native credential namespace so preview and release builds + /// cannot read, overwrite, or delete each other's token (same isolation as + /// the quota module's `{identifier}.quota`). Windows stores per-directory + /// and ignores the namespace. + pub fn new(directory: impl AsRef, identifier: &str) -> Self { Self { directory: directory.as_ref().to_owned(), + namespace: format!("{identifier}.doubao"), } } pub fn has_token(&self) -> Result { @@ -44,13 +51,13 @@ impl CredentialStore { } pub fn save(&self, token: &str) -> Result<(), CloudError> { let token = SecretToken::new(token.to_owned())?; - platform::save(&self.directory, token.expose()) + platform::save(&self.directory, &self.namespace, token.expose()) } pub fn load(&self) -> Result { - platform::load(&self.directory) + platform::load(&self.directory, &self.namespace) } pub fn clear(&self) -> Result<(), CloudError> { - platform::clear(&self.directory) + platform::clear(&self.directory, &self.namespace) } } @@ -114,7 +121,7 @@ mod platform { }; Ok(result) } - pub fn save(directory: &Path, token: &str) -> Result<(), CloudError> { + pub fn save(directory: &Path, _: &str, token: &str) -> Result<(), CloudError> { let encrypted = crypt(token.as_bytes(), true)?; fs::create_dir_all(directory).map_err(|_| CloudError::CredentialStore)?; let temporary = directory.join(format!("doubao-token-{}.tmp", uuid::Uuid::new_v4())); @@ -135,7 +142,7 @@ mod platform { } result } - pub fn load(directory: &Path) -> Result { + pub fn load(directory: &Path, _: &str) -> Result { let f = fs::File::open(directory.join(FILE)).map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { CloudError::MissingCredential @@ -154,7 +161,7 @@ mod platform { let text = std::str::from_utf8(&plaintext).map_err(|_| CloudError::CredentialStore)?; SecretToken::new(text.to_owned()).map_err(|_| CloudError::CredentialStore) } - pub fn clear(directory: &Path) -> Result<(), CloudError> { + pub fn clear(directory: &Path, _: &str) -> Result<(), CloudError> { match fs::remove_file(directory.join(FILE)) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -166,24 +173,24 @@ mod platform { #[cfg(any(target_os = "macos", target_os = "linux"))] mod platform { use super::*; - fn entry() -> Result { - keyring::Entry::new("AhaKey Studio", "doubao-access-token") + fn entry(namespace: &str) -> Result { + keyring::Entry::new(namespace, "doubao-access-token") .map_err(|_| CloudError::CredentialStore) } - pub fn save(_: &Path, token: &str) -> Result<(), CloudError> { - entry()? + pub fn save(_: &Path, namespace: &str, token: &str) -> Result<(), CloudError> { + entry(namespace)? .set_password(token) .map_err(|_| CloudError::CredentialStore) } - pub fn load(_: &Path) -> Result { - let value = entry()?.get_password().map_err(|e| match e { + pub fn load(_: &Path, namespace: &str) -> Result { + let value = entry(namespace)?.get_password().map_err(|e| match e { keyring::Error::NoEntry => CloudError::MissingCredential, _ => CloudError::CredentialStore, })?; SecretToken::new(value).map_err(|_| CloudError::CredentialStore) } - pub fn clear(_: &Path) -> Result<(), CloudError> { - match entry()?.delete_credential() { + pub fn clear(_: &Path, namespace: &str) -> Result<(), CloudError> { + match entry(namespace)?.delete_credential() { Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Err(_) => Err(CloudError::CredentialStore), } @@ -192,13 +199,13 @@ mod platform { #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] mod platform { use super::*; - pub fn save(_: &Path, _: &str) -> Result<(), CloudError> { + pub fn save(_: &Path, _: &str, _: &str) -> Result<(), CloudError> { Err(CloudError::CredentialStore) } - pub fn load(_: &Path) -> Result { + pub fn load(_: &Path, _: &str) -> Result { Err(CloudError::CredentialStore) } - pub fn clear(_: &Path) -> Result<(), CloudError> { + pub fn clear(_: &Path, _: &str) -> Result<(), CloudError> { Err(CloudError::CredentialStore) } } @@ -214,11 +221,18 @@ mod tests { ); assert!(SecretToken::new("token\r\nX-Evil: x").is_err()); } + #[test] + fn namespace_is_scoped_by_application_identity() { + let release = CredentialStore::new("/tmp/ahakey-test", "ai.ahakey.studio"); + let preview = CredentialStore::new("/tmp/ahakey-test", "ai.ahakey.studio.preview"); + assert_ne!(release.namespace, preview.namespace); + assert!(preview.namespace.ends_with(".preview.doubao")); + } #[cfg(windows)] #[test] fn real_dpapi_roundtrip_replacement_and_clear() { let dir = tempfile::tempdir().unwrap(); - let store = CredentialStore::new(dir.path()); + let store = CredentialStore::new(dir.path(), "ai.ahakey.studio.test"); assert!(!store.has_token().unwrap()); store.save("fake-isolated-test-token").unwrap(); assert!(store.has_token().unwrap()); @@ -238,7 +252,7 @@ mod tests { #[test] fn corrupt_blob_does_not_fall_back_to_plaintext() { let dir = tempfile::tempdir().unwrap(); - let store = CredentialStore::new(dir.path()); + let store = CredentialStore::new(dir.path(), "ai.ahakey.studio.test"); std::fs::write( dir.path().join("doubao-token.dpapi"), b"fake-plaintext-token", diff --git a/ahakey-desktop/src-tauri/src/main.rs b/ahakey-desktop/src-tauri/src/main.rs index ca135980..d7f3e572 100644 --- a/ahakey-desktop/src-tauri/src/main.rs +++ b/ahakey-desktop/src-tauri/src/main.rs @@ -52,6 +52,7 @@ fn main() { path, error, app.path().app_data_dir()?, + app.config().identifier.clone(), )); initialize_keys(app.handle()); app.state::() diff --git a/ahakey-desktop/src-tauri/src/state.rs b/ahakey-desktop/src-tauri/src/state.rs index cc60be90..344aa072 100644 --- a/ahakey-desktop/src-tauri/src/state.rs +++ b/ahakey-desktop/src-tauri/src/state.rs @@ -40,6 +40,9 @@ pub struct Runtime { pub settings_path: PathBuf, pub settings_error: Option, pub data_dir: PathBuf, + /// Application identity (tauri `config().identifier`); scopes the native + /// credential namespace so preview and release never share tokens. + pub identifier: String, pub caption: Mutex, pub caption_target: Mutex>, pub caption_placement: Mutex>, @@ -112,6 +115,7 @@ impl Runtime { path: PathBuf, error: Option, data_dir: PathBuf, + identifier: String, ) -> Self { let recovery = crate::recovery::Recovery::new(settings.saved_device.clone()); Self { @@ -124,6 +128,7 @@ impl Runtime { settings_path: path, settings_error: error, data_dir, + identifier, caption: Mutex::new(Caption::idle()), caption_target: Mutex::new(None), caption_placement: Mutex::new(None), @@ -170,6 +175,6 @@ impl Runtime { ahakey_speech::ModelStore::new(self.data_dir.join("models/sensevoice-int8-2024-07-17")) } pub fn credentials(&self) -> ahakey_cloud::CredentialStore { - ahakey_cloud::CredentialStore::new(self.data_dir.join("credentials")) + ahakey_cloud::CredentialStore::new(self.data_dir.join("credentials"), &self.identifier) } } From 99ae4afab2c08ecf2514a047e064ef4c7750f7e8 Mon Sep 17 00:00:00 2001 From: Lihao Date: Thu, 17 Sep 2026 12:20:12 -0700 Subject: [PATCH 14/21] fix(voice): release key latch when sessions end without a key end-edge Toggle mode latched the key state when a session ended via error, UI stop, the 120s watchdog, or startup failure, swallowing the first retry press. KeyTest.release_session clears only the logical latch and keeps the physical press edge, so a held key cannot restart a session while the next real press starts cleanly. --- ahakey-desktop/src-tauri/src/input.rs | 27 +++++++++++++++++++++++++++ ahakey-desktop/src-tauri/src/voice.rs | 10 ++++++++++ 2 files changed, 37 insertions(+) diff --git a/ahakey-desktop/src-tauri/src/input.rs b/ahakey-desktop/src-tauri/src/input.rs index e7fa7490..b481b90e 100644 --- a/ahakey-desktop/src-tauri/src/input.rs +++ b/ahakey-desktop/src-tauri/src/input.rs @@ -8,6 +8,14 @@ pub struct KeyTest { } impl KeyTest { + /// Sync the logical latch when the voice session ends without a key + /// end-edge (error, UI stop, 120s watchdog, startup failure). Clears only + /// the session latch and preserves the physical press edge, so the next + /// press is a fresh start and a still-held key cannot restart a session. + pub fn release_session(&mut self) { + self.active = false; + } + pub fn stop(&mut self) -> Option { let changed = self.active; *self = Self::default(); @@ -67,6 +75,25 @@ mod tests { assert_eq!(input.update(false, TriggerMode::Hold), None); } #[test] + fn toggle_error_end_does_not_swallow_next_press() { + let mut input = KeyTest::default(); + assert_eq!(input.update(true, TriggerMode::Toggle), Some(true)); + assert_eq!(input.update(false, TriggerMode::Toggle), None); + input.release_session(); + assert_eq!(input.update(true, TriggerMode::Toggle), Some(true)); + assert_eq!(input.update(false, TriggerMode::Toggle), None); + assert_eq!(input.update(true, TriggerMode::Toggle), Some(false)); + } + #[test] + fn hold_error_while_held_cannot_restart_until_next_press() { + let mut input = KeyTest::default(); + assert_eq!(input.update(true, TriggerMode::Hold), Some(true)); + input.release_session(); + assert_eq!(input.update(true, TriggerMode::Hold), None); + assert_eq!(input.update(false, TriggerMode::Hold), None); + assert_eq!(input.update(true, TriggerMode::Hold), Some(true)); + } + #[test] fn toggle_ends_on_next_down_and_disable_cancels_active_session() { let mut input = KeyTest::default(); assert_eq!(input.update(true, TriggerMode::Toggle), Some(true)); diff --git a/ahakey-desktop/src-tauri/src/voice.rs b/ahakey-desktop/src-tauri/src/voice.rs index c5085c6d..95c8f33d 100644 --- a/ahakey-desktop/src-tauri/src/voice.rs +++ b/ahakey-desktop/src-tauri/src/voice.rs @@ -78,6 +78,7 @@ fn cancel_locked(app: &tauri::AppHandle) { let state = app.state::(); state.generation.fetch_add(1, Ordering::SeqCst); state.recording.store(false, Ordering::SeqCst); + state.key_state.lock().unwrap().release_session(); let active = state.voice.lock().unwrap().take(); if let Some(mut active) = active { active.cancel(); @@ -137,6 +138,9 @@ pub async fn start( } let wechat = settings.provider == Provider::Wechat; let result = crate::platform::external_toggle(wechat); + if result.is_err() { + state.key_state.lock().unwrap().release_session(); + } if result.is_ok() { *state.voice.lock().unwrap() = Some(Active::External { wechat }); state.recording.store(true, Ordering::SeqCst); @@ -184,6 +188,7 @@ pub async fn start( } Update::Final(text) => { state.recording.store(false, Ordering::SeqCst); + state.key_state.lock().unwrap().release_session(); let mut message = "识别完成".to_string(); if insert && !text.trim().is_empty() @@ -204,6 +209,8 @@ pub async fn start( } Update::Error(error) => { state.recording.store(false, Ordering::SeqCst); + // An abnormal end must not swallow the next press retry. + state.key_state.lock().unwrap().release_session(); *state.speech.lock().unwrap() = SpeechStatus { phase: "error".into(), message: error.clone(), @@ -330,6 +337,7 @@ pub async fn start( } Err(error) => { state.recording.store(false, Ordering::SeqCst); + state.key_state.lock().unwrap().release_session(); post(&tx, Update::Error(error.clone())); return Err(error); } @@ -357,6 +365,8 @@ async fn finish_generation(app: tauri::AppHandle, expected: Option) -> Resu if !state.recording.swap(false, Ordering::SeqCst) { return Ok(()); } + // UI stop and the 120s watchdog end the session without a key end-edge. + state.key_state.lock().unwrap().release_session(); // Consume ownership BEFORE sending the stop toggle. If SendInput fails or // only partly succeeds, generic cleanup must never send a second toggle. let external = take_external(&mut state.voice.lock().unwrap()); From 22bb46317b4fec73c467d238eed0685e1e90afe4 Mon Sep 17 00:00:00 2001 From: Lihao Date: Thu, 17 Sep 2026 14:26:21 -0700 Subject: [PATCH 15/21] style(ble): apply rustfmt to ACK confirmation code --- ahakey-desktop/crates/ble/src/lib.rs | 7 +++++-- ahakey-desktop/crates/ble/src/protocol.rs | 10 ++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/ahakey-desktop/crates/ble/src/lib.rs b/ahakey-desktop/crates/ble/src/lib.rs index 8d459955..b2bfcded 100644 --- a/ahakey-desktop/crates/ble/src/lib.rs +++ b/ahakey-desktop/crates/ble/src/lib.rs @@ -625,7 +625,9 @@ impl BleClient { let mut replies = s.replies.subscribe(); for frame in frames { let Some(&cmd) = frame.get(2) else { - return Err(BleError::Invalid("config frame missing command byte".into())); + return Err(BleError::Invalid( + "config frame missing command byte".into(), + )); }; operation( &s.cancel, @@ -871,7 +873,8 @@ impl BleClient { .await } pub async fn save_keys(&self, mode: u8, keys: &[KeyConfig; 4]) -> Result<()> { - self.write_confirmed(protocol::key_frames(mode, keys)?).await + self.write_confirmed(protocol::key_frames(mode, keys)?) + .await } pub async fn set_light_effect(&self, effect: u8) -> Result<()> { self.write_confirmed(vec![protocol::frame(0x91, &[effect])]) diff --git a/ahakey-desktop/crates/ble/src/protocol.rs b/ahakey-desktop/crates/ble/src/protocol.rs index 51d267a3..9659f626 100644 --- a/ahakey-desktop/crates/ble/src/protocol.rs +++ b/ahakey-desktop/crates/ble/src/protocol.rs @@ -185,8 +185,14 @@ mod tests { } #[test] fn ack_frames_parse_and_rejections_surface() { - assert_eq!(parse_ack(&[0xaa, 0xbb, 0x73, 0, 0xcc, 0xdd]), Some((0x73, 0))); - assert_eq!(parse_ack(&[0xaa, 0xbb, 0x92, 3, 0xcc, 0xdd]), Some((0x92, 3))); + assert_eq!( + parse_ack(&[0xaa, 0xbb, 0x73, 0, 0xcc, 0xdd]), + Some((0x73, 0)) + ); + assert_eq!( + parse_ack(&[0xaa, 0xbb, 0x92, 3, 0xcc, 0xdd]), + Some((0x92, 3)) + ); // A status-query response is data, not an ACK. let status = [0xaa, 0xbb, 0, 46, 50, 1, 0, 1, 0, 1, 35, 0xcc, 0xdd]; assert!(parse_ack(&status).is_none()); From 3b09c2a48f80d6082a4bb614c76867c412634426 Mon Sep 17 00:00:00 2001 From: sakruhnab1 <91109111+ZephyrKeXiner@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:40:20 +0800 Subject: [PATCH 16/21] fix(voice): preserve key latch during recording startup --- ahakey-desktop/src-tauri/src/voice.rs | 115 +++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 13 deletions(-) diff --git a/ahakey-desktop/src-tauri/src/voice.rs b/ahakey-desktop/src-tauri/src/voice.rs index 95c8f33d..f259525c 100644 --- a/ahakey-desktop/src-tauri/src/voice.rs +++ b/ahakey-desktop/src-tauri/src/voice.rs @@ -76,9 +76,15 @@ pub async fn cancel(app: &tauri::AppHandle) { } fn cancel_locked(app: &tauri::AppHandle) { let state = app.state::(); + state.key_state.lock().unwrap().release_session(); + clear_session(&state); + let _ = crate::backend::display_caption(app, "idle", "", false); +} +// Starting a new recording also clears an old session, but must preserve the +// key latch already set by the press that requested this recording. +fn clear_session(state: &Runtime) { state.generation.fetch_add(1, Ordering::SeqCst); state.recording.store(false, Ordering::SeqCst); - state.key_state.lock().unwrap().release_session(); let active = state.voice.lock().unwrap().take(); if let Some(mut active) = active { active.cancel(); @@ -88,7 +94,6 @@ fn cancel_locked(app: &tauri::AppHandle) { message: "语音已取消".into(), recording: false, }; - let _ = crate::backend::display_caption(app, "idle", "", false); } pub async fn start( app: tauri::AppHandle, @@ -98,6 +103,20 @@ pub async fn start( ) -> Result<(), String> { let state = app.state::(); let _gate = state.voice_gate.lock().await; + let result = start_locked(app.clone(), target, key_epoch, press_sequence).await; + if result.is_err() { + // Includes early failures (missing model, target or caption window). + state.key_state.lock().unwrap().release_session(); + } + result +} +async fn start_locked( + app: tauri::AppHandle, + target: Option, + key_epoch: Option, + press_sequence: Option, +) -> Result<(), String> { + let state = app.state::(); if state.closing.load(Ordering::SeqCst) || key_epoch.is_some_and(|e| { e != state.key_epoch.load(Ordering::SeqCst) || !state.key_enabled.load(Ordering::SeqCst) @@ -112,7 +131,8 @@ pub async fn start( if state.speech.lock().unwrap().phase == "transcribing" { return Err("上一句仍在识别,请稍候".into()); } - cancel_locked(&app); + clear_session(&state); + let _ = crate::backend::display_caption(&app, "idle", "", false); let settings = state.settings.lock().unwrap().clone(); if settings.provider == Provider::Local && !state.model_store().is_installed() { return Err("请先在设置中下载或导入 SenseVoice 模型".into()); @@ -231,18 +251,14 @@ pub async fn start( }); // Caption placement uses the same captured target as text insertion. // Manual UI recording has no insertion target, so preview on the main window. + #[cfg(windows)] let caption_target = target.or_else(|| { - #[cfg(windows)] - { - app.get_webview_window("main") - .and_then(|w| w.hwnd().ok()) - .map(|h| h.0 as usize) - } - #[cfg(not(windows))] - { - None - } + app.get_webview_window("main") + .and_then(|w| w.hwnd().ok()) + .map(|h| h.0 as usize) }); + #[cfg(not(windows))] + let caption_target = target; *state.caption_target.lock().unwrap() = caption_target; crate::backend::position_caption(&app, settings.caption_bottom_offset)?; state.recording.store(true, Ordering::SeqCst); @@ -453,6 +469,79 @@ async fn finish_generation(app: tauri::AppHandle, expected: Option) -> Resu #[cfg(test)] mod external_tests { use super::*; + use crate::settings::{Settings, TriggerMode}; + + fn runtime() -> Runtime { + Runtime::new( + Settings::default(), + Default::default(), + None, + Default::default(), + "ai.ahakey.voice-test".into(), + ) + } + + #[test] + fn startup_cleanup_preserves_hold_release_and_toggle_stop() { + for mode in [TriggerMode::Hold, TriggerMode::Toggle] { + let state = runtime(); + // Same order as backend::key_event_target -> voice::start_locked. + assert_eq!( + state.key_state.lock().unwrap().update(true, mode.clone()), + Some(true) + ); + clear_session(&state); + state.recording.store(true, Ordering::SeqCst); + let mut keys = state.key_state.lock().unwrap(); + assert_eq!(keys.update(true, mode.clone()), None); + if mode == TriggerMode::Hold { + assert_eq!(keys.update(false, mode), Some(false)); + } else { + assert_eq!(keys.update(false, mode.clone()), None); + assert_eq!(keys.update(true, mode), Some(false)); + } + } + } + + #[test] + fn startup_cleanup_does_not_reactivate_an_already_released_key() { + let state = runtime(); + assert_eq!( + state + .key_state + .lock() + .unwrap() + .update(true, TriggerMode::Hold), + Some(true) + ); + // A quick release may already have queued finish before start runs. + assert_eq!( + state + .key_state + .lock() + .unwrap() + .update(false, TriggerMode::Hold), + Some(false) + ); + clear_session(&state); + assert_eq!( + state + .key_state + .lock() + .unwrap() + .update(false, TriggerMode::Hold), + None + ); + assert_eq!( + state + .key_state + .lock() + .unwrap() + .update(true, TriggerMode::Hold), + Some(true) + ); + } + #[test] fn failed_stop_cannot_leave_an_external_session_for_cleanup_to_toggle_again() { let mut slot = Some(Active::External { wechat: true }); From 06904de13f86a1283340804599a8135fe5c6b8d0 Mon Sep 17 00:00:00 2001 From: sakruhnab1 <91109111+ZephyrKeXiner@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:40:20 +0800 Subject: [PATCH 17/21] fix(hooks): migrate the known Java dispatcher with a backup --- ahakey-desktop/src-tauri/src/hooks.rs | 110 +++++++++++++++++- .../src/hooks/legacy-java-dispatcher.ps1 | 47 ++++++++ 2 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 ahakey-desktop/src-tauri/src/hooks/legacy-java-dispatcher.ps1 diff --git a/ahakey-desktop/src-tauri/src/hooks.rs b/ahakey-desktop/src-tauri/src/hooks.rs index a8fc05f1..8fe1de6b 100644 --- a/ahakey-desktop/src-tauri/src/hooks.rs +++ b/ahakey-desktop/src-tauri/src/hooks.rs @@ -68,6 +68,55 @@ fn atomic(path: &Path, data: &[u8]) -> Result<(), String> { std::fs::rename(temp, path).map_err(|_| "无法发布 Hook 状态")?; Ok(()) } + +// Exact snapshot of the dispatcher generated by the Java client's TopBar. +// A generated-file header alone is not sufficient evidence to overwrite a script. +const LEGACY_DISPATCHER: &str = include_str!("hooks/legacy-java-dispatcher.ps1"); + +fn normalize_script(script: &str) -> String { + script.trim_start_matches('\u{feff}').replace("\r\n", "\n") +} + +fn ensure_dispatcher(path: &Path) -> Result<(), String> { + let existing = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return atomic(path, DISPATCHER.as_bytes()); + } + Err(error) => return Err(format!("无法读取 Hook 脚本:{error}")), + }; + let normalized = std::str::from_utf8(&existing).ok().map(normalize_script); + if normalized.as_deref() == Some(DISPATCHER) { + return Ok(()); + } + if normalized.as_deref() != Some(LEGACY_DISPATCHER) { + return Err(format!( + "现有 Hook 脚本不是兼容的分发脚本,已保留;请备份并移走 {} 后重新启用 Hook", + path.display() + )); + } + let backup = path.with_extension("legacy.ps1"); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&backup) + { + Ok(mut file) => file + .write_all(&existing) + .map_err(|e| format!("无法备份旧 Hook 脚本:{e}"))?, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if std::fs::read(&backup).ok().as_deref() != Some(existing.as_slice()) { + return Err(format!( + "旧 Hook 备份已存在且内容不同,已保留:{}", + backup.display() + )); + } + } + Err(error) => return Err(format!("无法备份旧 Hook 脚本:{error}")), + } + atomic(path, DISPATCHER.as_bytes()) +} + pub fn start( directory: PathBuf, callback: impl Fn(String, u8) + Send + Sync + 'static, @@ -90,9 +139,7 @@ pub fn start( let port = listener.local_addr().map_err(|e| e.to_string())?.port(); let descriptor=serde_json::to_vec(&json!({"schemaVersion":1,"host":"127.0.0.1","port":port,"processId":std::process::id(),"startedAt":format!("{:?}",std::time::SystemTime::now())})).unwrap(); let script = directory.join("ahakey-hook.ps1"); - if !script.exists() { - std::fs::write(&script, DISPATCHER).map_err(|_| "无法写入 Hook 分发脚本")?; - } + ensure_dispatcher(&script)?; atomic(&path, &descriptor)?; let stop = Arc::new(AtomicBool::new(false)); let last = Arc::new(Mutex::new(None)); @@ -169,6 +216,63 @@ if ($EventName -match 'PermissionRequest$') { "#; #[cfg(test)] mod tests { + use super::*; + + #[test] + fn java_dispatcher_is_backed_up_and_events_reach_the_published_listener() { + let directory = tempfile::tempdir().unwrap(); + let script = directory.path().join("ahakey-hook.ps1"); + let legacy = format!("\u{feff}{}", LEGACY_DISPATCHER.replace('\n', "\r\n")); + std::fs::write(&script, &legacy).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let server = start(directory.path().to_owned(), move |name, state| { + tx.send((name, state)).unwrap(); + }) + .unwrap(); + assert_eq!(std::fs::read_to_string(&script).unwrap(), DISPATCHER); + assert_eq!( + std::fs::read_to_string(script.with_extension("legacy.ps1")).unwrap(), + legacy + ); + let endpoint: serde_json::Value = serde_json::from_slice( + &std::fs::read(directory.path().join("active-endpoint.json")).unwrap(), + ) + .unwrap(); + assert_eq!(endpoint["port"].as_u64(), Some(server.port as u64)); + let mut stream = TcpStream::connect(("127.0.0.1", server.port)).unwrap(); + stream.write_all(b"CodexPreToolUse\n").unwrap(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(3)).unwrap(), + ("CodexPreToolUse".into(), 3) + ); + ensure_dispatcher(&script).unwrap(); // Re-enabling is idempotent. + drop(server); + assert!(!directory.path().join("active-endpoint.json").exists()); + } + + #[test] + fn custom_script_is_preserved_and_no_endpoint_is_published() { + let directory = tempfile::tempdir().unwrap(); + let script = directory.path().join("ahakey-hook.ps1"); + let custom = format!("{LEGACY_DISPATCHER}\n# User customization\n"); + std::fs::write(&script, &custom).unwrap(); + assert!(start(directory.path().to_owned(), |_, _| {}).is_err()); + assert_eq!(std::fs::read_to_string(&script).unwrap(), custom); + assert!(!directory.path().join("active-endpoint.json").exists()); + } + + #[test] + fn conflicting_backup_is_never_overwritten() { + let directory = tempfile::tempdir().unwrap(); + let script = directory.path().join("ahakey-hook.ps1"); + let backup = script.with_extension("legacy.ps1"); + std::fs::write(&script, LEGACY_DISPATCHER).unwrap(); + std::fs::write(&backup, "existing backup").unwrap(); + assert!(ensure_dispatcher(&script).is_err()); + assert_eq!(std::fs::read_to_string(script).unwrap(), LEGACY_DISPATCHER); + assert_eq!(std::fs::read_to_string(backup).unwrap(), "existing backup"); + } + #[test] fn event_mapping_does_not_execute_unknown_input() { assert_eq!(super::state_for("CodexPermissionRequest"), Some(1)); diff --git a/ahakey-desktop/src-tauri/src/hooks/legacy-java-dispatcher.ps1 b/ahakey-desktop/src-tauri/src/hooks/legacy-java-dispatcher.ps1 new file mode 100644 index 00000000..c5d1ea9d --- /dev/null +++ b/ahakey-desktop/src-tauri/src/hooks/legacy-java-dispatcher.ps1 @@ -0,0 +1,47 @@ +# AhaKey Hook Dispatcher - Auto-generated, do not edit +# Receives hook event name as argument, dispatches to AhaKey Studio via TCP. +# Compatible with Claude Code, Codex, Kimi, and Cursor hooks. +param([Parameter(Position=0)][string]$EventName) +try { + if ([Console]::IsInputRedirected) { $null = [Console]::In.ReadToEnd() } +} catch { } +try { + $tcp = New-Object System.Net.Sockets.TcpClient + $tcp.Connect('127.0.0.1', 8765) + $writer = New-Object System.IO.StreamWriter($tcp.GetStream()) + $writer.WriteLine($EventName) + $writer.Flush() + $reader = New-Object System.IO.StreamReader($tcp.GetStream()) + $response = $reader.ReadLine() + $tcp.Close() +} catch { + $response = $null +} +# Codex lifecycle hooks must output exactly {} (Codex validates JSON schema) +if ($EventName -match '^Codex' -and $EventName -ne 'CodexPermissionRequest') { + [Console]::WriteLine('{}') + exit 0 +} +# Codex PermissionRequest: output hookSpecificOutput in Codex format +if ($EventName -eq 'CodexPermissionRequest') { + $isAuto = $response -match '"autoApproved"\s*:\s*true' + if ($isAuto) { + [Console]::WriteLine('{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}') + } else { + [Console]::WriteLine('{"hookSpecificOutput":{"hookEventName":"PermissionRequest"}}') + } + exit 0 +} +# Claude PermissionRequest: output hookSpecificOutput in Claude format +if ($EventName -eq 'PermissionRequest') { + $isAuto = $response -match '"autoApproved"\s*:\s*true' + if ($isAuto) { + [Console]::WriteLine('{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}') + } else { + [Console]::WriteLine('{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"ask"}}}') + } + exit 0 +} +# Kimi / Cursor: pass through server response +if ($response) { [Console]::WriteLine($response) } else { [Console]::WriteLine('{"ok":true}') } +exit 0 From 37554551cc53cc0c6512a2b80ae5e8d13fa2c9ce Mon Sep 17 00:00:00 2001 From: sakruhnab1 <91109111+ZephyrKeXiner@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:40:20 +0800 Subject: [PATCH 18/21] fix(ble): gate lighting writes and verify config readback --- ahakey-desktop/crates/ble/src/lib.rs | 43 ++++++------- ahakey-desktop/crates/ble/src/protocol.rs | 76 +++++++++++++++++++++++ 2 files changed, 95 insertions(+), 24 deletions(-) diff --git a/ahakey-desktop/crates/ble/src/lib.rs b/ahakey-desktop/crates/ble/src/lib.rs index b2bfcded..98afdc62 100644 --- a/ahakey-desktop/crates/ble/src/lib.rs +++ b/ahakey-desktop/crates/ble/src/lib.rs @@ -622,8 +622,13 @@ impl BleClient { return Err(BleError::NotConnected); } let _write_guard = s.writes.lock().await; + // Probe on the same locked session as the writes: neither a stale + // snapshot nor a success ACK proves legacy firmware supports lighting. + if protocol::needs_lighting_support(&frames) { + protocol::require_lighting_support(&Self::read_status(s).await?)?; + } let mut replies = s.replies.subscribe(); - for frame in frames { + for frame in &frames { let Some(&cmd) = frame.get(2) else { return Err(BleError::Invalid( "config frame missing command byte".into(), @@ -633,7 +638,7 @@ impl BleClient { &s.cancel, "write command", s.peripheral - .write(&s.command, &frame, WriteType::WithResponse), + .write(&s.command, frame, WriteType::WithResponse), ) .await?; let ack = async { @@ -659,21 +664,16 @@ impl BleClient { // Rejected config on an inactive host must not tear down its link. tokio::select! {biased;_=s.cancel.cancelled()=>return Err(BleError::Cancelled),_=sleep(Duration::from_millis(50))=>{}} } + if frames + .iter() + .any(|frame| matches!(frame.get(2), Some(0x85 | 0x92))) + { + protocol::confirm_config_status(&frames, &Self::read_status(s).await?)?; + } Ok(()) } - /// Re-read device status after a confirmed write and require the expected - /// value, so mode/brightness only report success when the device shows them. - async fn confirm_status( - &self, - expect: impl Fn(&DeviceStatus) -> bool, - what: &'static str, - ) -> Result<()> { - let session = self.session.lock().await; - let s = session.as_ref().ok_or(BleError::NotConnected)?; - if self.status().phase != ConnectionPhase::Ready { - return Err(BleError::NotConnected); - } - let _write_guard = s.writes.lock().await; + /// Caller holds both session and write locks through probe, write and readback. + async fn read_status(s: &Session) -> Result { let mut replies = s.replies.subscribe(); operation( &s.cancel, @@ -689,15 +689,13 @@ impl BleClient { .await .map_err(|e| BleError::Native(e.to_string()))?; if let Some(status) = protocol::parse_status(&bytes) { - if expect(&status) { - return Ok(()); - } + return Ok(status); } } }; tokio::select! { biased; _=s.cancel.cancelled()=>Err(BleError::Cancelled), - r=timeout(ACK_TIMEOUT, readback)=>r.map_err(|_|BleError::Timeout(what))?, + r=timeout(ACK_TIMEOUT, readback)=>r.map_err(|_|BleError::Timeout("device status readback"))?, } } pub async fn query_status(&self) -> Result<()> { @@ -862,7 +860,8 @@ impl BleClient { self.routing_request(None, generation).await?; self.routing_request(Some(config), generation).await } - /// Completion means acknowledged GATT writes, not proof of persistent flash readback. + /// Requires lighting capability, firmware ACKs and mode/brightness readback. + /// The protocol does not provide persistent flash readback for the key mappings. pub async fn save_profiles( &self, profiles: &[ProfileConfig; 4], @@ -892,8 +891,6 @@ impl BleClient { return Err(BleError::Invalid("mode must be 0..3".into())); } self.write_confirmed(vec![protocol::frame(0x92, &[mode])]) - .await?; - self.confirm_status(|s| s.work_mode == mode, "work mode readback") .await } pub async fn set_light_brightness(&self, brightness: u8) -> Result<()> { @@ -901,8 +898,6 @@ impl BleClient { return Err(BleError::Invalid("brightness must be 1..100".into())); } self.write_confirmed(vec![protocol::frame(0x85, &[brightness])]) - .await?; - self.confirm_status(|s| s.light_brightness == brightness, "brightness readback") .await } } diff --git a/ahakey-desktop/crates/ble/src/protocol.rs b/ahakey-desktop/crates/ble/src/protocol.rs index 9659f626..e576db49 100644 --- a/ahakey-desktop/crates/ble/src/protocol.rs +++ b/ahakey-desktop/crates/ble/src/protocol.rs @@ -57,6 +57,45 @@ pub fn frame(command: u8, payload: &[u8]) -> Vec { out } +pub(crate) fn needs_lighting_support(frames: &[Vec]) -> bool { + frames + .iter() + .any(|frame| matches!(frame.get(2), Some(0x84 | 0x85 | 0x91))) +} + +pub(crate) fn require_lighting_support(status: &DeviceStatus) -> Result<()> { + // Legacy firmware reserves this byte as zero and ACKs unknown lighting + // commands with success. Firmware version 1.0 alone cannot distinguish it + // from compatible community firmware, which reports brightness in 1..=100. + if !(1..=100).contains(&status.light_brightness) { + return Err(BleError::Invalid( + "固件未报告可配置灯效能力;未写入配置,请升级兼容固件或仅写入四键".into(), + )); + } + Ok(()) +} + +pub(crate) fn confirm_config_status(frames: &[Vec], status: &DeviceStatus) -> Result<()> { + for (command, actual, name) in [ + (0x85, status.light_brightness, "亮度"), + (0x92, status.work_mode, "工作模式"), + ] { + if let Some(expected) = frames + .iter() + .rev() + .find(|frame| frame.get(2) == Some(&command)) + .and_then(|frame| frame.get(3)) + { + if actual != *expected { + return Err(BleError::Invalid(format!( + "{name}回读不一致(期望 {expected},实际 {actual});未确认保存" + ))); + } + } + } + Ok(()) +} + /// Raw HID usage list (modifiers are usages E0..E7, not a modifier bitmap). #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -206,4 +245,41 @@ mod tests { assert!(profile_frames(&p, 0, 50).is_err()); assert!(profile_frames(&profiles(), 4, 50).is_err()); } + + #[test] + fn legacy_success_ack_does_not_authorize_lighting_writes() { + let legacy = parse_status(&[0xaa, 0xbb, 0, 65, 50, 1, 0, 0, 0, 0, 0, 0xcc, 0xdd]).unwrap(); + assert_eq!(parse_ack(&frame(0x91, &[0])), Some((0x91, 0))); + assert!(require_lighting_support(&legacy).is_err()); + assert!(needs_lighting_support( + &profile_frames(&profiles(), 0, 35).unwrap() + )); + assert!(needs_lighting_support(&[frame(0x91, &[2])])); + assert!(needs_lighting_support(&[frame(0x85, &[35])])); + assert!(!needs_lighting_support( + &key_frames(0, &profiles()[0].keys).unwrap() + )); + let mut compatible = legacy; + compatible.light_brightness = 35; + assert!(require_lighting_support(&compatible).is_ok()); + compatible.light_brightness = 255; + assert!(require_lighting_support(&compatible).is_err()); + } + + #[test] + fn profile_readback_requires_both_mode_and_brightness() { + let frames = profile_frames(&profiles(), 2, 70).unwrap(); + let mut status = + parse_status(&[0xaa, 0xbb, 0, 65, 50, 1, 0, 2, 0, 0, 70, 0xcc, 0xdd]).unwrap(); + assert!(confirm_config_status(&frames, &status).is_ok()); + status.light_brightness = 35; + assert!(confirm_config_status(&frames, &status).is_err()); + status.light_brightness = 70; + status.work_mode = 0; + assert!(confirm_config_status(&frames, &status).is_err()); + // Key-only writes do not claim to change mode or brightness. + assert!( + confirm_config_status(&key_frames(0, &profiles()[0].keys).unwrap(), &status).is_ok() + ); + } } From 1c10f967e7b27a01a5531997a94e92e134c13d69 Mon Sep 17 00:00:00 2001 From: sakruhnab1 <91109111+ZephyrKeXiner@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:40:20 +0800 Subject: [PATCH 19/21] chore(client): document compatibility fixes and pass macOS lint --- ahakey-desktop/README.md | 12 ++++++++++++ ahakey-desktop/src-tauri/src/device.rs | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/ahakey-desktop/README.md b/ahakey-desktop/README.md index 9d45b3c4..4b9d28cb 100644 --- a/ahakey-desktop/README.md +++ b/ahakey-desktop/README.md @@ -56,6 +56,18 @@ Controlled firmware source and schematics are not part of this project. - Tray provider/profile/caption actions; an optional loopback Hook-event receiver. Hooks never auto-approve requests or edit external harness settings. +Full profile and lighting writes first read the connected device's brightness +capability. Legacy firmware that leaves this field at zero is rejected before +configuration writes, even if it would acknowledge unsupported commands as +successful. Key-only writes remain available. Mode and brightness changes also +require matching status readback on the same connection. + +When enabling Hooks after using the Java client, an exact match of its generated +`~/.ahakey/hooks/ahakey-hook.ps1` is backed up as `ahakey-hook.legacy.ps1` and +replaced with the dispatcher that reads `active-endpoint.json`. Modified or +unrecognized scripts and conflicting backups are preserved; enabling Hooks +reports an error with the path to resolve instead of claiming success. + 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 diff --git a/ahakey-desktop/src-tauri/src/device.rs b/ahakey-desktop/src-tauri/src/device.rs index 2c961c83..4f439c92 100644 --- a/ahakey-desktop/src-tauri/src/device.rs +++ b/ahakey-desktop/src-tauri/src/device.rs @@ -7,6 +7,7 @@ use serde::Serialize; use tauri::Manager; #[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[cfg_attr(not(windows), derive(Default))] #[serde(rename_all = "camelCase")] pub struct UsbSnapshot { pub supported: bool, @@ -14,10 +15,11 @@ pub struct UsbSnapshot { pub status: Option, pub error: Option, } +#[cfg(windows)] impl Default for UsbSnapshot { fn default() -> Self { Self { - supported: cfg!(windows), + supported: true, present: None, status: None, error: None, From c35fca1fe283cbcdce1deb293f1081f8a1bf00fd Mon Sep 17 00:00:00 2001 From: sakruhnab1 <91109111+ZephyrKeXiner@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:49:00 +0800 Subject: [PATCH 20/21] refactor(client): isolate Tauri app under ahakey-studio-tauri --- .../{rust-client.yml => ahakey-studio-tauri.yml} | 14 +++++++------- .../.gitignore | 0 .../FIRMWARE.md | 0 {ahakey-desktop => ahakey-studio-tauri}/README.md | 5 +++-- .../crates/ble/.gitignore | 0 .../crates/ble/Cargo.toml | 0 .../crates/ble/README.md | 0 .../crates/ble/examples/probe.rs | 0 .../crates/ble/examples/usb_routing.rs | 0 .../crates/ble/src/host_info.rs | 0 .../crates/ble/src/lib.rs | 0 .../crates/ble/src/native_windows.rs | 0 .../crates/ble/src/protocol.rs | 0 .../crates/ble/src/reset.rs | 0 .../crates/ble/src/routing.rs | 0 .../crates/ble/src/usb_routing.rs | 0 .../crates/cloud/.gitignore | 0 .../crates/cloud/Cargo.toml | 0 .../crates/cloud/README.md | 0 .../crates/cloud/src/credentials.rs | 0 .../crates/cloud/src/lib.rs | 0 .../crates/cloud/src/protocol.rs | 0 .../crates/cloud/src/session.rs | 0 .../crates/speech/.gitignore | 0 .../crates/speech/Cargo.toml | 0 .../crates/speech/README.md | 0 .../crates/speech/build.rs | 0 .../speech/licenses/onnxruntime-LICENSE.txt | 0 .../speech/licenses/sherpa-onnx-LICENSE.txt | 0 .../crates/speech/scripts/prepare-runtime.ps1 | 0 .../crates/speech/src/audio.rs | 0 .../crates/speech/src/lib.rs | 0 .../crates/speech/src/model.rs | 0 .../crates/speech/src/recognizer.rs | 0 .../crates/speech/src/session.rs | 0 .../crates/speech/tests/known_answer.rs | 0 .../index.html | 0 .../package.json | 0 .../pnpm-lock.yaml | 0 .../public/favicon.svg | 0 .../scripts/build-windows.ps1 | 0 .../scripts/packaging.test.mjs | 0 .../scripts/probe-caption-monitors.ps1 | 0 .../src-tauri/Cargo.lock | 0 .../src-tauri/Cargo.toml | 0 .../src-tauri/Info.plist | 0 .../src-tauri/build.rs | 0 .../src-tauri/capabilities/default.json | 0 .../src-tauri/icons/icon.icns | Bin .../src-tauri/icons/icon.ico | Bin .../src-tauri/icons/icon.png | Bin .../src-tauri/icons/icon.svg | 0 .../src-tauri/src/backend.rs | 0 .../src-tauri/src/caption.rs | 0 .../src-tauri/src/device.rs | 0 .../src-tauri/src/device_routing.rs | 0 .../src-tauri/src/hooks.rs | 0 .../src/hooks/legacy-java-dispatcher.ps1 | 0 .../src-tauri/src/host_notes.rs | 0 .../src-tauri/src/input.rs | 0 .../src-tauri/src/keys.rs | 0 .../src-tauri/src/main.rs | 0 .../src-tauri/src/platform.rs | 0 .../src-tauri/src/quota/mod.rs | 0 .../src-tauri/src/quota/model.rs | 0 .../src-tauri/src/recovery.rs | 0 .../src-tauri/src/settings.rs | 0 .../src-tauri/src/state.rs | 0 .../src-tauri/src/tray.rs | 0 .../src-tauri/src/voice.rs | 0 .../src-tauri/src/windows_voice_keys.rs | 0 .../src-tauri/tauri.conf.json | 0 .../src-tauri/tauri.macos.conf.json | 0 .../src/DisplayPanel.tsx | 0 .../src/FourKeysPanel.tsx | 0 .../src/LivePanels.tsx | 0 .../src/ProviderPicker.tsx | 0 .../src/RoutingPanel.tsx | 0 .../src/contracts.test.ts | 0 .../src/contracts.ts | 0 .../src/device.test.ts | 0 .../src/display.test.ts | 0 .../src/display.ts | 0 .../src/host-info.test.ts | 0 .../src/main.tsx | 0 .../src/routing.test.ts | 0 .../src/routing.ts | 0 .../src/styles.css | 0 .../tsconfig.json | 0 .../vite.config.ts | 0 90 files changed, 10 insertions(+), 9 deletions(-) rename .github/workflows/{rust-client.yml => ahakey-studio-tauri.yml} (86%) rename {ahakey-desktop => ahakey-studio-tauri}/.gitignore (100%) rename {ahakey-desktop => ahakey-studio-tauri}/FIRMWARE.md (100%) rename {ahakey-desktop => ahakey-studio-tauri}/README.md (97%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/.gitignore (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/Cargo.toml (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/README.md (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/examples/probe.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/examples/usb_routing.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/src/host_info.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/src/lib.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/src/native_windows.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/src/protocol.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/src/reset.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/src/routing.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/ble/src/usb_routing.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/cloud/.gitignore (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/cloud/Cargo.toml (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/cloud/README.md (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/cloud/src/credentials.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/cloud/src/lib.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/cloud/src/protocol.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/cloud/src/session.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/.gitignore (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/Cargo.toml (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/README.md (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/build.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/licenses/onnxruntime-LICENSE.txt (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/licenses/sherpa-onnx-LICENSE.txt (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/scripts/prepare-runtime.ps1 (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/src/audio.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/src/lib.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/src/model.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/src/recognizer.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/src/session.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/crates/speech/tests/known_answer.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/index.html (100%) rename {ahakey-desktop => ahakey-studio-tauri}/package.json (100%) rename {ahakey-desktop => ahakey-studio-tauri}/pnpm-lock.yaml (100%) rename {ahakey-desktop => ahakey-studio-tauri}/public/favicon.svg (100%) rename {ahakey-desktop => ahakey-studio-tauri}/scripts/build-windows.ps1 (100%) rename {ahakey-desktop => ahakey-studio-tauri}/scripts/packaging.test.mjs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/scripts/probe-caption-monitors.ps1 (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/Cargo.lock (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/Cargo.toml (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/Info.plist (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/build.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/capabilities/default.json (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/icons/icon.icns (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/icons/icon.ico (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/icons/icon.png (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/icons/icon.svg (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/backend.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/caption.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/device.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/device_routing.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/hooks.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/hooks/legacy-java-dispatcher.ps1 (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/host_notes.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/input.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/keys.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/main.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/platform.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/quota/mod.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/quota/model.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/recovery.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/settings.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/state.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/tray.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/voice.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/src/windows_voice_keys.rs (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/tauri.conf.json (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src-tauri/tauri.macos.conf.json (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/DisplayPanel.tsx (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/FourKeysPanel.tsx (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/LivePanels.tsx (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/ProviderPicker.tsx (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/RoutingPanel.tsx (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/contracts.test.ts (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/contracts.ts (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/device.test.ts (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/display.test.ts (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/display.ts (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/host-info.test.ts (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/main.tsx (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/routing.test.ts (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/routing.ts (100%) rename {ahakey-desktop => ahakey-studio-tauri}/src/styles.css (100%) rename {ahakey-desktop => ahakey-studio-tauri}/tsconfig.json (100%) rename {ahakey-desktop => ahakey-studio-tauri}/vite.config.ts (100%) diff --git a/.github/workflows/rust-client.yml b/.github/workflows/ahakey-studio-tauri.yml similarity index 86% rename from .github/workflows/rust-client.yml rename to .github/workflows/ahakey-studio-tauri.yml index 537b571a..90ea19f4 100644 --- a/.github/workflows/rust-client.yml +++ b/.github/workflows/ahakey-studio-tauri.yml @@ -1,14 +1,14 @@ -name: Rust client +name: AhaKey Studio Tauri on: pull_request: paths: - - "ahakey-desktop/**" - - ".github/workflows/rust-client.yml" + - "ahakey-studio-tauri/**" + - ".github/workflows/ahakey-studio-tauri.yml" push: paths: - - "ahakey-desktop/**" - - ".github/workflows/rust-client.yml" + - "ahakey-studio-tauri/**" + - ".github/workflows/ahakey-studio-tauri.yml" workflow_dispatch: permissions: @@ -24,7 +24,7 @@ jobs: runs-on: windows-latest defaults: run: - working-directory: ahakey-desktop + working-directory: ahakey-studio-tauri shell: pwsh steps: - uses: actions/checkout@v4 @@ -35,7 +35,7 @@ jobs: with: node-version: "22" cache: pnpm - cache-dependency-path: ahakey-desktop/pnpm-lock.yaml + cache-dependency-path: ahakey-studio-tauri/pnpm-lock.yaml - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy diff --git a/ahakey-desktop/.gitignore b/ahakey-studio-tauri/.gitignore similarity index 100% rename from ahakey-desktop/.gitignore rename to ahakey-studio-tauri/.gitignore diff --git a/ahakey-desktop/FIRMWARE.md b/ahakey-studio-tauri/FIRMWARE.md similarity index 100% rename from ahakey-desktop/FIRMWARE.md rename to ahakey-studio-tauri/FIRMWARE.md diff --git a/ahakey-desktop/README.md b/ahakey-studio-tauri/README.md similarity index 97% rename from ahakey-desktop/README.md rename to ahakey-studio-tauri/README.md index 4b9d28cb..f29e82d6 100644 --- a/ahakey-desktop/README.md +++ b/ahakey-studio-tauri/README.md @@ -1,7 +1,8 @@ # AhaKey Studio: Rust client -Rust + Tauri 2 + React client, version 1.1.5. The client is a separate subproject; -it does not replace the Java, Swift or bridge source trees. +Rust + Tauri 2 + React client, version 1.1.5. The client lives in the independent +`ahakey-studio-tauri/` subdirectory at the repository root; 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. This is a community client for hardware by [AhaKey](https://github.com/AhakeyAI), diff --git a/ahakey-desktop/crates/ble/.gitignore b/ahakey-studio-tauri/crates/ble/.gitignore similarity index 100% rename from ahakey-desktop/crates/ble/.gitignore rename to ahakey-studio-tauri/crates/ble/.gitignore diff --git a/ahakey-desktop/crates/ble/Cargo.toml b/ahakey-studio-tauri/crates/ble/Cargo.toml similarity index 100% rename from ahakey-desktop/crates/ble/Cargo.toml rename to ahakey-studio-tauri/crates/ble/Cargo.toml diff --git a/ahakey-desktop/crates/ble/README.md b/ahakey-studio-tauri/crates/ble/README.md similarity index 100% rename from ahakey-desktop/crates/ble/README.md rename to ahakey-studio-tauri/crates/ble/README.md diff --git a/ahakey-desktop/crates/ble/examples/probe.rs b/ahakey-studio-tauri/crates/ble/examples/probe.rs similarity index 100% rename from ahakey-desktop/crates/ble/examples/probe.rs rename to ahakey-studio-tauri/crates/ble/examples/probe.rs diff --git a/ahakey-desktop/crates/ble/examples/usb_routing.rs b/ahakey-studio-tauri/crates/ble/examples/usb_routing.rs similarity index 100% rename from ahakey-desktop/crates/ble/examples/usb_routing.rs rename to ahakey-studio-tauri/crates/ble/examples/usb_routing.rs diff --git a/ahakey-desktop/crates/ble/src/host_info.rs b/ahakey-studio-tauri/crates/ble/src/host_info.rs similarity index 100% rename from ahakey-desktop/crates/ble/src/host_info.rs rename to ahakey-studio-tauri/crates/ble/src/host_info.rs diff --git a/ahakey-desktop/crates/ble/src/lib.rs b/ahakey-studio-tauri/crates/ble/src/lib.rs similarity index 100% rename from ahakey-desktop/crates/ble/src/lib.rs rename to ahakey-studio-tauri/crates/ble/src/lib.rs diff --git a/ahakey-desktop/crates/ble/src/native_windows.rs b/ahakey-studio-tauri/crates/ble/src/native_windows.rs similarity index 100% rename from ahakey-desktop/crates/ble/src/native_windows.rs rename to ahakey-studio-tauri/crates/ble/src/native_windows.rs diff --git a/ahakey-desktop/crates/ble/src/protocol.rs b/ahakey-studio-tauri/crates/ble/src/protocol.rs similarity index 100% rename from ahakey-desktop/crates/ble/src/protocol.rs rename to ahakey-studio-tauri/crates/ble/src/protocol.rs diff --git a/ahakey-desktop/crates/ble/src/reset.rs b/ahakey-studio-tauri/crates/ble/src/reset.rs similarity index 100% rename from ahakey-desktop/crates/ble/src/reset.rs rename to ahakey-studio-tauri/crates/ble/src/reset.rs diff --git a/ahakey-desktop/crates/ble/src/routing.rs b/ahakey-studio-tauri/crates/ble/src/routing.rs similarity index 100% rename from ahakey-desktop/crates/ble/src/routing.rs rename to ahakey-studio-tauri/crates/ble/src/routing.rs diff --git a/ahakey-desktop/crates/ble/src/usb_routing.rs b/ahakey-studio-tauri/crates/ble/src/usb_routing.rs similarity index 100% rename from ahakey-desktop/crates/ble/src/usb_routing.rs rename to ahakey-studio-tauri/crates/ble/src/usb_routing.rs diff --git a/ahakey-desktop/crates/cloud/.gitignore b/ahakey-studio-tauri/crates/cloud/.gitignore similarity index 100% rename from ahakey-desktop/crates/cloud/.gitignore rename to ahakey-studio-tauri/crates/cloud/.gitignore diff --git a/ahakey-desktop/crates/cloud/Cargo.toml b/ahakey-studio-tauri/crates/cloud/Cargo.toml similarity index 100% rename from ahakey-desktop/crates/cloud/Cargo.toml rename to ahakey-studio-tauri/crates/cloud/Cargo.toml diff --git a/ahakey-desktop/crates/cloud/README.md b/ahakey-studio-tauri/crates/cloud/README.md similarity index 100% rename from ahakey-desktop/crates/cloud/README.md rename to ahakey-studio-tauri/crates/cloud/README.md diff --git a/ahakey-desktop/crates/cloud/src/credentials.rs b/ahakey-studio-tauri/crates/cloud/src/credentials.rs similarity index 100% rename from ahakey-desktop/crates/cloud/src/credentials.rs rename to ahakey-studio-tauri/crates/cloud/src/credentials.rs diff --git a/ahakey-desktop/crates/cloud/src/lib.rs b/ahakey-studio-tauri/crates/cloud/src/lib.rs similarity index 100% rename from ahakey-desktop/crates/cloud/src/lib.rs rename to ahakey-studio-tauri/crates/cloud/src/lib.rs diff --git a/ahakey-desktop/crates/cloud/src/protocol.rs b/ahakey-studio-tauri/crates/cloud/src/protocol.rs similarity index 100% rename from ahakey-desktop/crates/cloud/src/protocol.rs rename to ahakey-studio-tauri/crates/cloud/src/protocol.rs diff --git a/ahakey-desktop/crates/cloud/src/session.rs b/ahakey-studio-tauri/crates/cloud/src/session.rs similarity index 100% rename from ahakey-desktop/crates/cloud/src/session.rs rename to ahakey-studio-tauri/crates/cloud/src/session.rs diff --git a/ahakey-desktop/crates/speech/.gitignore b/ahakey-studio-tauri/crates/speech/.gitignore similarity index 100% rename from ahakey-desktop/crates/speech/.gitignore rename to ahakey-studio-tauri/crates/speech/.gitignore diff --git a/ahakey-desktop/crates/speech/Cargo.toml b/ahakey-studio-tauri/crates/speech/Cargo.toml similarity index 100% rename from ahakey-desktop/crates/speech/Cargo.toml rename to ahakey-studio-tauri/crates/speech/Cargo.toml diff --git a/ahakey-desktop/crates/speech/README.md b/ahakey-studio-tauri/crates/speech/README.md similarity index 100% rename from ahakey-desktop/crates/speech/README.md rename to ahakey-studio-tauri/crates/speech/README.md diff --git a/ahakey-desktop/crates/speech/build.rs b/ahakey-studio-tauri/crates/speech/build.rs similarity index 100% rename from ahakey-desktop/crates/speech/build.rs rename to ahakey-studio-tauri/crates/speech/build.rs diff --git a/ahakey-desktop/crates/speech/licenses/onnxruntime-LICENSE.txt b/ahakey-studio-tauri/crates/speech/licenses/onnxruntime-LICENSE.txt similarity index 100% rename from ahakey-desktop/crates/speech/licenses/onnxruntime-LICENSE.txt rename to ahakey-studio-tauri/crates/speech/licenses/onnxruntime-LICENSE.txt diff --git a/ahakey-desktop/crates/speech/licenses/sherpa-onnx-LICENSE.txt b/ahakey-studio-tauri/crates/speech/licenses/sherpa-onnx-LICENSE.txt similarity index 100% rename from ahakey-desktop/crates/speech/licenses/sherpa-onnx-LICENSE.txt rename to ahakey-studio-tauri/crates/speech/licenses/sherpa-onnx-LICENSE.txt diff --git a/ahakey-desktop/crates/speech/scripts/prepare-runtime.ps1 b/ahakey-studio-tauri/crates/speech/scripts/prepare-runtime.ps1 similarity index 100% rename from ahakey-desktop/crates/speech/scripts/prepare-runtime.ps1 rename to ahakey-studio-tauri/crates/speech/scripts/prepare-runtime.ps1 diff --git a/ahakey-desktop/crates/speech/src/audio.rs b/ahakey-studio-tauri/crates/speech/src/audio.rs similarity index 100% rename from ahakey-desktop/crates/speech/src/audio.rs rename to ahakey-studio-tauri/crates/speech/src/audio.rs diff --git a/ahakey-desktop/crates/speech/src/lib.rs b/ahakey-studio-tauri/crates/speech/src/lib.rs similarity index 100% rename from ahakey-desktop/crates/speech/src/lib.rs rename to ahakey-studio-tauri/crates/speech/src/lib.rs diff --git a/ahakey-desktop/crates/speech/src/model.rs b/ahakey-studio-tauri/crates/speech/src/model.rs similarity index 100% rename from ahakey-desktop/crates/speech/src/model.rs rename to ahakey-studio-tauri/crates/speech/src/model.rs diff --git a/ahakey-desktop/crates/speech/src/recognizer.rs b/ahakey-studio-tauri/crates/speech/src/recognizer.rs similarity index 100% rename from ahakey-desktop/crates/speech/src/recognizer.rs rename to ahakey-studio-tauri/crates/speech/src/recognizer.rs diff --git a/ahakey-desktop/crates/speech/src/session.rs b/ahakey-studio-tauri/crates/speech/src/session.rs similarity index 100% rename from ahakey-desktop/crates/speech/src/session.rs rename to ahakey-studio-tauri/crates/speech/src/session.rs diff --git a/ahakey-desktop/crates/speech/tests/known_answer.rs b/ahakey-studio-tauri/crates/speech/tests/known_answer.rs similarity index 100% rename from ahakey-desktop/crates/speech/tests/known_answer.rs rename to ahakey-studio-tauri/crates/speech/tests/known_answer.rs diff --git a/ahakey-desktop/index.html b/ahakey-studio-tauri/index.html similarity index 100% rename from ahakey-desktop/index.html rename to ahakey-studio-tauri/index.html diff --git a/ahakey-desktop/package.json b/ahakey-studio-tauri/package.json similarity index 100% rename from ahakey-desktop/package.json rename to ahakey-studio-tauri/package.json diff --git a/ahakey-desktop/pnpm-lock.yaml b/ahakey-studio-tauri/pnpm-lock.yaml similarity index 100% rename from ahakey-desktop/pnpm-lock.yaml rename to ahakey-studio-tauri/pnpm-lock.yaml diff --git a/ahakey-desktop/public/favicon.svg b/ahakey-studio-tauri/public/favicon.svg similarity index 100% rename from ahakey-desktop/public/favicon.svg rename to ahakey-studio-tauri/public/favicon.svg diff --git a/ahakey-desktop/scripts/build-windows.ps1 b/ahakey-studio-tauri/scripts/build-windows.ps1 similarity index 100% rename from ahakey-desktop/scripts/build-windows.ps1 rename to ahakey-studio-tauri/scripts/build-windows.ps1 diff --git a/ahakey-desktop/scripts/packaging.test.mjs b/ahakey-studio-tauri/scripts/packaging.test.mjs similarity index 100% rename from ahakey-desktop/scripts/packaging.test.mjs rename to ahakey-studio-tauri/scripts/packaging.test.mjs diff --git a/ahakey-desktop/scripts/probe-caption-monitors.ps1 b/ahakey-studio-tauri/scripts/probe-caption-monitors.ps1 similarity index 100% rename from ahakey-desktop/scripts/probe-caption-monitors.ps1 rename to ahakey-studio-tauri/scripts/probe-caption-monitors.ps1 diff --git a/ahakey-desktop/src-tauri/Cargo.lock b/ahakey-studio-tauri/src-tauri/Cargo.lock similarity index 100% rename from ahakey-desktop/src-tauri/Cargo.lock rename to ahakey-studio-tauri/src-tauri/Cargo.lock diff --git a/ahakey-desktop/src-tauri/Cargo.toml b/ahakey-studio-tauri/src-tauri/Cargo.toml similarity index 100% rename from ahakey-desktop/src-tauri/Cargo.toml rename to ahakey-studio-tauri/src-tauri/Cargo.toml diff --git a/ahakey-desktop/src-tauri/Info.plist b/ahakey-studio-tauri/src-tauri/Info.plist similarity index 100% rename from ahakey-desktop/src-tauri/Info.plist rename to ahakey-studio-tauri/src-tauri/Info.plist diff --git a/ahakey-desktop/src-tauri/build.rs b/ahakey-studio-tauri/src-tauri/build.rs similarity index 100% rename from ahakey-desktop/src-tauri/build.rs rename to ahakey-studio-tauri/src-tauri/build.rs diff --git a/ahakey-desktop/src-tauri/capabilities/default.json b/ahakey-studio-tauri/src-tauri/capabilities/default.json similarity index 100% rename from ahakey-desktop/src-tauri/capabilities/default.json rename to ahakey-studio-tauri/src-tauri/capabilities/default.json diff --git a/ahakey-desktop/src-tauri/icons/icon.icns b/ahakey-studio-tauri/src-tauri/icons/icon.icns similarity index 100% rename from ahakey-desktop/src-tauri/icons/icon.icns rename to ahakey-studio-tauri/src-tauri/icons/icon.icns diff --git a/ahakey-desktop/src-tauri/icons/icon.ico b/ahakey-studio-tauri/src-tauri/icons/icon.ico similarity index 100% rename from ahakey-desktop/src-tauri/icons/icon.ico rename to ahakey-studio-tauri/src-tauri/icons/icon.ico diff --git a/ahakey-desktop/src-tauri/icons/icon.png b/ahakey-studio-tauri/src-tauri/icons/icon.png similarity index 100% rename from ahakey-desktop/src-tauri/icons/icon.png rename to ahakey-studio-tauri/src-tauri/icons/icon.png diff --git a/ahakey-desktop/src-tauri/icons/icon.svg b/ahakey-studio-tauri/src-tauri/icons/icon.svg similarity index 100% rename from ahakey-desktop/src-tauri/icons/icon.svg rename to ahakey-studio-tauri/src-tauri/icons/icon.svg diff --git a/ahakey-desktop/src-tauri/src/backend.rs b/ahakey-studio-tauri/src-tauri/src/backend.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/backend.rs rename to ahakey-studio-tauri/src-tauri/src/backend.rs diff --git a/ahakey-desktop/src-tauri/src/caption.rs b/ahakey-studio-tauri/src-tauri/src/caption.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/caption.rs rename to ahakey-studio-tauri/src-tauri/src/caption.rs diff --git a/ahakey-desktop/src-tauri/src/device.rs b/ahakey-studio-tauri/src-tauri/src/device.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/device.rs rename to ahakey-studio-tauri/src-tauri/src/device.rs diff --git a/ahakey-desktop/src-tauri/src/device_routing.rs b/ahakey-studio-tauri/src-tauri/src/device_routing.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/device_routing.rs rename to ahakey-studio-tauri/src-tauri/src/device_routing.rs diff --git a/ahakey-desktop/src-tauri/src/hooks.rs b/ahakey-studio-tauri/src-tauri/src/hooks.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/hooks.rs rename to ahakey-studio-tauri/src-tauri/src/hooks.rs diff --git a/ahakey-desktop/src-tauri/src/hooks/legacy-java-dispatcher.ps1 b/ahakey-studio-tauri/src-tauri/src/hooks/legacy-java-dispatcher.ps1 similarity index 100% rename from ahakey-desktop/src-tauri/src/hooks/legacy-java-dispatcher.ps1 rename to ahakey-studio-tauri/src-tauri/src/hooks/legacy-java-dispatcher.ps1 diff --git a/ahakey-desktop/src-tauri/src/host_notes.rs b/ahakey-studio-tauri/src-tauri/src/host_notes.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/host_notes.rs rename to ahakey-studio-tauri/src-tauri/src/host_notes.rs diff --git a/ahakey-desktop/src-tauri/src/input.rs b/ahakey-studio-tauri/src-tauri/src/input.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/input.rs rename to ahakey-studio-tauri/src-tauri/src/input.rs diff --git a/ahakey-desktop/src-tauri/src/keys.rs b/ahakey-studio-tauri/src-tauri/src/keys.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/keys.rs rename to ahakey-studio-tauri/src-tauri/src/keys.rs diff --git a/ahakey-desktop/src-tauri/src/main.rs b/ahakey-studio-tauri/src-tauri/src/main.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/main.rs rename to ahakey-studio-tauri/src-tauri/src/main.rs diff --git a/ahakey-desktop/src-tauri/src/platform.rs b/ahakey-studio-tauri/src-tauri/src/platform.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/platform.rs rename to ahakey-studio-tauri/src-tauri/src/platform.rs diff --git a/ahakey-desktop/src-tauri/src/quota/mod.rs b/ahakey-studio-tauri/src-tauri/src/quota/mod.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/quota/mod.rs rename to ahakey-studio-tauri/src-tauri/src/quota/mod.rs diff --git a/ahakey-desktop/src-tauri/src/quota/model.rs b/ahakey-studio-tauri/src-tauri/src/quota/model.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/quota/model.rs rename to ahakey-studio-tauri/src-tauri/src/quota/model.rs diff --git a/ahakey-desktop/src-tauri/src/recovery.rs b/ahakey-studio-tauri/src-tauri/src/recovery.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/recovery.rs rename to ahakey-studio-tauri/src-tauri/src/recovery.rs diff --git a/ahakey-desktop/src-tauri/src/settings.rs b/ahakey-studio-tauri/src-tauri/src/settings.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/settings.rs rename to ahakey-studio-tauri/src-tauri/src/settings.rs diff --git a/ahakey-desktop/src-tauri/src/state.rs b/ahakey-studio-tauri/src-tauri/src/state.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/state.rs rename to ahakey-studio-tauri/src-tauri/src/state.rs diff --git a/ahakey-desktop/src-tauri/src/tray.rs b/ahakey-studio-tauri/src-tauri/src/tray.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/tray.rs rename to ahakey-studio-tauri/src-tauri/src/tray.rs diff --git a/ahakey-desktop/src-tauri/src/voice.rs b/ahakey-studio-tauri/src-tauri/src/voice.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/voice.rs rename to ahakey-studio-tauri/src-tauri/src/voice.rs diff --git a/ahakey-desktop/src-tauri/src/windows_voice_keys.rs b/ahakey-studio-tauri/src-tauri/src/windows_voice_keys.rs similarity index 100% rename from ahakey-desktop/src-tauri/src/windows_voice_keys.rs rename to ahakey-studio-tauri/src-tauri/src/windows_voice_keys.rs diff --git a/ahakey-desktop/src-tauri/tauri.conf.json b/ahakey-studio-tauri/src-tauri/tauri.conf.json similarity index 100% rename from ahakey-desktop/src-tauri/tauri.conf.json rename to ahakey-studio-tauri/src-tauri/tauri.conf.json diff --git a/ahakey-desktop/src-tauri/tauri.macos.conf.json b/ahakey-studio-tauri/src-tauri/tauri.macos.conf.json similarity index 100% rename from ahakey-desktop/src-tauri/tauri.macos.conf.json rename to ahakey-studio-tauri/src-tauri/tauri.macos.conf.json diff --git a/ahakey-desktop/src/DisplayPanel.tsx b/ahakey-studio-tauri/src/DisplayPanel.tsx similarity index 100% rename from ahakey-desktop/src/DisplayPanel.tsx rename to ahakey-studio-tauri/src/DisplayPanel.tsx diff --git a/ahakey-desktop/src/FourKeysPanel.tsx b/ahakey-studio-tauri/src/FourKeysPanel.tsx similarity index 100% rename from ahakey-desktop/src/FourKeysPanel.tsx rename to ahakey-studio-tauri/src/FourKeysPanel.tsx diff --git a/ahakey-desktop/src/LivePanels.tsx b/ahakey-studio-tauri/src/LivePanels.tsx similarity index 100% rename from ahakey-desktop/src/LivePanels.tsx rename to ahakey-studio-tauri/src/LivePanels.tsx diff --git a/ahakey-desktop/src/ProviderPicker.tsx b/ahakey-studio-tauri/src/ProviderPicker.tsx similarity index 100% rename from ahakey-desktop/src/ProviderPicker.tsx rename to ahakey-studio-tauri/src/ProviderPicker.tsx diff --git a/ahakey-desktop/src/RoutingPanel.tsx b/ahakey-studio-tauri/src/RoutingPanel.tsx similarity index 100% rename from ahakey-desktop/src/RoutingPanel.tsx rename to ahakey-studio-tauri/src/RoutingPanel.tsx diff --git a/ahakey-desktop/src/contracts.test.ts b/ahakey-studio-tauri/src/contracts.test.ts similarity index 100% rename from ahakey-desktop/src/contracts.test.ts rename to ahakey-studio-tauri/src/contracts.test.ts diff --git a/ahakey-desktop/src/contracts.ts b/ahakey-studio-tauri/src/contracts.ts similarity index 100% rename from ahakey-desktop/src/contracts.ts rename to ahakey-studio-tauri/src/contracts.ts diff --git a/ahakey-desktop/src/device.test.ts b/ahakey-studio-tauri/src/device.test.ts similarity index 100% rename from ahakey-desktop/src/device.test.ts rename to ahakey-studio-tauri/src/device.test.ts diff --git a/ahakey-desktop/src/display.test.ts b/ahakey-studio-tauri/src/display.test.ts similarity index 100% rename from ahakey-desktop/src/display.test.ts rename to ahakey-studio-tauri/src/display.test.ts diff --git a/ahakey-desktop/src/display.ts b/ahakey-studio-tauri/src/display.ts similarity index 100% rename from ahakey-desktop/src/display.ts rename to ahakey-studio-tauri/src/display.ts diff --git a/ahakey-desktop/src/host-info.test.ts b/ahakey-studio-tauri/src/host-info.test.ts similarity index 100% rename from ahakey-desktop/src/host-info.test.ts rename to ahakey-studio-tauri/src/host-info.test.ts diff --git a/ahakey-desktop/src/main.tsx b/ahakey-studio-tauri/src/main.tsx similarity index 100% rename from ahakey-desktop/src/main.tsx rename to ahakey-studio-tauri/src/main.tsx diff --git a/ahakey-desktop/src/routing.test.ts b/ahakey-studio-tauri/src/routing.test.ts similarity index 100% rename from ahakey-desktop/src/routing.test.ts rename to ahakey-studio-tauri/src/routing.test.ts diff --git a/ahakey-desktop/src/routing.ts b/ahakey-studio-tauri/src/routing.ts similarity index 100% rename from ahakey-desktop/src/routing.ts rename to ahakey-studio-tauri/src/routing.ts diff --git a/ahakey-desktop/src/styles.css b/ahakey-studio-tauri/src/styles.css similarity index 100% rename from ahakey-desktop/src/styles.css rename to ahakey-studio-tauri/src/styles.css diff --git a/ahakey-desktop/tsconfig.json b/ahakey-studio-tauri/tsconfig.json similarity index 100% rename from ahakey-desktop/tsconfig.json rename to ahakey-studio-tauri/tsconfig.json diff --git a/ahakey-desktop/vite.config.ts b/ahakey-studio-tauri/vite.config.ts similarity index 100% rename from ahakey-desktop/vite.config.ts rename to ahakey-studio-tauri/vite.config.ts From 8c3a2ad9aaf833430207966c4eacc9bfcf514685 Mon Sep 17 00:00:00 2001 From: sakruhnab1 <91109111+ZephyrKeXiner@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:52:39 +0800 Subject: [PATCH 21/21] fix(hooks): normalize legacy fixture line endings on Windows --- ahakey-studio-tauri/src-tauri/src/hooks.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ahakey-studio-tauri/src-tauri/src/hooks.rs b/ahakey-studio-tauri/src-tauri/src/hooks.rs index 8fe1de6b..76fe7a56 100644 --- a/ahakey-studio-tauri/src-tauri/src/hooks.rs +++ b/ahakey-studio-tauri/src-tauri/src/hooks.rs @@ -89,7 +89,7 @@ fn ensure_dispatcher(path: &Path) -> Result<(), String> { if normalized.as_deref() == Some(DISPATCHER) { return Ok(()); } - if normalized.as_deref() != Some(LEGACY_DISPATCHER) { + if normalized.as_deref() != Some(normalize_script(LEGACY_DISPATCHER).as_str()) { return Err(format!( "现有 Hook 脚本不是兼容的分发脚本,已保留;请备份并移走 {} 后重新启用 Hook", path.display() @@ -222,7 +222,10 @@ mod tests { fn java_dispatcher_is_backed_up_and_events_reach_the_published_listener() { let directory = tempfile::tempdir().unwrap(); let script = directory.path().join("ahakey-hook.ps1"); - let legacy = format!("\u{feff}{}", LEGACY_DISPATCHER.replace('\n', "\r\n")); + let legacy = format!( + "\u{feff}{}", + normalize_script(LEGACY_DISPATCHER).replace('\n', "\r\n") + ); std::fs::write(&script, &legacy).unwrap(); let (tx, rx) = std::sync::mpsc::channel(); let server = start(directory.path().to_owned(), move |name, state| {