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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/switches.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ Existing point-scan commands and event fields remain compatible for geometry and

Switchify owns local input through `switch_input`, a platform adapter that supplies generation-tagged press/release events. There is no USAHP runtime or dependency. Output still uses Switchify's existing input adapters. The capture state and macOS event tap were adapted from MIT-licensed code; attribution is retained beside the source.

Windows captures assigned physical keys and Escape with a low-level keyboard hook on a dedicated message-loop thread. It suppresses the complete press, autorepeat and release sequence while Switchify is foreground, background or minimized, including assigned keys pressed with Shift, Ctrl, Alt or Windows held. Unassigned keys and Switchify-generated shortcuts pass through. F12 remains unavailable. Learning captures supported keys until the first complete press/release; keys held before learning must be released before they can be learned.
Windows captures assigned physical keys and Escape with a low-level keyboard hook on a dedicated message-loop thread in an isolated Switchify worker process. The worker uses the same executable and signing identity without starting a WebView. This avoids capture disappearing when Switchify’s WebView has focus. It suppresses the complete press, autorepeat and release sequence while Switchify is foreground, background or minimized, including assigned keys pressed with Shift, Ctrl, Alt or Windows held. Unassigned keys and Switchify-generated shortcuts pass through. F12 remains unavailable. Learning captures supported keys until the first complete press/release; keys held before learning must be released before they can be learned.

The hook makes suppression decisions using thread-owned state and sends generation-tagged edges through a bounded queue. A separate worker feeds gesture timing and cancellation. Stopping discards queued actions but keeps consuming repeats and releases for presses already consumed. Startup failures are reported without a hotkey fallback; shutdown removes the hook. Raw Input is used only for device-removal diagnostics, never gesture delivery. If a disconnected switch was held and no release arrives, reconnect it (or use the same key on another keyboard) and press and release that key once to clear it without an action. Ownership is retained until that release so another still-held switch cannot leak repeats. Worker and application watchdogs detect stalled processing; idle heartbeats do not prove Windows still has the hook installed. Secure-desktop/UAC input is outside this capture's scope. macOS retains its existing event-tap capture.
The hook makes suppression decisions using thread-owned state and sends generation-tagged edges through a bounded queue. A separate worker feeds gesture timing and cancellation. Stopping discards queued actions but keeps consuming repeats and releases for presses already consumed. Startup failures are reported without a hotkey fallback; shutdown removes the hook. Raw Input diagnoses device removal and unexpected pass-through of assigned keys; it never delivers gestures or proves that a suppressed key was released. Unexpected pass-through cancels pending actions and replaces the hook. Recovery reserves the switch keys and asks you to release every held switch, then press and release a switch to reconnect. That recovery gesture executes no action; use a separate Select press to begin again. If an old held-key state is uncertain, an additional release may be needed before the recovery gesture. An event that reveals lost suppression may already have reached the foreground application. If a disconnected switch was held and no release arrives, reconnect it (or use the same key on another keyboard) and press and release that key once to clear it without an action. Ownership is retained until that release so another still-held switch cannot leak repeats. Private bounded IPC carries generation-tagged results; cancellation cannot be undone by a delayed worker response. The worker shares the parent’s time origin, and a Windows job ends it if the application exits. If the worker process dies or IPC stalls, capture stays stopped: release your switches, then restart Switchify. This failure does not automatically clear uncertain held keys or re-arm scanning. Worker and application watchdogs detect stalled processing; idle heartbeats do not prove Windows still has the hook installed. Secure-desktop/UAC input is outside this capture's scope. macOS retains its existing event-tap capture.

