diff --git a/docs/point-scan.md b/docs/point-scan.md index af57dd36..a52631b2 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -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. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 50faf926..06ee736a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1266,7 +1266,6 @@ fn save_remote_switches( config: remote_scan::Config, ) -> Result { require_main(&window)?; - point_scan_runtime::pause(&app); remote_scan::save(&app, config) } #[tauri::command] @@ -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::() - .save(&app, settings) + let view = app + .state::() + .save(&app, settings)?; + remote_scan::apply(&app); + Ok(view) } #[tauri::command] fn begin_switch_capture( @@ -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::() .begin_capture(&app) } diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 07713c8e..317c7d2a 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -20,12 +20,17 @@ pub fn configure(app: &AppHandle, config: Config) -> Result { 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::(app, config) + let view = scanning_runtime::configure::(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::(app); } +pub fn interrupt(app: &AppHandle) { + scanning_runtime::interrupt::(app); +} pub fn install(app: &AppHandle) { scanning_runtime::install::(app); } diff --git a/src-tauri/src/remote_scan.rs b/src-tauri/src/remote_scan.rs index f8d5a902..422e4108 100644 --- a/src-tauri/src/remote_scan.rs +++ b/src-tauri/src/remote_scan.rs @@ -19,6 +19,18 @@ const MAX_EVENTS: usize = 64; pub struct Slot { pub press_action: Option, pub hold_actions: Vec, + /// User-facing name; falls back to "Remote switch N". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} +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)] @@ -46,6 +58,7 @@ impl Default for Config { .map(|press_action| Slot { press_action, hold_actions: vec![], + name: None, }) .collect(), } @@ -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()) @@ -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(), @@ -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::>().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::>()}) } @@ -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()) @@ -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, @@ -308,10 +334,31 @@ pub fn save(app: &AppHandle, mut config: Config) -> Result { 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::() else { + return; + }; + let interval = app + .state::() + .settings() + .hold_interval_ms; + let automatic = app + .state::() + .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::() { c.data.lock().unwrap_or_else(|p| p.into_inner()).stop(); @@ -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::(&serde_json::to_vec(&m.config).unwrap()) .unwrap() .validate() @@ -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; @@ -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]; diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 7b553a8b..1ef01b0f 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -207,10 +207,17 @@ fn reset_scanner(app: &AppHandle, message: &str) { hide_prompt(); publish::(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(app: &AppHandle) { - disable::(app, "Scanning paused while switches change."); + reset_scanner::(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(app: &AppHandle) { + disable::(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 @@ -221,7 +228,7 @@ pub fn configure( ) -> Result::Phase>, String> { A::validate(&config)?; let path = config_path::(app)?; - disable::(app, "Applying scanning settings..."); + reset_scanner::(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())?; @@ -535,7 +542,9 @@ fn tick(app: &AppHandle) { .is_some_and(|start| now_ms.saturating_sub(start) >= settings.escape_ms()) }; if expired { - disable::(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::(app, "Switch held too long. Scan reset."); return; } } diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx index 556aff6c..11c0d502 100644 --- a/src/Switches.test.tsx +++ b/src/Switches.test.tsx @@ -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, @@ -62,6 +70,7 @@ function event(next: SwitchState) { } beforeEach(() => { current = structuredClone(initial); + remote = structuredClone(initialRemote); Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, value: {}, @@ -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": @@ -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(); @@ -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(); + 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(); + 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(); + 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(); + 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(); +}); diff --git a/src/scanning/useRemoteSwitches.ts b/src/scanning/useRemoteSwitches.ts new file mode 100644 index 00000000..3fad7687 --- /dev/null +++ b/src/scanning/useRemoteSwitches.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import type { SwitchAction } from "./useSwitches"; + +// Switches forwarded from Switchify Remote. Slots are positional: slot N is +// the Nth switch on the phone's Forwarding screen. The backend applies saved +// changes to a live remote session, so edits save as they happen, like keys. +export type RemoteSlot = { + pressAction: SwitchAction | null; + holdActions: SwitchAction[]; + name?: string; +}; +export type RemoteConfig = { + schemaVersion: number; + revision: number; + slots: RemoteSlot[]; +}; +export const remoteSlotCount = 8; + +export function useRemoteSwitches() { + const [config, setConfig] = useState(null); + const [pending, setPending] = useState(0); + const [error, setError] = useState(null); + const model = useRef({ + config: null as RemoteConfig | null, + revision: 0, + saved: 0, + pending: 0, + }); + const queue = useRef(Promise.resolve()); + useEffect(() => { + let alive = true; + if (!("__TAURI_INTERNALS__" in window)) return; + void invoke("get_remote_switches") + .then((value) => { + if (!alive) return; + model.current.config = value; + setConfig(value); + }) + .catch((e) => { + if (alive) setError(String(e)); + }); + return () => { + alive = false; + }; + }, []); + const save = (revision: number, next: RemoteConfig) => { + const m = model.current; + m.pending++; + setPending(m.pending); + queue.current = queue.current + .then(async () => { + if (revision !== m.revision) return; + setError(null); + const result = await invoke("save_remote_switches", { + config: next, + }); + m.saved = revision; + if (m.config) { + m.config = { ...m.config, revision: result.revision }; + setConfig(m.config); + } + }) + .catch((e) => setError(String(e))) + .finally(() => { + m.pending--; + setPending(m.pending); + }); + }; + const update = (slots: RemoteSlot[]) => { + const m = model.current; + if (!m.config) return; + const next = { ...m.config, slots }; + m.config = next; + m.revision++; + setConfig(next); + save(m.revision, next); + }; + return { + config, + pending, + error, + update, + retry: () => { + if (model.current.config) + save(model.current.revision, model.current.config); + }, + unsaved: model.current.revision !== model.current.saved, + }; +} +export type RemoteSwitchController = ReturnType; diff --git a/src/settings/RemoteSwitches.test.tsx b/src/settings/RemoteSwitches.test.tsx deleted file mode 100644 index b0f796c4..00000000 --- a/src/settings/RemoteSwitches.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, expect, it, vi } from "vitest"; -import { RemoteSwitches } from "./RemoteSwitches"; -const invoke = vi.hoisted(() => vi.fn()); -vi.mock("@tauri-apps/api/core", () => ({ invoke })); -const config = { schemaVersion: 1, revision: 1, slots: Array.from({ length: 8 }, (_, i) => ({ pressAction: i === 0 ? "select" : null, holdActions: [] })) }; -beforeEach(() => { Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); invoke.mockReset(); invoke.mockImplementation(async (command, args) => command === "get_remote_switches" ? structuredClone(config) : { ...args.config, revision: 2 }); }); -it("edits remote slots independently and saves ordered holds", async () => { - render(); - const action = await screen.findByLabelText("Remote switch 1 action"); - fireEvent.change(action, { target: { value: "next" } }); - fireEvent.click(screen.getByText("Add hold action for remote switch 1")); - fireEvent.change(screen.getByLabelText("Remote switch 1 hold action 1"), { target: { value: "select" } }); - fireEvent.click(screen.getByText("Save remote switches")); - await screen.findByRole("status"); - expect(invoke).toHaveBeenCalledWith("save_remote_switches", { config: expect.objectContaining({ slots: expect.arrayContaining([{ pressAction: "next", holdActions: ["select"] }]) }) }); - expect(invoke.mock.calls.some(([command]) => command === "save_switches")).toBe(false); -}); -it("retains edits on save failure and permits retry", async () => { - render(); await screen.findByLabelText("Remote switch 1 action"); - invoke.mockRejectedValueOnce(new Error("Save failed")); - fireEvent.click(screen.getByText("Save remote switches")); - await screen.findByRole("alert"); - await waitFor(() => expect(screen.getByText("Save remote switches")).not.toBeDisabled()); - fireEvent.click(screen.getByText("Save remote switches")); - await screen.findByRole("status"); -}); diff --git a/src/settings/RemoteSwitches.tsx b/src/settings/RemoteSwitches.tsx deleted file mode 100644 index 6c9cd674..00000000 --- a/src/settings/RemoteSwitches.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { actions, type SwitchAction } from "../scanning/useSwitches"; -import { SettingGroup } from "./controls"; -type Slot = { pressAction: SwitchAction | null; holdActions: SwitchAction[] }; -type Config = { schemaVersion: number; revision: number; slots: Slot[] }; -export function RemoteSwitches() { - const [config, setConfig] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [saved, setSaved] = useState(false); - useEffect(() => { - let alive = true; - if (!("__TAURI_INTERNALS__" in window)) return; - void invoke("get_remote_switches").then((value) => { if (alive) setConfig(value); }).catch((e) => { if (alive) setError(String(e)); }); - return () => { alive = false; }; - }, []); - const update = (index: number, slot: Slot) => { if (config) setConfig({ ...config, slots: config.slots.map((s, i) => i === index ? slot : s) }); setSaved(false); }; - const save = async () => { - if (!config) return; - setBusy(true); setError(null); setSaved(false); - try { setConfig(await invoke("save_remote_switches", { config })); setSaved(true); } - catch (e) { setError(String(e)); } - finally { setBusy(false); } - }; - return -

