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
8 changes: 8 additions & 0 deletions docs/point-scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,11 @@ Drag retains the source and scans a destination on the same display without hold
The desktop view adds menu, menuSuspended, dragDestination, dragConfirmation and executing phases. Existing point phases, saved settings and Bluetooth commands retain their shapes. Foreground changes, display changes, Android connections, switch editing/learning and shutdown cancel the workflow. Targets and foreground identity remain in memory and are never logged or emitted. Automated action tests use fake input only; physical Windows/macOS focus, scaling, target, scrolling and drag checks remain required for hardware qualification.

The action menu uses a fixed grid of square icon tiles with labels beneath the artwork. A yellow border and amber background identify the current row or item. The same artwork is drawn on Windows and macOS.

## Remote scanning

Select **Switchify scanning** on Switchify Remote's Android Forwarding screen. Assign up to eight numbered remote slots under PC Settings → Switches → Remote switches. Defaults are Select, Next, Previous, Pause/resume, Reverse and Stop, with two unassigned slots. Remote uses the PC scan mode, colour, movement and hold timing; local keyboard assignments are separate. Manual mode requires connected slots covering Select, Next and Previous.

Start forwarding, then press Select to begin. Local switch keys are inactive during the remote session; PC Escape remains an emergency stop. Forwarding's hold-to-stop and inactivity limits take precedence over hold actions. Saving remote assignments or shared scanner settings stops the session and changes the profile revision. Reload profiles before starting again.

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.
2 changes: 2 additions & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ fn main() {
let app_manifest = tauri_build::AppManifest::new().commands(&[
"get_app_state",
"get_switches",
"get_remote_switches",
"save_remote_switches",
"save_switches",
"begin_switch_capture",
"cancel_switch_capture",
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/capabilities/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"core:default",
"allow-get-app-state",
"allow-get-switches",
"allow-get-remote-switches",
"allow-save-remote-switches",
"allow-save-switches",
"allow-begin-switch-capture",
"allow-cancel-switch-capture",
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/permissions/autogenerated/get_remote_switches.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!

[[permission]]
identifier = "allow-get-remote-switches"
description = "Enables the get_remote_switches command without any pre-configured scope."
commands.allow = ["get_remote_switches"]

[[permission]]
identifier = "deny-get-remote-switches"
description = "Denies the get_remote_switches command without any pre-configured scope."
commands.deny = ["get_remote_switches"]
11 changes: 11 additions & 0 deletions src-tauri/permissions/autogenerated/save_remote_switches.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!

[[permission]]
identifier = "allow-save-remote-switches"
description = "Enables the save_remote_switches command without any pre-configured scope."
commands.allow = ["save_remote_switches"]

[[permission]]
identifier = "deny-save-remote-switches"
description = "Denies the save_remote_switches command without any pre-configured scope."
commands.deny = ["save_remote_switches"]
30 changes: 30 additions & 0 deletions src-tauri/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1468,6 +1468,36 @@ mod tests {
assert!(!input.has_active_drag());
}
#[test]
fn remote_stop_releases_drag_before_direct_input_and_blocks_failed_release() {
use crate::{
point_workflow::Request, remote_scan::input_available, scan_executor::execute,
};
for fail in [false, true] {
let mut input = DesktopInput::new(FakeInjector::default());
execute(&mut input, Request::DragStart((10, 20)), true).unwrap();
input.injector.fail_pointer_release = fail;
let mut cleanup_required = input.release_all().is_err();
if input_available(false, cleanup_required) {
input.click_pointer(MouseButton::Left, 1).unwrap();
}
if fail {
assert!(input.injector.clicks.is_empty());
assert!(input.has_active_drag());
input.injector.fail_pointer_release = false;
cleanup_required = input.release_all().is_err();
assert!(input_available(false, cleanup_required));
input.click_pointer(MouseButton::Left, 1).unwrap();
assert_eq!(
input.injector.events,
vec!["move", "down", "up", "up", "click"]
);
} else {
assert_eq!(input.injector.events, vec!["move", "down", "up", "click"]);
}
assert!(!input.has_active_drag());
}
}
#[test]
fn scan_drag_failures_remain_owned_until_cleanup_succeeds() {
use crate::{point_workflow::Request, scan_executor::execute};
let mut input = DesktopInput::new(FakeInjector::default());
Expand Down
25 changes: 24 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod point_scan_activation;
mod point_scan_runtime;
mod point_workflow;
mod protocol;
mod remote_scan;
mod scan_executor;
mod scan_host;
mod scan_menu;
Expand Down Expand Up @@ -1251,6 +1252,24 @@ fn require_main(window: &tauri::WebviewWindow) -> Result<(), String> {
Ok(())
}
#[tauri::command]
fn get_remote_switches(
window: tauri::WebviewWindow,
app: AppHandle,
) -> Result<remote_scan::Config, String> {
require_main(&window)?;
Ok(remote_scan::config(&app))
}
#[tauri::command]
fn save_remote_switches(
window: tauri::WebviewWindow,
app: AppHandle,
config: remote_scan::Config,
) -> Result<remote_scan::Config, String> {
require_main(&window)?;
point_scan_runtime::pause(&app);
remote_scan::save(&app, config)
}
#[tauri::command]
fn get_switches(
window: tauri::WebviewWindow,
app: AppHandle,
Expand All @@ -1267,6 +1286,7 @@ fn save_switches(
require_main(&window)?;
// Sync commands run on the main thread, which pausing the overlay needs.
point_scan_runtime::pause(&app);
remote_scan::save(&app, remote_scan::config(&app))?;
app.state::<switch_runtime::Controller>()
.save(&app, settings)
}
Expand Down Expand Up @@ -1320,7 +1340,7 @@ async fn configure_point_scan(
/// Pure environment check, safe to call every tick while scanning is off.
fn point_scan_ready(app: &AppHandle) -> Result<(), String> {
let state = app.state::<AppModel>().snapshot();
if state.bluetooth == state::BluetoothState::Connected {
if state.bluetooth == state::BluetoothState::Connected && !remote_scan::active(app) {
return Err("Local scanning pauses while Android is connected.".into());
}
if state.accessibility != state::AccessibilityState::Granted {
Expand Down Expand Up @@ -1376,6 +1396,7 @@ pub fn run() {
.manage(PendingNavigation::default())
.setup(move |app| {
switch_runtime::install(app.handle());
remote_scan::install(app.handle());
point_scan_runtime::install(app.handle());
install_tray(app)?;
if updater_is_configured(app.config().plugins.0.get("updater")) {
Expand Down Expand Up @@ -1467,6 +1488,8 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
get_switches,
get_remote_switches,
save_remote_switches,
save_switches,
begin_switch_capture,
cancel_switch_capture,
Expand Down
64 changes: 58 additions & 6 deletions src-tauri/src/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1134,7 +1134,8 @@ impl MacRuntime {
self.stop_all_repeats();
let dx = command.dx.round() as i32;
let dy = command.dy.round() as i32;
let injection = self.inject_pointer_move(dx, dy);
let injection = crate::remote_scan::allow_direct(&self.app)
.and_then(|_| self.inject_pointer_move(dx, dy));
if injection.is_ok() {
let feedback = self
.input
Expand Down Expand Up @@ -1176,7 +1177,8 @@ impl MacRuntime {
fn handle_mouse_click(&mut self, command: MouseClickCommand) {
self.app.state::<DwellController>().cancel(&self.app);
self.stop_all_repeats();
let injection = self.inject_pointer_click(command.button, command.click_count);
let injection = crate::remote_scan::allow_direct(&self.app)
.and_then(|_| self.inject_pointer_click(command.button, command.click_count));
if injection.is_ok() {
self.show_overlay(PointerFeedback::Click {
button: command.button,
Expand Down Expand Up @@ -1232,7 +1234,8 @@ impl MacRuntime {
|| self.stop_all_repeats(),
);
let character_count = command.text.chars().count();
let injection = self.inject_text(&command.text);
let injection = crate::remote_scan::allow_direct(&self.app)
.and_then(|_| self.inject_text(&command.text));
typing_route.finish(injection.is_ok(), || {
self.app.state::<CursorOverlay>().hide_for_typing()
});
Expand Down Expand Up @@ -1266,6 +1269,53 @@ impl MacRuntime {
}

fn handle_desktop(&mut self, command: DesktopCommand) {
if command.command_type == "switch.session.start" {
self.stop_all_repeats();
if let Some(input) = self.input.as_mut() {
if let Err(error) = input.release_all() {
let response = self
.shared
.lock()
.unwrap_or_else(|p| p.into_inner())
.engine
.complete_desktop_command_with_error(
&command,
Err(("input_failed", error.as_str())),
);
if let Some(response) = response {
if let Err(error) = self.enqueue_message(&response) {
self.report_error(error);
}
}
return;
}
}
}
if let Some(result) = crate::remote_scan::route(
&self.app,
&command.device_id,
&command.command_type,
&command.payload,
) {
let response = self
.shared
.lock()
.unwrap_or_else(|p| p.into_inner())
.engine
.complete_desktop_command_with_error(
&command,
result
.as_ref()
.map(|_| ())
.map_err(|e| ("input_failed", e.as_str())),
);
if let Some(response) = response {
if let Err(error) = self.enqueue_message(&response) {
self.report_error(error);
}
}
return;
}
if command.command_type == "mouse.repeat.start" {
self.handle_repeat_start(command);
return;
Expand Down Expand Up @@ -1296,9 +1346,11 @@ impl MacRuntime {
.clone();
if command.command_type == "switch.profile.list" {
self.stop_all_repeats();
if let Err(error) =
self.enqueue_message(&switch_profile_catalog_response(&command.id, &profiles))
{
if let Err(error) = self.enqueue_message(&crate::remote_scan::catalog(
&self.app,
&command.payload,
switch_profile_catalog_response(&command.id, &profiles),
)) {
self.report_error(error);
}
return;
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/point_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,7 @@ mod tests {
assert!(!config.switches().automatic);
assert_eq!(config.point().grid_size, 7);
let view = crate::scanning_runtime::View {
remote: false,
config,
enabled: true,
phase: Phase::Cell,
Expand All @@ -847,7 +848,7 @@ mod tests {
};
assert_eq!(
serde_json::to_value(view).unwrap(),
serde_json::json!({"config":json,"enabled":true,"phase":"cell","paused":true,"message":"Ready","supported":true})
serde_json::json!({"config":json,"enabled":true,"phase":"cell","paused":true,"message":"Ready","supported":true,"remote":false})
);
}
#[test]
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/point_scan_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub fn configure(app: &AppHandle, config: Config) -> Result<View, String> {
if config.switches().keys() != previous.switches().keys() {
return Err("Edit key assignments in Settings → Switches.".into());
}
crate::remote_scan::save(app, crate::remote_scan::config(app))?;
scanning_runtime::configure::<PointScan>(app, config)
}
pub fn pause(app: &AppHandle) {
Expand Down
28 changes: 27 additions & 1 deletion src-tauri/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -982,9 +982,12 @@ fn valid_desktop_payload(command: &str, payload: &Value) -> bool {
.is_some_and(|sequence| sequence > 0)
};
match command {
"switch.profile.list" | "connection.disconnecting" | "mouse.repeat.stop" => {
"switch.profile.list" => {
object.is_empty()
|| (object.len() == 1
&& object.get("includeScanning").is_some_and(Value::is_boolean))
}
"connection.disconnecting" | "mouse.repeat.stop" => object.is_empty(),
"mouse.repeat.start" => valid_repeat_start(object),
"switch.session.start" => {
object.len() == 4
Expand Down Expand Up @@ -1400,6 +1403,7 @@ pub fn pointer_profile_response(
},
"capabilities": {
"noAckMouseMove": true,
"switchScanning": cfg!(any(target_os = "windows", target_os = "macos")),
"noAckCommands": [
"mouse.move",
"mouse.click",
Expand Down Expand Up @@ -1580,6 +1584,28 @@ pub fn switch_profile_catalog_response(
mod tests {
use super::*;

#[test]
fn scanning_catalog_opt_in_preserves_existing_requests() {
assert!(valid_desktop_payload("switch.profile.list", &json!({})));
assert!(valid_desktop_payload(
"switch.profile.list",
&json!({"includeScanning":true})
));
assert!(!valid_desktop_payload(
"switch.profile.list",
&json!({"includeScanning":"yes"})
));
let legacy: Value = serde_json::from_str(&switch_profile_catalog_response(
"id",
&crate::state::built_in_profiles(true),
))
.unwrap();
assert!(legacy["payload"]["profiles"]
.as_array()
.unwrap()
.iter()
.all(|p| p["kind"] != "scanning"));
}
#[test]
fn bluetooth_status_advertises_platform_without_a_version_bump() {
for platform in ["windows", "macos"] {
Expand Down
Loading