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/point-scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ The action menu uses a fixed grid of square icon tiles with labels beneath the a

## 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.
Select **Switchify scanning** on Switchify Remote's Android Forwarding screen. Remote switches live in the same list as keyboard switches under PC Settings → Switches: Add remote switch takes the next free number, and the number can be changed in the editor. Numbers match the switches shown on the Forwarding screen, up to eight. Defaults are Select, Next, Previous, Pause/resume, Reverse and Stop in switches one to six. Remote switches can be named and use the PC scan mode, colour, movement and hold timing; keyboard assignments are separate. Manual mode requires connected switches 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.
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 timing: hold actions work exactly as for keyboard switches, and holding a remote switch past the emergency limit resets the scan without ending the session. 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.
11 changes: 6 additions & 5 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1266,7 +1266,6 @@ fn save_remote_switches(
config: remote_scan::Config,
) -> Result<remote_scan::Config, String> {
require_main(&window)?;
point_scan_runtime::pause(&app);
remote_scan::save(&app, config)
}
#[tauri::command]
Expand All @@ -1286,9 +1285,11 @@ 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)
let view = app
.state::<switch_runtime::Controller>()
.save(&app, settings)?;
remote_scan::apply(&app);
Ok(view)
}
#[tauri::command]
fn begin_switch_capture(
Expand All @@ -1300,7 +1301,7 @@ fn begin_switch_capture(
return Err("Focus Switchify PC before learning a switch.".into());
}
point_scan_prepare(&app)?;
point_scan_runtime::pause(&app);
point_scan_runtime::interrupt(&app);
app.state::<switch_runtime::Controller>()
.begin_capture(&app)
}
Expand Down
9 changes: 7 additions & 2 deletions src-tauri/src/point_scan_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,17 @@ 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)
let view = scanning_runtime::configure::<PointScan>(app, config)?;
// A mode change alters which remote assignments a live session needs.
crate::remote_scan::apply(app);
Ok(view)
}
pub fn pause(app: &AppHandle) {
scanning_runtime::pause::<PointScan>(app);
}
pub fn interrupt(app: &AppHandle) {
scanning_runtime::interrupt::<PointScan>(app);
}
pub fn install(app: &AppHandle) {
scanning_runtime::install::<PointScan>(app);
}
Expand Down
101 changes: 94 additions & 7 deletions src-tauri/src/remote_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ const MAX_EVENTS: usize = 64;
pub struct Slot {
pub press_action: Option<Action>,
pub hold_actions: Vec<Action>,
/// User-facing name; falls back to "Remote switch N".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl Slot {
pub fn display_name(&self, index: usize) -> String {
self.name
.as_deref()
.map(str::trim)
.filter(|n| !n.is_empty())
.map_or_else(|| format!("Remote switch {}", index + 1), str::to_string)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
Expand Down Expand Up @@ -46,6 +58,7 @@ impl Default for Config {
.map(|press_action| Slot {
press_action,
hold_actions: vec![],
name: None,
})
.collect(),
}
Expand All @@ -57,7 +70,8 @@ impl Config {
|| self.revision == 0
|| self.slots.len() != 8
|| self.slots.iter().any(|s| {
s.hold_actions.len() > 32
s.name.as_ref().is_some_and(|n| n.chars().count() > 64)
|| s.hold_actions.len() > 32
|| s.press_action == Some(Action::Cancel)
|| s.hold_actions.contains(&Action::Cancel)
|| (s.press_action.is_none() && !s.hold_actions.is_empty())
Expand All @@ -79,7 +93,7 @@ impl Config {
.filter_map(|(i, s)| {
Some(Binding {
id: (i + 1).to_string(),
name: format!("Remote switch {}", i + 1),
name: s.display_name(i),
key: format!("F{}", i + 1),
press_action: s.press_action?,
hold_actions: s.hold_actions.clone(),
Expand All @@ -92,6 +106,7 @@ impl Config {
json!({"id":PROFILE_ID,"name":"Switchify scanning","version":self.revision,"kind":"scanning","bindings":self.slots.iter().enumerate().map(|(i,s)| {
let mut label = s.press_action.map_or("Unassigned", Action::label).to_string();
if !s.hold_actions.is_empty() { label.push_str("; hold: "); label.push_str(&s.hold_actions.iter().map(|a| a.label()).collect::<Vec<_>>().join(", ")); }
if s.press_action.is_some() && s.name.as_deref().is_some_and(|n| !n.trim().is_empty()) { label = format!("{}: {label}", s.display_name(i)); }
json!({"switchId":i+1,"label":label,"behavior":if s.press_action.is_some(){"stateful"}else{"unassigned"}})
}).collect::<Vec<_>>()})
}
Expand Down Expand Up @@ -156,9 +171,6 @@ impl Mailbox {
.as_u64()
.filter(|n| (1..=8).contains(n))
.ok_or("Invalid remote switch count")? as u8;
if payload["profileVersion"].as_u64() != Some(self.config.revision.into()) {
return Err("The scanning profile changed. Reload profiles and start again.".into());
}
let id = payload["sessionId"]
.as_str()
.filter(|id| uuid::Uuid::parse_str(id).is_ok())
Expand All @@ -178,6 +190,20 @@ impl Mailbox {
});
Ok(())
}
/// Applies the current config to a live session so edits take effect on the
/// next press instead of ending the session. A config the mode can no longer
/// use ends it, since scanning could not continue anyway.
fn apply(&mut self, interval: u64, automatic: bool) {
let Some(session) = self.session.as_mut() else {
return;
};
let settings = self.config.settings(session.count as usize, interval);
if settings.validate_actions(automatic).is_ok() {
session.settings = settings;
} else {
self.stop();
}
}
fn accept(
&mut self,
device: &str,
Expand Down Expand Up @@ -308,10 +334,31 @@ pub fn save(app: &AppHandle, mut config: Config) -> Result<Config, String> {
serde_json::to_vec_pretty(&config).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
data.stop();
data.config = config.clone();
drop(data);
apply(app);
Ok(config)
}
/// Re-derives a live session's settings from the saved remote config, the
/// shared hold interval and the current scan mode.
pub fn apply(app: &AppHandle) {
let Some(c) = app.try_state::<Controller>() else {
return;
};
let interval = app
.state::<crate::switch_runtime::Controller>()
.settings()
.hold_interval_ms;
let automatic = app
.state::<crate::point_scan_runtime::Controller>()
.view()
.config
.automatic;
c.data
.lock()
.unwrap_or_else(|p| p.into_inner())
.apply(interval, automatic);
}
pub fn cancel(app: &AppHandle) {
if let Some(c) = app.try_state::<Controller>() {
c.data.lock().unwrap_or_else(|p| p.into_inner()).stop();
Expand Down Expand Up @@ -488,7 +535,16 @@ mod tests {
m.start("peer", &start(3), 1000, false, 0).unwrap();
let mut changed = start(3);
changed["profileVersion"] = json!(2);
assert!(m.start("peer", &changed, 1000, true, 0).is_err());
m.start("peer", &changed, 1000, true, 0).unwrap();
m.config.slots[0].name = Some("Head switch".into());
assert_eq!(m.config.settings(1, 1000).bindings[0].name, "Head switch");
assert!(m.config.profile()["bindings"][0]["label"]
.as_str()
.unwrap()
.starts_with("Head switch: Select"));
m.config.slots[0].name = Some("x".repeat(65));
assert!(m.config.validate().is_err());
m.config.slots[0].name = None;
serde_json::from_slice::<Config>(&serde_json::to_vec(&m.config).unwrap())
.unwrap()
.validate()
Expand Down Expand Up @@ -532,6 +588,20 @@ mod tests {
);
}
#[test]
fn a_withdrawn_press_followed_by_a_new_press_resets_then_presses() {
// Remote reports a replacement press as a sync without the switch and
// then a fresh down, so the PC never sees a release it could act on.
let mut m = ready();
m.accept("peer", "switch.edge", &edge(2, 1, "down"), 1)
.unwrap();
m.queue.clear();
m.accept("peer", "switch.sync", &sync(3, vec![]), 2)
.unwrap();
m.accept("peer", "switch.edge", &edge(4, 1, "down"), 3)
.unwrap();
assert_eq!(m.queue, VecDeque::from([Edge::Reset, Edge::Down(1)]));
}
#[test]
fn stop_and_overflow_discard_queued_input() {
let mut m = ready();
let generation = m.generation;
Expand Down Expand Up @@ -560,6 +630,23 @@ mod tests {
assert!(m.queue.is_empty());
}
#[test]
fn applying_config_updates_a_live_session_or_ends_an_unusable_one() {
let mut m = ready();
m.config.slots[0].hold_actions = vec![Action::Stop];
m.apply(1000, true);
assert_eq!(
m.session.as_ref().unwrap().settings.bindings[0].hold_actions,
vec![Action::Stop]
);
m.config.slots[0].press_action = Some(Action::Next);
for slot in &mut m.config.slots[1..] {
slot.press_action = None;
slot.hold_actions.clear();
}
m.apply(1000, true);
assert!(m.session.is_none(), "no Select left, so the session ends");
}
#[test]
fn remote_edges_use_shared_release_and_hold_gestures() {
let mut m = ready();
m.config.slots[0].hold_actions = vec![Action::Next];
Expand Down
19 changes: 14 additions & 5 deletions src-tauri/src/scanning_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,17 @@ fn reset_scanner<A: Adapter>(app: &AppHandle, message: &str) {
hide_prompt();
publish::<A>(app);
}
/// Pauses scanning so switches can be saved or learned. Must run on the main
/// thread, as the commands that call it do; the tick loop re-arms afterwards.
/// Pauses scanning so settings can be saved. Must run on the main thread, as
/// the commands that call it do. A live remote session survives: only the
/// scanner and the local key broker reset, and the next tick restarts the
/// session with the freshly applied settings.
pub fn pause<A: Adapter>(app: &AppHandle) {
disable::<A>(app, "Scanning paused while switches change.");
reset_scanner::<A>(app, "Scanning paused while switches change.");
}
/// Stops scanning outright, ending any remote session, so the local key
/// broker is free for learning a key.
pub fn interrupt<A: Adapter>(app: &AppHandle) {
disable::<A>(app, "Scanning paused while a switch is learned.");
}
/// Saves new settings and re-arms scanning with them. A failure to arm is not
/// an error here: the settings are saved and the view's message says why
Expand All @@ -221,7 +228,7 @@ pub fn configure<A: Adapter>(
) -> Result<View<A::Config, <A::Technique as Technique>::Phase>, String> {
A::validate(&config)?;
let path = config_path::<A>(app)?;
disable::<A>(app, "Applying scanning settings...");
reset_scanner::<A>(app, "Applying scanning settings...");
let save = || -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -535,7 +542,9 @@ fn tick<A: Adapter>(app: &AppHandle) {
.is_some_and(|start| now_ms.saturating_sub(start) >= settings.escape_ms())
};
if expired {
disable::<A>(app, "Remote switch held. Start forwarding again.");
// Mirror the local emergency hold: reset the scan, keep the
// session. The next tick restarts remote scanning in place.
reset_scanner::<A>(app, "Switch held too long. Scan reset.");
return;
}
}
Expand Down
71 changes: 70 additions & 1 deletion src/Switches.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ const initial: SwitchState = {
unavailableKeys: [],
};
let current: SwitchState;
type RemoteSlotFixture = { pressAction: string | null; holdActions: string[]; name?: string };
const emptySlot: RemoteSlotFixture = { pressAction: null, holdActions: [] };
const initialRemote: { schemaVersion: number; revision: number; slots: RemoteSlotFixture[] } = {
schemaVersion: 1,
revision: 1,
slots: [{ pressAction: "select", holdActions: [] }, ...Array.from({ length: 7 }, () => emptySlot)],
};
let remote: typeof initialRemote;
const scan = {
config: defaultPointScanConfig,
enabled: false,
Expand Down Expand Up @@ -62,6 +70,7 @@ function event(next: SwitchState) {
}
beforeEach(() => {
current = structuredClone(initial);
remote = structuredClone(initialRemote);
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
value: {},
Expand All @@ -86,6 +95,11 @@ beforeEach(() => {
capture: { active: false, key: null, error: null },
};
return current;
case "get_remote_switches":
return structuredClone(remote);
case "save_remote_switches":
remote = { ...args.config, revision: remote.revision + 1 };
return remote;
case "get_point_scan":
return scan;
case "configure_point_scan":
Expand Down Expand Up @@ -193,7 +207,7 @@ it("shows hold timing computed from the interval", async () => {
).toBeTruthy();
fireEvent.click(within(screen.getByRole("group", { name: "Hold action interval" })).getByRole("button", { name: "2s" }));
await screen.findByText("Hold 2s for Next, 4s for Stop scanning. Release to run the action shown.");
await screen.findByText(/Holding any switch for 8s disables switch control/);
await screen.findByText(/Holding any switch for 8s resets the scan/);
});
it("preserves failed switch edits until a retry succeeds", async () => {
render(<Shell />);
Expand Down Expand Up @@ -331,3 +345,58 @@ it("keeps focus in the draft when another switch is removed while adding", async
await waitFor(() => expect(current.settings.bindings).toHaveLength(0));
expect(document.activeElement).toBe(screen.getByLabelText("New switch name"));
});
it("lists remote switches with local ones and edits them in place", async () => {
render(<Shell />);
await screen.findByRole("heading", { name: "Remote switch 1" });
expect(screen.getByText("Remote 1")).toBeTruthy();
open("Remote switch 1");
expect(screen.queryByRole("button", { name: /Learn another key/ })).toBeNull();
fireEvent.change(screen.getByLabelText("Normal action for Remote switch 1"), { target: { value: "next" } });
await waitFor(() => expect(remote.slots[0].pressAction).toBe("next"));
fireEvent.change(screen.getByLabelText("Name for Remote 1"), { target: { value: "Chin" } });
await waitFor(() => expect(remote.slots[0].name).toBe("Chin"));
await screen.findByRole("heading", { name: "Chin" });
fireEvent.click(screen.getByRole("button", { name: "Add hold action for Chin" }));
await waitFor(() => expect(remote.slots[0].holdActions).toEqual(["next"]));
expect(mocks.invoke.mock.calls.some(([c]) => c === "save_switches")).toBe(false);
});
it("adds a remote switch into the next free slot and can move it", async () => {
render(<Shell />);
await screen.findByRole("heading", { name: "Remote switch 1" });
fireEvent.click(screen.getByRole("button", { name: "Add remote switch" }));
expect(screen.queryByRole("dialog")).toBeNull();
const slot = screen.getByLabelText("Remote switch number for Remote switch 2");
expect(slot).toHaveValue("1");
fireEvent.change(slot, { target: { value: "3" } });
fireEvent.click(screen.getByRole("button", { name: "Save switch" }));
await waitFor(() => expect(remote.slots[3].pressAction).toBe("select"));
expect(remote.slots[1].pressAction).toBeNull();
await screen.findByRole("heading", { name: "Remote switch 4" });
open("Remote switch 4");
fireEvent.change(screen.getByLabelText("Remote switch number for Remote switch 4"), { target: { value: "1" } });
await waitFor(() => expect(remote.slots[1].pressAction).toBe("select"));
expect(remote.slots[3].pressAction).toBeNull();
expect(screen.getByRole("button", { name: "Close Remote switch 2" })).toBeTruthy();
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Close Remote switch 2" }));
});
it("moves focus into the remote draft and back to a usable Add button", async () => {
render(<Shell />);
await screen.findByRole("heading", { name: "Remote switch 1" });
fireEvent.click(screen.getByRole("button", { name: "Add remote switch" }));
expect(document.activeElement).toBe(screen.getByLabelText("New switch name"));
fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" }));
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Add switch" }));
});
it("removes a remote switch and surfaces a failed remote save with retry", async () => {
render(<Shell />);
await screen.findByRole("heading", { name: "Remote switch 1" });
mocks.invoke.mockImplementationOnce(async () => {
throw "Remote save failed";
});
fireEvent.click(screen.getByRole("button", { name: "Remove Remote switch 1" }));
await screen.findByText("Remote save failed");
expect(remote.slots[0].pressAction).toBe("select");
fireEvent.click(screen.getByRole("button", { name: "Retry save" }));
await waitFor(() => expect(remote.slots[0].pressAction).toBeNull());
expect(screen.queryByRole("heading", { name: "Remote switch 1" })).toBeNull();
});
Loading