Remote owns scanning while forwarding. PC Escape stops it. Remote’s hold-to-stop and inactivity limits take priority over hold actions. Saving stops an active remote scan.

- {error &&

{error}

} - {config &&
- {config.slots.map((slot, index) =>
Remote switch {index + 1}: {slot.pressAction ? actions[slot.pressAction] : "Unassigned"} - - {slot.holdActions.map((action, hold) =>
- -
)} - -
)} - -
} - {saved &&

Remote switches saved. Reload profiles in Remote before starting again.

} -
; -} diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index 7bb9dab3..ed91ddc3 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -1,6 +1,6 @@ -import { RemoteSwitches } from "./RemoteSwitches"; import { useEffect, useId, useRef, useState } from "react"; -import { ChevronDown, ChevronUp, Keyboard, Trash2, X } from "lucide-react"; +import { ChevronDown, ChevronUp, Keyboard, Smartphone, Trash2, X } from "lucide-react"; +import { useRemoteSwitches, type RemoteSlot } from "../scanning/useRemoteSwitches"; import { actions, type Binding, @@ -86,6 +86,7 @@ function SwitchEditor({ onDone, onRemove, nameRef, + remote, }: { id?: string; binding: Binding; @@ -99,10 +100,14 @@ function SwitchEditor({ onDone: () => void; onRemove: () => void; nameRef: React.RefObject; + /** Present for a switch forwarded from Switchify Remote: its slot and the + * slots it could move to. Remote switches have a default name, so a new + * one can be saved unnamed. */ + remote?: { slot: number; free: number[]; onSlot: (slot: number) => void }; }) { - const name = binding.name || "this switch"; + const name = binding.name || (remote ? `Remote switch ${remote.slot + 1}` : "this switch"); const keyErrorId = useId(); - const canSave = !isNew || (!!binding.name.trim() && !!binding.key); + const canSave = !isNew || (!!binding.key && (!!binding.name.trim() || !!remote)); const move = (index: number, delta: number) => { const holdActions = [...binding.holdActions]; [holdActions[index], holdActions[index + delta]] = [ @@ -124,27 +129,46 @@ function SwitchEditor({ onChange={(e) => onChange({ ...binding, name: e.target.value })} /> -
- Key -
- - +
+ {(keyError || unavailable) && ( + + {keyError ?? + "This key is unavailable on this computer. Learn another key."} + + )}
- {(keyError || unavailable) && ( - - {keyError ?? - "This key is unavailable on this computer. Learn another key."} - - )} - + )}