The reusable gesture engine handles normal and ordered hold actions. The shared scan controller dispatches them to Session<T>. Native prompts are click-through and nonactivating.

Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ objc2-core-graphics = { version = "0.3.2", default-features = false, features =
objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSGeometry", "NSHost", "NSNotification", "NSObject", "NSOperation", "NSString", "NSThread", "NSURL", "block2"] }

[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Security", "Win32_System_JobObjects", "Win32_System_SystemInformation", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] }
windows = { version = "0.62.2", features = [
"Devices_Bluetooth",
"Devices_Bluetooth_GenericAttributeProfile",
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,10 @@ fn point_scan_prepare(app: &AppHandle) -> Result<(), String> {
}

pub fn run() {
#[cfg(target_os = "windows")]
if switch_input::run_worker_from_args() {
return;
}
#[cfg(target_os = "macos")]
if macos_relaunch::run_from_args() {
return;
Expand Down
21 changes: 18 additions & 3 deletions src-tauri/src/scanning_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ fn disable<A: Adapter>(app: &AppHandle, message: &str) {
reset_scanner::<A>(app, message);
}
fn reset_scanner<A: Adapter>(app: &AppHandle, message: &str) {
reset_scanner_for_capture::<A>(app, message, false);
}
fn reset_scanner_for_capture<A: Adapter>(app: &AppHandle, message: &str, recovering: bool) {
let c = app.state::<Controller<A>>();
c.enabled.store(false, Ordering::SeqCst);
c.generation.fetch_add(1, Ordering::SeqCst);
Expand All @@ -199,7 +202,12 @@ fn reset_scanner<A: Adapter>(app: &AppHandle, message: &str) {
c.data.lock().unwrap_or_else(|p| p.into_inner()).message =
"Input cleanup will be retried before scanning resumes.".into();
}
app.state::<switch_runtime::Controller>().stop();
if recovering {
app.state::<switch_runtime::Controller>()
.stop_for_recovery();
} else {
app.state::<switch_runtime::Controller>().stop();
}
hide_scan_visuals();
let token = c
.data
Expand Down Expand Up @@ -360,7 +368,8 @@ fn switch<A: Adapter>(app: &AppHandle, action: Action, input_generation: u64, re
return;
}
if !input_active(app, input_generation, remote) {
disable::<A>(app, "Switch capture stopped.");
crate::remote_scan::cancel(app);
reset_scanner_for_capture::<A>(app, "Switch capture stopped.", true);
return;
}
if matches!(action, Action::Cancel | Action::Stop) {
Expand Down Expand Up @@ -649,7 +658,13 @@ fn tick<A: Adapter>(app: &AppHandle) {
}
match event {
Event::Stopped { reason, .. } => {
disable::<A>(app, switch_runtime::stop_message(reason));
crate::remote_scan::cancel(app);
reset_scanner_for_capture::<A>(
app,
switch_runtime::stop_message(reason),
cfg!(target_os = "windows")
&& reason == crate::switch_input::StopReason::CaptureLost,
);
return;
}
Event::Switch {
Expand Down
58 changes: 51 additions & 7 deletions src-tauri/src/switch_input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,23 @@ mod windows;
#[cfg(any(target_os = "windows", test))]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
mod windows_hook;
#[cfg(target_os = "windows")]
mod windows_worker;
#[cfg(any(target_os = "windows", test))]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
mod windows_worker_protocol;
use anyhow::{bail, Result};
pub use keys::normalize as normalize_key;
#[cfg(target_os = "windows")]
pub fn run_worker_from_args() -> bool {
windows_worker::run_from_args()
}
use std::{
collections::{HashMap, HashSet, VecDeque},
sync::{Arc, Mutex},
time::Instant,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Action {
Pressed,
Released,
Expand All @@ -30,7 +39,7 @@ pub const HEARTBEAT_INTERVAL_MS: u64 = 500;
pub const HEARTBEAT_TIMEOUT_MS: u64 = 1500;
const QUEUE_LIMIT: usize = 256;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum StopReason {
Disabled,
Escape,
Expand All @@ -39,7 +48,7 @@ pub enum StopReason {
QueueOverflow,
CaptureLost,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Event {
Switch {
generation: u64,
Expand All @@ -56,20 +65,22 @@ pub enum Event {
reason: StopReason,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Mode {
Off,
Learning,
Active,
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct Status {
pub generation: u64,
pub mode: Mode,
pub reason: Option<StopReason>,
}
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
struct Core {
#[cfg(any(target_os = "windows", test))]
worker_failed: bool,
status: Status,
native_lost: bool,
mappings: HashMap<String, String>,
Expand All @@ -84,6 +95,8 @@ struct Core {
impl Default for Core {
fn default() -> Self {
Self {
#[cfg(any(target_os = "windows", test))]
worker_failed: false,
status: Status {
generation: 0,
mode: Mode::Off,
Expand Down Expand Up @@ -307,7 +320,7 @@ impl Driver {
pub struct Capture {
driver: Driver,
#[cfg(target_os = "windows")]
native: Option<windows::Capture>,
native: Option<windows_worker::Capture>,
#[cfg(target_os = "macos")]
native: Option<macos::Capture>,
}
Expand Down Expand Up @@ -335,6 +348,10 @@ impl Capture {
.status
}
pub fn heartbeat(&self) {
#[cfg(target_os = "windows")]
if let Some(native) = &self.native {
native.check_health();
}
let mut core = self.driver.core.lock().unwrap_or_else(|p| p.into_inner());
core.last_heartbeat = self.driver.now();
}
Expand Down Expand Up @@ -401,6 +418,22 @@ impl Capture {
}
#[cfg(target_os = "windows")]
{
if self
.driver
.core
.lock()
.unwrap_or_else(|p| p.into_inner())
.worker_failed
{
bail!("{}", windows_worker_protocol::WORKER_FAILURE_MESSAGE);
}
if self
.native
.as_ref()
.is_some_and(windows_worker::Capture::recovering)
{
bail!("{}", windows_hook::RECOVERY_MESSAGE);
}
let mut held = self
.driver
.core
Expand All @@ -425,7 +458,7 @@ impl Capture {
native.await_shutdown()?;
}
self.native.take();
self.native = Some(windows::Capture::start(self.driver.clone(), mode)?);
self.native = Some(windows_worker::Capture::start(self.driver.clone(), mode)?);
Ok(self.status().generation)
}
#[cfg(not(target_os = "windows"))]
Expand Down Expand Up @@ -469,6 +502,17 @@ impl Capture {
.unwrap_or_else(|p| p.into_inner())
.stop(StopReason::Disabled);
}
pub fn stop_for_recovery(&mut self) {
#[cfg(target_os = "windows")]
if let Some(native) = &self.native {
native.cancel_for_recovery();
}
self.driver
.core
.lock()
.unwrap_or_else(|p| p.into_inner())
.stop(StopReason::Disabled);
}
}
impl Drop for Capture {
fn drop(&mut self) {
Expand Down
107 changes: 84 additions & 23 deletions src-tauri/src/switch_input/windows.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::{
windows_hook::{await_shutdown, Installation, NativeResources, Resource, Shared, State},
windows_hook::{Installation, NativeResources, Resource, Shared, State},
Driver, Mode, StopReason,
};
use anyhow::{bail, Result};
Expand Down Expand Up @@ -118,11 +118,43 @@ unsafe extern "system" fn keyboard(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT
CallNextHookEx(std::ptr::null_mut(), code, wp, lp)
}
unsafe extern "system" fn window(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
if msg == WM_INPUT {
let mut raw: RAWINPUT = std::mem::zeroed();
let mut size = std::mem::size_of::<RAWINPUT>() as u32;
let read = GetRawInputData(
lp as HRAWINPUT,
RID_INPUT,
&mut raw as *mut _ as *mut _,
&mut size,
std::mem::size_of::<RAWINPUTHEADER>() as u32,
);
if read != u32::MAX
&& read >= std::mem::size_of::<RAWINPUTHEADER>() as u32
&& raw.header.dwType == RIM_TYPEKEYBOARD
{
let key = raw.data.keyboard;
INPUT.with(|slot| {
if let Ok(mut slot) = slot.try_borrow_mut() {
if let Some(input) = slot.as_mut() {
let now = input.started.elapsed().as_millis().min(u64::MAX as u128) as u64;
input.state.raw(
&input.shared,
key.VKey as u32,
key.Flags & RI_KEY_BREAK as u16 == 0,
crate::input::own_input(key.ExtraInformation as i64),
now,
);
}
}
});
}
}
if msg == WM_INPUT_DEVICE_CHANGE && wp == GIDC_REMOVAL as usize {
INPUT.with(|slot| {
if let Ok(slot) = slot.try_borrow() {
if let Some(input) = slot.as_ref() {
input.shared.fail(StopReason::CaptureLost);
if let Ok(mut slot) = slot.try_borrow_mut() {
if let Some(input) = slot.as_mut() {
let now = input.started.elapsed().as_millis().min(u64::MAX as u128) as u64;
input.state.interrupted(&input.shared, now);
}
}
});
Expand All @@ -139,6 +171,23 @@ struct Resources {
hook: HHOOK,
}
impl NativeResources for Resources {
fn replace_hook(&mut self) -> bool {
unsafe {
let replacement = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard), self.module, 0);
if replacement.is_null() {
return false;
}
if replacement != self.hook
&& UnhookWindowsHookEx(self.hook) == 0
&& GetLastError() != ERROR_INVALID_HOOK_HANDLE
{
UnhookWindowsHookEx(replacement);
return false;
}
self.hook = replacement;
true
}
}
fn acquire(&mut self, resource: Resource) -> bool {
unsafe {
match resource {
Expand Down Expand Up @@ -249,27 +298,18 @@ impl Capture {
}
}
pub fn cancel(&self) {
self.shared.cancel_recovery();
}
pub fn cancel_for_recovery(&self) {
self.shared.cancel();
}
pub fn await_shutdown(&self) -> Result<()> {
let started = std::time::Instant::now();
await_shutdown(
|| {
let held = self.held_keys();
if !held.is_empty() {
bail!(
"Release held keys before starting capture: {}.",
held.join(", ")
);
}
Ok(self
.thread
.as_ref()
.is_none_or(std::thread::JoinHandle::is_finished))
},
|| std::thread::sleep(Duration::from_millis(1)),
|| started.elapsed().as_millis().min(u64::MAX as u128) as u64,
)
pub fn recovering(&self) -> bool {
self.shared.recovering()
}
pub fn finished(&self) -> bool {
self.thread
.as_ref()
.is_none_or(std::thread::JoinHandle::is_finished)
}
pub fn held_keys(&self) -> Vec<String> {
known_keys()
Expand Down Expand Up @@ -300,6 +340,11 @@ unsafe fn run(
shared: Arc<Shared>,
tx: mpsc::SyncSender<Result<u32, String>>,
) {
let escape_ms = driver
.core
.lock()
.unwrap_or_else(|p| p.into_inner())
.escape_ms;
let module = GetModuleHandleW(std::ptr::null());
let class = wide("SwitchifyLocalKeyboardInput");
let wc = WNDCLASSW {
Expand Down Expand Up @@ -390,6 +435,22 @@ unsafe fn run(
}
TranslateMessage(&msg);
DispatchMessageW(&msg);
let now = driver.now();
let replace = INPUT.with(|slot| {
slot.borrow_mut()
.as_mut()
.is_some_and(|input| input.state.maintenance(&shared, now, escape_ms))
});
if replace {
let replaced = installation
.as_mut()
.is_some_and(Installation::replace_hook);
INPUT.with(|slot| {
if let Some(input) = slot.borrow_mut().as_mut() {
input.state.replaced(replaced, now);
}
});
}
if shared.drained() {
break;
}
Expand Down
Loading
Loading