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
2 changes: 1 addition & 1 deletion docs/point-scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Select **Switchify scanning** on Switchify Remote's mobile Forwarding screen. Re

Start forwarding, then press Select to begin. Local switch keys are inactive during the remote session; PC Escape remains an emergency stop. The PC owns hold-action timing: hold actions work exactly as for keyboard switches, and holding a remote switch past the PC emergency limit resets the scan without ending the session. Remote's own Forwarding hold-to-stop (3, 5 or 8 seconds, set on the phone) still ends the session without selecting, so keep hold lists short enough to finish inside it. Edits to remote switches, shared scanner settings or hold timing apply to a live session on the next press; a change that leaves the current mode without its required actions ends the session. The PC no longer rejects a stale profile revision, so Remote only needs to reload profiles to refresh its labels.

Cancelled edges, missing edges and sync mismatches never select. Held-state mismatches cancel the gesture and require neutral input. Sessions expire after five seconds without an authenticated edge or sync. Disconnects and safety stops discard the point and release drag buttons; start explicitly again. Ordinary forwarding and remote scanning cannot own input simultaneously. Local scanning resumes after Remote disconnects.
Cancelled edges, missing edges and sync mismatches never select. Held-state mismatches cancel the gesture and require neutral input. Sessions expire after five seconds without an authenticated edge or sync. Disconnects and safety stops discard the point and release drag buttons; start explicitly again. Ordinary forwarding and remote scanning cannot own input simultaneously. A local switch capture failure restarts remote scanning in place when capture can be re-armed; if it cannot, such as after the capture worker fails, the session ends. Escape and the local emergency hold still end it. Local scanning resumes after Remote disconnects.

## Home

Expand Down
2 changes: 1 addition & 1 deletion docs/switches.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Switchify owns local input through `switch_input`, a platform adapter that suppl

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鈥檚 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 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鈥檚 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 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 while a suppressed switch is held, and unexpected pass-through of assigned keys. Removing an unrelated keyboard while no switch is held leaves capture running. Raw Input 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鈥檚 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
26 changes: 25 additions & 1 deletion src-tauri/src/scanning_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,16 @@ fn prediction_captured_keys(remote: bool, settings: &Settings) -> Vec<String> {
}
keys
}
/// Remote input never passes through local switch capture, so a capture
/// failure only restarts remote scanning in place. Escape and the local
/// emergency hold still end the session.
fn local_stop_ends_remote(reason: crate::switch_input::StopReason) -> bool {
use crate::switch_input::StopReason;
!matches!(
reason,
StopReason::CaptureLost | StopReason::HeartbeatTimeout | StopReason::QueueOverflow
)
}
fn source_is_current(remote: bool, remote_current: bool, local_current: bool) -> bool {
if remote {
remote_current
Expand Down Expand Up @@ -934,7 +944,11 @@ fn tick<A: Adapter>(app: &AppHandle) {
}
match event {
Event::Stopped { reason, .. } => {
crate::remote_scan::cancel(app);
// Read before reset_scanner_for_capture clears the flag.
let remote = c.data.lock().unwrap_or_else(|p| p.into_inner()).remote;
if !remote || local_stop_ends_remote(reason) {
crate::remote_scan::cancel(app);
}
reset_scanner_for_capture::<A>(
app,
switch_runtime::stop_message(reason),
Expand Down Expand Up @@ -1705,6 +1719,16 @@ mod ownership_tests {
assert_eq!(prediction_captured_keys(true, &settings), ["Escape"]);
}
#[test]
fn only_user_stops_end_remote_scanning() {
use crate::switch_input::StopReason;
assert!(super::local_stop_ends_remote(StopReason::Escape));
assert!(super::local_stop_ends_remote(StopReason::HoldEscape));
assert!(super::local_stop_ends_remote(StopReason::Disabled));
assert!(!super::local_stop_ends_remote(StopReason::CaptureLost));
assert!(!super::local_stop_ends_remote(StopReason::HeartbeatTimeout));
assert!(!super::local_stop_ends_remote(StopReason::QueueOverflow));
}
#[test]
fn stopped_remote_input_cannot_fall_back_to_a_matching_local_generation() {
assert!(!super::source_is_current(true, false, true));
assert!(super::source_is_current(false, false, true));
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/switch_input/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ unsafe extern "system" fn window(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -
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);
input.state.device_removed(&input.shared, now);
}
}
});
Expand Down
36 changes: 36 additions & 0 deletions src-tauri/src/switch_input/windows_hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,14 @@ impl State {
self.observe(shared, code, pressed, true, ms);
}
}
/// A removed keyboard can only strand a switch that is held and
/// suppressed. Other removals, such as an unrelated Bluetooth keyboard
/// reconnecting, leave the hook and every switch intact.
pub fn device_removed(&mut self, shared: &Shared, ms: u64) {
if shared.has_owned() {
self.interrupted(shared, ms);
}
}
pub fn interrupted(&mut self, shared: &Shared, ms: u64) {
self.observations.clear();
self.quarantine = false;
Expand Down Expand Up @@ -451,6 +459,34 @@ mod tests {
)
}
#[test]
fn device_removal_interrupts_only_while_a_switch_is_held() {
let (mut state, shared, mut core, names) = setup(Mode::Active);
state.device_removed(&shared, 1);
shared.drain_into(&mut core, &names, 2);
assert_eq!(core.status.mode, Mode::Active);
assert!(core.events.is_empty());
assert!(state.key(&shared, 32, true, false, 3));
state.device_removed(&shared, 4);
shared.drain_into(&mut core, &names, 5);
assert_eq!(core.status.reason, Some(StopReason::CaptureLost));
assert!(shared.recovering.load(Acquire));
}
#[test]
fn device_removal_during_recovery_keeps_the_recovery_gesture() {
let (mut state, shared, mut core, names) = setup(Mode::Active);
assert!(state.key(&shared, 32, true, false, 1));
state.device_removed(&shared, 2);
shared.drain_into(&mut core, &names, 3);
state.replaced(true, 4);
assert!(state.key(&shared, 32, false, false, 5));
state.device_removed(&shared, 6);
assert!(state.key(&shared, 32, true, false, 7));
assert!(state.key(&shared, 32, false, false, 8));
shared.drain_into(&mut core, &names, 9);
assert!(shared.drained());
assert_eq!(core.events.len(), 1);
}
#[test]
fn missing_hook_edges_cancel_actions_and_require_a_separate_recovery_gesture() {
for missed_release in [false, true] {
let (mut state, shared, mut core, names) = setup(Mode::Active);
Expand Down
Loading