diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31b257d6..5bab4d1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,8 @@ jobs: bundles: nsis - os: macos-14 bundles: app,dmg + - os: ubuntu-24.04 + bundles: none runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -111,6 +113,11 @@ jobs: with: toolchain: 1.97.1 components: rustfmt, clippy + - name: Install Linux development libraries + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libxkbcommon-dev libdbus-1-dev - run: npm ci - run: cargo fmt --manifest-path src-tauri/Cargo.toml --check - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings @@ -126,6 +133,9 @@ jobs: run: cargo test --locked --manifest-path src-tauri/startup-launcher/Cargo.toml - if: runner.os == 'macOS' run: npm run tauri build -- --bundles ${{ matrix.bundles }} --no-sign + - name: Build Linux development application + if: runner.os == 'Linux' + run: npm run tauri build -- --debug --no-bundle - if: runner.os == 'Windows' run: pwsh ./scripts/Build-WindowsUiAccess.ps1 -SkipSign - if: runner.os == 'Windows' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5b34006d..b673b4a5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,8 +2,6 @@ name: CodeQL on: pull_request: - branches: - - main push: branches: - main diff --git a/README.md b/README.md index e251746e..2e6bb022 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,32 @@ npm run tauri dev ## Checks +### Linux development foundation + +Linux currently builds a development UI only. Bluetooth pairing, keyboard/mouse input, +and native feedback overlays are not implemented. X11 and Wayland control are both +unavailable at this stage; this is not a supported Linux release. See the +[Linux implementation plan](docs/linux-support-plan.md) for the remaining milestones. + +On Ubuntu 24.04 x86_64, install Node.js 24, Rust 1.97.1 and the native dependencies: + +```bash +sudo apt-get update +sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libxkbcommon-dev libdbus-1-dev +npm ci +npm run tauri dev +``` + +Use `npm run tauri build -- --debug --no-bundle` to build the development executable. +Linux reports Bluetooth and input unavailable and does not open feedback windows. +It always opens the main window, including at startup, and closing that window quits +after any profile-edit confirmation. Tray/background behavior and Linux installers +are deferred until qualified. CI builds and tests on Ubuntu without injecting input; +physical Bluetooth and graphical session testing are still required before support +can be enabled. The next implementation milestone is BlueZ peripheral interoperability. + +### Repository validation + ```bash npm run lint npm test @@ -56,7 +82,7 @@ cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings cargo test --manifest-path src-tauri/Cargo.toml ``` -Rust tests use fake input adapters and never control the local pointer or keyboard. Native checks and unsigned bundles run on Windows and macOS in `.github/workflows/ci.yml`. +Rust tests use fake input adapters and never control the local pointer or keyboard. Native checks and unsigned bundles run on Windows and macOS in `.github/workflows/ci.yml`; Ubuntu also runs native checks and an unbundled development build. ## Production releases and signed updates diff --git a/docs/linux-support-plan.md b/docs/linux-support-plan.md new file mode 100644 index 00000000..734d54a3 --- /dev/null +++ b/docs/linux-support-plan.md @@ -0,0 +1,138 @@ +# Linux implementation plan + +Tracking issue: [#710](https://github.com/switchifyapp/switchify-pc/issues/710) (planning only). +Source baseline: `d222639`, version `1.0.0-rc.6`. +Status: proposed implementation sequence; no Linux runtime support is delivered by this document. + +## Goal and support boundaries + +Enable the existing Switchify Android client to securely pair with and control a Linux desktop. Preserve the product identity, protocol v1, pairing approval, persisted settings, and existing macOS and Windows behavior. + +The proposed first supported configuration is Ubuntu 24.04 x86_64 with an X11 desktop session, BlueZ, a Bluetooth adapter that successfully supports connectable LE advertising, and an available persistent desktop credential store. Treat Debian as a subsequent validation target until an exact release and desktop have passed the matrix below. A successful Linux build alone does not qualify a platform as supported. + +The first public milestone includes pairing, reconnection, text and keyboard input, pointer movement, clicks, dragging, scrolling, switch profiles, repeat controls, diagnostics, startup, and recoverable background operation. Cursor feedback and dwell must either pass their acceptance tests or be explicitly disabled in UI and wire capabilities. Grid 3 and Windows UIAccess stay unavailable. Window management commands and display navigation are exposed only after their desktop-specific behavior is validated. + +Native Wayland support is a separate milestone. A working XWayland window does not establish control of native Wayland applications. Do not label the first milestone as general Linux support without stating the X11 requirement. ARM64, Flatpak, Snap, RPM, and broad distro coverage follow independently. + +## Existing foundation and gaps + +| Area | Current source | Implementation implication | +| --- | --- | --- | +| Runtime dispatch | [lib.rs](../src-tauri/src/lib.rs), [macos.rs](../src-tauri/src/macos.rs), [windows_runtime.rs](../src-tauri/src/windows_runtime.rs) | Add Linux implementations for installation, shutdown, access checks, pairing approval/rejection, disconnect, repeats, and dwell. Runtime hooks currently exist only for macOS and Windows. | +| BLE and protocol | [protocol.rs](../src-tauri/src/protocol.rs), [ble_lifecycle.rs](../src-tauri/src/ble_lifecycle.rs) | Reuse authentication, framing and recovery machinery. Add a BlueZ server transport; selectively expose reusable items currently gated to macOS/tests. | +| Input | [input.rs](../src-tauri/src/input.rs), [Cargo.toml](../src-tauri/Cargo.toml) | `InputInjector` and `DesktopInput` provide a seam for Linux. Enigo 0.6.1 is pinned; generic injection exists, but Linux window actions return an error. | +| Visual feedback | [overlay.rs](../src-tauri/src/overlay.rs), [modifier_overlay.rs](../src-tauri/src/modifier_overlay.rs), [display_navigation.rs](../src-tauri/src/display_navigation.rs) | Cursor overlay lacks a Linux module. Modifier overlay has a generic webview path requiring runtime verification. Display calculations can be reused after coordinate validation. | +| Capabilities and UI | [state.rs](../src-tauri/src/state.rs), [App.tsx](../src/App.tsx), [api.ts](../src/api.ts) | Linux exists as a capability value with overlays/navigation disabled. Replace binary platform assumptions, setup text, sample state and unconditional feature claims. | +| Persistence/startup | [storage.rs](../src-tauri/src/storage.rs), [lib.rs](../src-tauri/src/lib.rs) | Non-macOS token storage uses keyring; verify the actual pinned Linux backend and persistence. Autostart has a generic non-Windows path. | +| CI and release | [ci.yml](../.github/workflows/ci.yml), [release-tauri.yml](../.github/workflows/release-tauri.yml), [create-update-feed.mjs](../scripts/create-update-feed.mjs) | Native CI covers Windows/macOS only. Release artifact selection and updater keys assume those two platforms. | + +Static inspection identifies missing runtime and overlay symbols on Linux. The initial assessment did not execute a native build because Cargo was unavailable in that environment; milestone 1 must establish the complete compiler/dependency baseline. + +## Ordered implementation milestones + +Each milestone becomes a separate implementation issue and focused branch from current `main`, with a draft PR, validation evidence, and independent review under [AGENTS.md](../AGENTS.md). Closing #710 accepts the plan only. Do not treat it as completion of Linux support. Keep Linux disabled for public releases until the release gates pass. + +### 1. Establish a Linux build and test baseline + +- Install Node.js 24 and Rust 1.97.1 and resolve the Linux development libraries required by the pinned Tauri, Enigo and credential dependencies, including WebKitGTK 4.1 and AppIndicator integration. +- Add `linux_runtime.rs` and a Linux cursor-overlay module or explicit unavailable implementation behind `cfg(target_os = "linux")`. Make unsupported features safe at startup and correctly represented in capabilities; do not grant input access through a stub. +- Audit all non-Windows/non-macOS branches, including shutdown, modifier overlay initialization, tray availability, and unsupported window actions. Optional overlay or tray failure must not abort the usable main window. +- Add Ubuntu native build, Clippy and tests to CI. Use injected fake adapters; a headless test job must not control the runner's input or need physical Bluetooth hardware. +- Extract shared runtime command/session logic only where needed for the Linux backend; test existing platform behavior before moving it. + +Exit: Linux compiles and launches its UI with honest unavailable states; existing native jobs remain green. Record exact build/runtime packages and the tested desktop session. + +### 2. Prove BlueZ peripheral interoperability + +- Evaluate a maintained Rust BlueZ server binding versus direct D-Bus integration against the required APIs before selecting a dependency. Central-only scanning libraries are insufficient. +- Implement local GATT service registration and connectable advertising using the existing service, RX, TX and status UUIDs and characteristic properties. +- Prototype reads, writes, offsets, write-without-response, notification subscription, fragmentation and negotiated MTU behavior using the existing Android app and at least two BLE adapters. +- Establish how incoming requests identify a peer and how outbound replies are delivered. BlueZ subscription notifications may not provide the same per-central targeting as other backends: prove that a pairing token or response cannot be delivered to an unintended subscriber. If targeted delivery cannot be guaranteed, enforce a tested single-peer policy before returning sensitive data. +- Record ordinary-user D-Bus permissions, missing adapter/daemon behavior, advertising limits, rfkill and powered-off behavior. Do not run the UI as root or assume every Bluetooth adapter can advertise. + +Exit: Android discovers the desktop and exchanges the existing framed protocol through BlueZ. Document peer/subscription isolation, adapter requirements and the chosen transport library. This is the first technical go/no-go checkpoint. + +### 3. Integrate secure sessions, storage and recovery + +- Route the BlueZ transport through the existing protocol engine. Preserve canonical JSON authentication, frame/queue limits, timestamps, replay protection, constant-time comparison, pairing approval, and sanitized errors. +- Serialize transport events and input operations with an explicit runtime owner. Invalidate stale callbacks and queued notifications using session/recovery generations. Avoid holding the shared model lock over D-Bus operations. +- Implement bounded notification backpressure and error handling. Reuse recovery scheduling for radio changes, adapter removal, daemon restart and suspend/resume; prevent duplicate service registrations or advertisements. +- Make disconnect, unsubscribe, authentication failure, timeout, device forgetting, suspend and application exit cancel repeats/dwell and release held keys/buttons. Test cleanup after partial injection failures. +- Verify a persistent Linux credential backend in the pinned keyring version. Handle absent/locked stores with actionable state, preserve saved pairing records on storage failure, and avoid silently accepting a pairing that cannot persist. Test restart and logout/login; do not silently fall back to plaintext or volatile storage. +- Retain single-instance behavior and diagnose conflicts without removing another process's advertisements or unrelated Bluetooth pairings. + +Exit: secure pairing survives restart; all teardown paths clean up deterministically; repeated power/daemon disruptions recover without stale commands. Authentication and storage regression tests pass across platforms. + +### 4. Deliver and validate X11 input + +- Instantiate Enigo through the existing input abstraction and report access according to usable session/backend state. Detect a Wayland session explicitly and show the X11 requirement until milestone 7 passes. +- Validate text, shortcuts, modifier latching, switch press/release, pointer scale, dragging, both scroll axes, media controls, mouse/key repeat and disconnect cleanup. Cover Unicode, non-US layouts, physical modifier interaction and repeated identical keys. +- Implement Linux window commands only for documented desktop mappings, or expose unsupported results consistently. Audit Android supported-command lists as well as desktop controls. Add backward-compatible capability tests for any protocol additions; inspect Android consumption before promising that no client change is necessary. +- Wire repeat cancellation and dwell through real Linux runtime hooks. Keep unsupported controls disabled rather than accepting operations with no effect. + +Exit: existing Android control works in real X11 applications under a normal user, with no stuck input after forced disconnect. Every advertised command has automated adapter coverage and a recorded manual result. + +### 5. Add feedback and desktop integration + +- Reuse shared cursor rendering for a Linux overlay. Verify transparency, click-through, non-focusability, stacking, fullscreen/workspace behavior and deterministic hiding on session end. +- Test the generic modifier overlay and disable it gracefully if unsupported. Never allow a feedback window to steal keyboard focus. +- Verify pointer position and monitor coordinates, including negative origins, scaling, hotplug and display navigation. Enable capabilities only after validation. Dwell must have reliable pointer tracking and visible progress/cancellation feedback before being enabled. +- Validate tray menu access, close-to-background and start-hidden behavior on the selected desktop. Provide an accessible recovery path when no tray host is present so closing the window does not strand the user. +- Test startup registration with installed paths, moved AppImages, duplicate instances and disabled startup. Update setup/support text with Bluetooth, credential-store and session-specific remediation. + +Exit: supported desktop integration works without focus theft; missing optional components leave a usable app; capabilities match measured support. + +### 6. Package and release the X11 milestone + +- Produce an x86_64 `.deb` and AppImage with the existing identity and Linux-specific dependency metadata. Build on the oldest selected supported base with compatible WebKitGTK/glibc; test installation on clean machines. +- Use signed Tauri updater artifacts for the AppImage path after validating the pinned updater plugin's Linux format and restart behavior. `.deb` installations should use package-managed/manual package upgrades and must not attempt to replace themselves with an AppImage. +- Extend artifact discovery, signature/format verification, feed tests and release workflow with `linux-x86_64` only when the Linux release gate is enabled. Preserve existing Windows/macOS keys and legacy release metadata. +- Publish the combined feed only after every platform required for that release passes verification. Before Linux is enabled, Linux experimentation must not block existing releases. +- Test fresh install, pairing persistence through upgrade, cancelled download, invalid signature, interrupted update, unwritable installation path, clean restart and recovery using a previously published package. Uninstall must not unexpectedly erase pairing/settings data. +- Document exact supported OS, desktop/session, architecture and Bluetooth prerequisites. Keep ARM and additional formats out of the first release claim. + +Exit: install and upgrade succeed on clean supported machines, signed updater checks pass, and the complete X11 acceptance matrix has evidence attached to the release issue. + +### 7. Qualify Wayland as a separate support milestone + +- Prototype consent-based RemoteDesktop portal/libei input for GNOME and KDE, recording exact compositor and portal versions. Evaluate text/layout fidelity, relative/absolute movement, coordinate mapping, session restoration, permission denial and logout/suspend behavior. +- Evaluate compositor-specific protocols only as explicitly scoped alternatives. Enigo's experimental backends are feasibility starting points, not a support guarantee. +- If a kernel `uinput` helper is necessary, write a separate architecture/security decision covering authenticated local IPC, minimum permissions, active-seat/session restrictions, lock-screen behavior, bounded command validation, cleanup, helper updates and uninstall. Avoid world-writable device access and a root UI. A virtual input device does not by itself solve global pointer observation, monitor navigation or overlays. +- Independently qualify pointer observation and overlay positioning. Keep unavailable feedback/navigation features disabled on compositors that cannot provide them; require suitable feedback before enabling dwell. +- Test native Wayland and XWayland applications, denied/revoked permission, startup without a restored session, remote/local session boundaries, and lock/logout transitions. Publish a compositor-specific feature matrix. + +Exit: choose and document the supported backend and permission UX, then enable only the compositor/features proven by the matrix. If no acceptable route passes, retain the explicit X11 support boundary and record the unresolved requirements. + +## Validation matrix and release evidence + +| Dimension | Required evidence | +| --- | --- | +| Transport hardware | At least two independent advertising-capable adapters and two Android devices; discovery, pairing, small MTU, subscription churn, queue saturation, disconnect and reconnect. | +| Trust boundaries | Unapproved/invalid/replayed commands rejected; competing subscribers cannot receive secrets; no typed text, tokens, signatures or raw payloads in diagnostics. | +| Lifecycle | Bluetooth off/on, rfkill, adapter removal, daemon restart, suspend/resume, app exit/crash and device forgetting; verify no stuck keys/buttons or replayed queued input. | +| Input | X11 real applications, Unicode and multiple keyboard layouts, modifiers, media, scrolling, dragging and repeat controls; fake injectors for automated coverage. | +| Desktop | Selected X11 desktop with/without tray host, multiple monitors/scales, negative monitor coordinates, fullscreen, workspace changes and autostart. | +| Persistence | Available, absent and locked credential store; failed writes; restart/logout; update preserves identity, settings and pairings. | +| Distribution | Clean Ubuntu 24.04 x86_64 install, `.deb` upgrade, signed AppImage update/restart, failure recovery. Additional releases/desktops require their own recorded results. | +| Existing clients | Protocol fixtures and current Android interoperability; unchanged behavior for Windows/macOS; older capability consumers handle additions safely. | +| Wayland (later) | Exact GNOME/KDE and portal versions, native apps, consent denial/revocation, session restoration, lock/logout and per-feature limitations. | + +Run the repository-required frontend lint/tests/build and Rust format/Clippy/tests with the pinned toolchains for implementation PRs, plus Linux native bundling. Use mocked D-Bus, credentials and input for CI, and an explicitly operated physical test system for hardware checks. Repeat existing Windows and macOS validation for shared-code changes. Independent review must cover the latest PR head; re-review after fixes. Do not merge without user instruction. + +## Sequencing, estimates and decisions + +Milestone 1 precedes 2; 2 precedes secure transport integration in 3; 3 and 4 must both pass before end-to-end qualification in 5 and release work in 6. The Wayland feasibility investigation can begin after the input seam in 4 exists, but it does not gate the explicitly X11 release. + +Planning estimates for one experienced Rust/Linux engineer are 1–2 weeks for a hardware prototype, 4–6 weeks total for a constrained X11 milestone, and 8–12+ weeks total for production qualification including selected Wayland desktops. These are provisional ranges, not commitments. Re-estimate after the BlueZ peer-isolation prototype and Wayland input/overlay tests; hardware access and upstream limitations can dominate elapsed time. + +Before expanding release scope, record decisions on the exact desktop matrix, Bluetooth adapter requirements, credential backend, per-peer notification safety, update ownership by package format, and Wayland permissions. Create implementation issues as work is scheduled rather than assuming all later milestones are already approved for release. + +## Technical references + +Verify these APIs against the chosen dependency and OS versions during implementation: + +- [BlueZ local GATT registration](https://github.com/bluez/bluez/blob/master/doc/org.bluez.GattManager.rst) +- [BlueZ advertising API](https://github.com/bluez/bluez/blob/master/doc/org.bluez.LEAdvertisement.rst) +- [Enigo 0.6.1 Linux backends and limitations](https://docs.rs/crate/enigo/0.6.1) +- [Tauri Linux prerequisites](https://v2.tauri.app/start/prerequisites/) +- [Tauri AppImage distribution constraints](https://v2.tauri.app/distribute/appimage/) diff --git a/src-tauri/src/dwell.rs b/src-tauri/src/dwell.rs index 2f207251..44dfb6e5 100644 --- a/src-tauri/src/dwell.rs +++ b/src-tauri/src/dwell.rs @@ -3,6 +3,7 @@ use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager}; +#[cfg(not(target_os = "linux"))] use crate::display_navigation; use crate::input::PointerFeedback; use crate::overlay::CursorOverlay; @@ -228,6 +229,12 @@ fn schedule_tick(app: AppHandle, generation: u64) { }); } +#[cfg(target_os = "linux")] +fn sample_pointer(_app: &AppHandle) -> Result { + Err("Dwell pointer tracking is unavailable on Linux.".into()) +} + +#[cfg(not(target_os = "linux"))] fn sample_pointer(app: &AppHandle) -> Result { let (position, displays) = display_navigation::displays(app) .map_err(|_| "The dwell pointer position could not be read.".to_string())?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 68bac917..d6f286be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,20 +1,33 @@ +// These shared engines remain compiled/tested on Linux before its transport is +// connected. Remove the Linux dead-code allowances as their adapters are wired. +#[cfg_attr(target_os = "linux", allow(dead_code))] mod ble_lifecycle; mod diagnostics; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod display_navigation; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod dwell; #[cfg(target_os = "windows")] mod grid3; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod input; +#[cfg(target_os = "linux")] +mod linux_runtime; #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "macos")] mod macos_overlay_window; #[cfg(target_os = "macos")] mod macos_relaunch; +#[cfg_attr(target_os = "linux", path = "modifier_overlay_unavailable.rs")] mod modifier_overlay; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod mouse_repeat; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod overlay; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod protocol; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod state; mod storage; mod telemetry; @@ -28,11 +41,21 @@ mod windows_security; #[cfg(target_os = "windows")] mod windows_startup; +#[cfg(target_os = "linux")] +use linux_runtime::{ + approve_pairing as platform_approve_pairing, + check_accessibility as platform_check_accessibility, disconnect_all as platform_disconnect_all, + install as platform_install, reject_pairing as platform_reject_pairing, + shutdown as platform_shutdown, +}; use state::{ snapshot, ActivityKind, AppModel, AppSettings, AppState, PairedDeviceView, SwitchProfile, }; use std::sync::Mutex; -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::menu::MenuItem; +#[cfg(not(target_os = "linux"))] +use tauri::menu::{Menu, PredefinedMenuItem}; +#[cfg(not(target_os = "linux"))] use tauri::tray::TrayIconBuilder; #[cfg(target_os = "windows")] use tauri::tray::{MouseButton, TrayIconEvent}; @@ -105,8 +128,10 @@ fn request_profile_exit(app: &AppHandle, action: ProfileExitAction) { } } +#[cfg(any(not(target_os = "linux"), test))] const NAVIGATE_REQUESTED_EVENT: &str = "navigate-requested"; +#[cfg(not(target_os = "linux"))] fn show_tray_menu_on_left_click() -> bool { cfg!(target_os = "macos") } @@ -115,6 +140,7 @@ fn show_tray_menu_on_left_click() -> bool { struct PendingNavigation(Mutex>); impl PendingNavigation { + #[cfg(any(not(target_os = "linux"), test))] fn set(&self, destination: &str) { *self .0 @@ -130,6 +156,7 @@ impl PendingNavigation { } } +#[cfg(not(target_os = "linux"))] fn show_main_window(app: &AppHandle, destination: Option<&str>) { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); @@ -349,6 +376,7 @@ async fn disconnect_all_on_main_thread(app: AppHandle) -> Result) -> Result { Ok(model.snapshot()) } +#[cfg(not(target_os = "linux"))] fn install_tray(app: &mut tauri::App) -> tauri::Result<()> { let show = MenuItem::with_id(app, "show", "Show Switchify PC", true, None::<&str>)?; let settings = MenuItem::with_id(app, "settings", "Open settings", true, None::<&str>)?; @@ -1231,7 +1260,7 @@ pub fn run() { let modifier_overlay_shared = shared.clone(); tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, args, _| { - if has_start_hidden_argument(&args) { + if should_start_hidden(&args) { return; } if let Some(window) = app.get_webview_window("main") { @@ -1250,6 +1279,7 @@ pub fn run() { .manage(PendingProfileExit::default()) .manage(PendingNavigation::default()) .setup(move |app| { + #[cfg(not(target_os = "linux"))] install_tray(app)?; if updater_is_configured(app.config().plugins.0.get("updater")) { let model = app.state::(); @@ -1310,7 +1340,7 @@ pub fn run() { _ => {} } } - if has_start_hidden_argument(&std::env::args().collect::>()) { + if should_start_hidden(&std::env::args().collect::>()) { if let Some(window) = app.get_webview_window("main") { let _ = window.hide(); } @@ -1329,7 +1359,7 @@ pub fn run() { } if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); - request_profile_exit(window.app_handle(), ProfileExitAction::Hide); + request_profile_exit(window.app_handle(), close_window_action()); } }) .invoke_handler(tauri::generate_handler![ @@ -1365,6 +1395,19 @@ fn has_start_hidden_argument(args: &[String]) -> bool { args.iter().any(|argument| argument == "--start-hidden") } +fn should_start_hidden(args: &[String]) -> bool { + // Linux has no qualified tray integration yet; always keep a recovery window. + !cfg!(target_os = "linux") && has_start_hidden_argument(args) +} + +fn close_window_action() -> ProfileExitAction { + if cfg!(target_os = "linux") { + ProfileExitAction::Quit + } else { + ProfileExitAction::Hide + } +} + #[cfg(target_os = "macos")] fn platform_install(app: AppHandle, shared: state::SharedModel) -> Result<(), String> { macos::install(app, shared) @@ -1441,15 +1484,24 @@ fn platform_disconnect_all(app: &AppHandle, shared: &state::SharedModel) -> Resu #[cfg(test)] mod tests { + #[cfg(not(target_os = "linux"))] + use super::show_tray_menu_on_left_click; use super::{ - has_start_hidden_argument, record_update_failure, show_tray_menu_on_left_click, - updater_is_configured, validate_profile, PendingNavigation, PendingProfileExit, - ProfileExitAction, TraySnapshot, NAVIGATE_REQUESTED_EVENT, + has_start_hidden_argument, record_update_failure, updater_is_configured, validate_profile, + PendingNavigation, PendingProfileExit, ProfileExitAction, TraySnapshot, + NAVIGATE_REQUESTED_EVENT, }; use crate::state::{AppModel, BluetoothState, SwitchBinding, SwitchProfile}; use crate::storage::AppStorage; use serde_json::json; + #[cfg(target_os = "linux")] + #[test] + fn linux_without_tray_cannot_be_stranded_in_background() { + assert!(!super::should_start_hidden(&["--start-hidden".into()])); + assert_eq!(super::close_window_action(), ProfileExitAction::Quit); + } + fn custom_profile() -> SwitchProfile { SwitchProfile { id: "3a393675-6434-4e50-a62f-d85ac24bcdf5".into(), diff --git a/src-tauri/src/linux_runtime.rs b/src-tauri/src/linux_runtime.rs new file mode 100644 index 00000000..3f48354a --- /dev/null +++ b/src-tauri/src/linux_runtime.rs @@ -0,0 +1,91 @@ +//! Development foundation. No Bluetooth service, input device or permission +//! prompt is opened until the Linux adapters have been implemented and tested. +use tauri::AppHandle; + +use crate::state::{emit_state, AccessibilityState, BluetoothState, SharedModel}; + +const UNAVAILABLE: &str = "Bluetooth control is not implemented in this Linux development build."; + +fn reset_session(shared: &SharedModel) { + let mut data = shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + data.engine.reset_transport_session(); + data.state.pending_pairings.clear(); + data.state.connected_device_name = None; + data.state.bluetooth = BluetoothState::Unsupported; + data.state.accessibility = AccessibilityState::Unavailable; +} + +pub fn install(app: AppHandle, shared: SharedModel) -> Result<(), String> { + reset_session(&shared); + emit_state(&app, &shared); + Ok(()) +} + +pub fn shutdown(_app: &AppHandle, shared: &SharedModel) { + reset_session(shared); +} + +pub fn check_accessibility( + app: &AppHandle, + shared: &SharedModel, + _prompt: bool, +) -> Result<(), String> { + reset_session(shared); + emit_state(app, shared); + Ok(()) +} + +pub fn approve_pairing( + _app: &AppHandle, + _shared: &SharedModel, + _request_id: &str, +) -> Result<(), String> { + Err(UNAVAILABLE.into()) +} + +pub fn reject_pairing( + _app: &AppHandle, + _shared: &SharedModel, + _request_id: &str, +) -> Result<(), String> { + Err(UNAVAILABLE.into()) +} + +pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), String> { + reset_session(shared); + emit_state(app, shared); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::AppModel; + use crate::storage::AppStorage; + + #[test] + fn unavailable_runtime_clears_session_but_preserves_pairing_credentials() { + let root = std::env::temp_dir().join(format!("switchify-linux-{}", uuid::Uuid::new_v4())); + let model = AppModel::with_storage_for_test(AppStorage::at(root.join("state.json"))); + { + let mut data = model.shared.lock().unwrap(); + data.engine + .set_paired_token("test-device".into(), "test-token".into()); + data.state.bluetooth = BluetoothState::Connected; + data.state.accessibility = AccessibilityState::Granted; + data.state.connected_device_name = Some("Test device".into()); + } + reset_session(&model.shared); + reset_session(&model.shared); + let data = model.shared.lock().unwrap(); + assert_eq!(data.state.bluetooth, BluetoothState::Unsupported); + assert_eq!(data.state.accessibility, AccessibilityState::Unavailable); + assert!(data.state.connected_device_name.is_none()); + assert!(data.state.pending_pairings.is_empty()); + assert_eq!(data.engine.token_for("test-device"), Some("test-token")); + drop(data); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/src-tauri/src/modifier_overlay_unavailable.rs b/src-tauri/src/modifier_overlay_unavailable.rs new file mode 100644 index 00000000..d68ba8ce --- /dev/null +++ b/src-tauri/src/modifier_overlay_unavailable.rs @@ -0,0 +1,37 @@ +//! Linux deliberately creates no feedback window until overlay support is qualified. +use serde::Serialize; + +use crate::input::ModifierKey; +use crate::state::SharedModel; + +// Keep the input seam usable by the shared fake-injector tests on Linux. +#[allow(dead_code)] +pub trait ModifierKeyOverlayNotifier: Send + Sync { + fn set_active_modifiers(&self, active_modifiers: &[ModifierKey]); + fn end_control_session(&self); +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModifierOverlaySnapshot { + pub revision: u64, + pub labels: Vec, +} + +pub struct ModifierOverlay; + +impl ModifierOverlay { + pub fn install(_app: tauri::AppHandle, _shared: SharedModel) -> Result { + Ok(Self) + } + + pub fn ready(&self, _window_label: &str) -> Result { + Err("Modifier feedback is unavailable in this Linux development build.".into()) + } + + pub fn present(&self, _window_label: &str, _revision: u64) -> Result<(), String> { + Err("Modifier feedback is unavailable in this Linux development build.".into()) + } + + pub fn end_session(&self) {} +} diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index f11889bf..446cc7c9 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -15,6 +15,17 @@ mod platform; #[path = "overlay_windows.rs"] mod platform; +#[cfg(target_os = "linux")] +mod platform { + use super::*; + + pub(super) fn spawn(_app: AppHandle, _shared: SharedModel, receiver: Receiver) { + // A closed receiver makes all feedback sends cheap no-ops. No thread or + // window is created while Linux cursor feedback is unavailable. + drop(receiver); + } +} + const FOLLOW_INTERVAL: Duration = Duration::from_millis(75); const DEFAULT_DURATION: Duration = Duration::from_millis(900); const LANDING_DURATION: Duration = Duration::from_millis(300); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 4ffdbf45..f9f5a0f6 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -960,7 +960,7 @@ mod tests { let root = std::env::temp_dir().join(format!("switchify-last-connection-{}", Uuid::new_v4())); let state_path = root.join("state.json"); - let model = AppModel::with_storage_for_test(AppStorage::at(state_path.clone())); + let model = AppModel::with_storage_for_test(AppStorage::at_with_memory_tokens(state_path)); let device_id = format!("remote-{}", Uuid::new_v4()); { let mut data = model.shared.lock().unwrap(); @@ -999,7 +999,9 @@ mod tests { .storage .save_pairing_token(&device_id, "test-token") .unwrap(); - let restored = AppModel::with_storage_for_test(AppStorage::at(state_path)); + // Recreate the model from persisted state while retaining the injected + // credential store; no native keychain or D-Bus service is used in tests. + let restored = AppModel::with_storage_for_test(model.storage); let restored_device = &restored.snapshot().paired_devices[0]; assert_eq!(restored_device.device_name, "Kitchen Remote"); assert_eq!(restored_device.last_seen_at, Some(99)); diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index fbefa8e9..d409ac53 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -286,6 +286,16 @@ impl AppStorage { } } + #[cfg(test)] + pub fn at_with_memory_tokens(path: PathBuf) -> Self { + Self { + path, + // Model persistence tests do not require a native credential store. + // Platform storage tests still use `at` to exercise their adapters. + pairing_tokens: Box::::default(), + } + } + pub fn load(&self) -> Result { if !self.path.exists() { return Ok(PersistedState::default()); @@ -387,7 +397,7 @@ mod tests { use std::sync::Mutex; #[derive(Debug, Default)] - struct MemoryPairingTokenStore { + pub(super) struct MemoryPairingTokenStore { tokens: Mutex>, } diff --git a/src/App.test.tsx b/src/App.test.tsx index 67243502..915bc1ea 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -8,6 +8,8 @@ const defaultBrowserSettings = structuredClone(browserState.settings); describe("Switchify PC shell", () => { beforeEach(() => { + browserState.capabilities = { platform: "windows", grid3: false, uiAccess: false, displayNavigation: false, cursorOverlay: true }; + browserState.accessibility = "required"; browserState.settings = structuredClone(defaultBrowserSettings); browserState.bluetooth = "initializing"; browserState.pendingPairings = []; @@ -24,6 +26,24 @@ describe("Switchify PC shell", () => { vi.restoreAllMocks(); }); + it("explains the unavailable Linux build without blaming hardware or offering macOS permissions", async () => { + browserState.capabilities.platform = "linux"; + browserState.capabilities.cursorOverlay = false; + browserState.bluetooth = "unsupported"; + browserState.accessibility = "unavailable"; + render(); + expect(await screen.findByText("Bluetooth pairing is not yet available in this Linux development build.")).toBeInTheDocument(); + expect(screen.queryByText("This computer does not support the required Bluetooth features.")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Open Accessibility Settings" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Set up" })); + const setup = await screen.findByRole("dialog"); + expect(within(setup).getByText("Input controls are not yet available in this Linux development build.")).toBeInTheDocument(); + expect(within(setup).getByRole("button", { name: "Next" })).toBeDisabled(); + fireEvent.click(within(setup).getByRole("button", { name: "Skip for now" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(browserState.setup.completed).toBe(false); + }); + it("uses the Switchify application icon in the sidebar", async () => { const { container } = render(); await screen.findByRole("heading", { name: "Switchify PC" }); diff --git a/src/App.tsx b/src/App.tsx index 1646b99a..c9bef87b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { applyLocalSettings, changedSettingKeys } from "./settings/diff"; import { SettingsView } from "./settings/SettingsView"; import { updateDescription, updateInFlight, updateLiveness, updateProgress, updateStanding, type UpdateAction } from "./settings/UpdatesSection"; import { TabPanel, Tabs } from "./Tabs"; +import { inputAccessAction, linuxBluetoothDescription, linuxInputDescription, linuxInputUnavailable } from "./platform"; type View = "home" | "devices" | "profiles" | "settings" | "support"; @@ -43,6 +44,7 @@ function StatusIcon({ ok, children }: { ok: boolean; children: ReactNode }) { } function AccessibilityCopy({ state, detailed = false }: { state: AppState; detailed?: boolean }) { + if (linuxInputUnavailable(state)) return

{linuxInputDescription}

; if (state.accessibility === "granted") return

Ready

; if (state.accessibility === "unavailable") return

Unavailable on this system

; if (!detailed || state.capabilities.platform !== "macos") return

Permission required

; @@ -58,12 +60,12 @@ function HomeView({ state, onDisconnect, onAccessibility, onSetup }: { state: Ap

Switchify PC

Android control for this computer

{bluetoothOk ? : } -

{bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}

+

{bluetoothLabels[state.bluetooth]}

{state.capabilities.platform === "linux" && state.bluetooth === "unsupported" ? linuxBluetoothDescription : state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}

{state.bluetooth === "connected" ? : }

Bluetooth

{bluetoothLabels[state.bluetooth]}

-

Input access

{state.accessibility === "required" && }
+

Input access

{state.accessibility === "required" && }

Secure pairing

{state.pairedDevices.length === 0 ? "No saved devices" : `${state.pairedDevices.length} saved device${state.pairedDevices.length === 1 ? "" : "s"}`}

; @@ -307,12 +309,12 @@ function SupportView({ state, busy, perform, openSetup, openUpdates }: { state: {tab === "setup" ? <>
{bluetoothReady ? : }

Bluetooth

{bluetoothLabels[state.bluetooth]}

-

Input access

{state.accessibility === "required" && }
+

Input access

{state.accessibility === "required" && }
0}>

Android device

{state.pairedDevices.length > 0 ? `${state.pairedDevices.length} paired` : "Open Switchify on Android and select this computer"}

Connection

{state.connectedDeviceName ?? "Waiting for a paired device"}

:

Bluetooth connection

{bluetoothLabels[state.bluetooth]}

-

Input access

{state.accessibility === "required" ? : }
+

Input access

{state.accessibility === "required" ? : }

Application update

Switchify PC {state.version}

Diagnostics

Export sanitized health, capability, and recent event data

Recent Bluetooth changes

{state.diagnostics.recentBluetooth.length > 0 ? state.diagnostics.recentBluetooth.map((event) => event.status).join(" → ") : "No Bluetooth changes recorded yet"}

@@ -338,7 +340,7 @@ function SetupGuide({ state, busy, error, skip, finish, accessibility, approve, const dialogRef = useRef(null); const titles = ["Bluetooth and input access", "Get Switchify for Android", "Pair securely", "Start with system", "Anonymous diagnostics"]; const bluetoothReady = state.bluetooth === "advertising" || state.bluetooth === "connected"; - const canContinue = step === 2 ? state.pairedDevices.length > 0 : step === 3 ? startupChoice !== null : step === 4 ? diagnosticsChoice !== null : true; + const canContinue = !linuxInputUnavailable(state) && (step === 2 ? state.pairedDevices.length > 0 : step === 3 ? startupChoice !== null : step === 4 ? diagnosticsChoice !== null : true); useEffect(() => { dialogRef.current?.focus(); }, [step]); @@ -357,8 +359,8 @@ function SetupGuide({ state, busy, error, skip, finish, accessibility, approve, {error &&
{error}
}
{step === 0 &&
-

Bluetooth

{bluetoothLabels[state.bluetooth]}

-

Input access

{state.accessibility === "required" && }
+

Bluetooth

{state.capabilities.platform === "linux" && state.bluetooth === "unsupported" ? linuxBluetoothDescription : bluetoothLabels[state.bluetooth]}

+

Input access

{state.accessibility === "required" && }
} {step === 1 &&

Install the Android app

Install Switchify from Google Play, then open it near this computer.

Open Google Play
QR code for Switchify on Google Play
} {step === 2 &&

{state.pairedDevices.length > 0 ? "Android device paired" : "Waiting for an Android device"}

{state.pairedDevices.length > 0 ? "Secure pairing is complete. You can continue setup." : "In Switchify for Android, select this computer and confirm the matching code."}

diff --git a/src/api.ts b/src/api.ts index 387a48d5..8c8e5cde 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,13 +1,16 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { AppSettings, AppState, SwitchProfile } from "./types"; +import { browserPlatform } from "./platform"; export type ProfileExitAction = "hide" | "quit"; export type NavigationTarget = "home" | "settings" | "profiles"; +const samplePlatform = browserPlatform(navigator.userAgent); + export const browserState: AppState = { - bluetooth: "initializing", - accessibility: "required", + bluetooth: samplePlatform === "linux" ? "unsupported" : "initializing", + accessibility: samplePlatform === "linux" ? "unavailable" : "required", desktopId: "browser", pendingPairings: [], pairedDevices: [], @@ -24,8 +27,8 @@ export const browserState: AppState = { cursorCrosshairs: false, shareDiagnostics: false, }, capabilities: { - platform: navigator.userAgent.includes("Mac") ? "macos" : "windows", - grid3: false, uiAccess: false, displayNavigation: false, cursorOverlay: true, + platform: samplePlatform, + grid3: false, uiAccess: false, displayNavigation: false, cursorOverlay: samplePlatform !== "linux", }, version: "1.0.0-rc.6", diagnostics: { recentBluetooth: [], lastDisconnect: null, recentErrors: [] }, diff --git a/src/platform.test.ts b/src/platform.test.ts new file mode 100644 index 00000000..95e372a6 --- /dev/null +++ b/src/platform.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { browserPlatform } from "./platform"; + +describe("browser sample platform", () => { + it.each([ + ["Mozilla/5.0 (X11; Linux x86_64)", "linux"], + ["Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "windows"], + ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "macos"], + ["Mozilla/5.0 (Linux; Android 16)", "windows"], + ])("selects the desktop sample for %s", (agent, expected) => { + expect(browserPlatform(agent)).toBe(expected); + }); +}); diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 00000000..948f2e35 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,19 @@ +import type { AppState, PlatformCapabilities } from "./types"; + +// This only selects browser sample data. Native state comes from Rust. +export function browserPlatform(userAgent: string): PlatformCapabilities["platform"] { + if (/Mac/i.test(userAgent)) return "macos"; + if (/Linux/i.test(userAgent) && !/Android/i.test(userAgent)) return "linux"; + return "windows"; +} + +export function linuxInputUnavailable(state: AppState): boolean { + return state.capabilities.platform === "linux" && state.accessibility === "unavailable"; +} + +export const linuxInputDescription = "Input controls are not yet available in this Linux development build."; +export const linuxBluetoothDescription = "Bluetooth pairing is not yet available in this Linux development build."; + +export function inputAccessAction(state: AppState): string { + return state.capabilities.platform === "linux" ? "Check input access" : "Open Accessibility Settings"; +} diff --git a/src/settings/SettingsView.tsx b/src/settings/SettingsView.tsx index a2ff676e..ed22df61 100644 --- a/src/settings/SettingsView.tsx +++ b/src/settings/SettingsView.tsx @@ -7,6 +7,8 @@ import { PointerSection } from "./PointerSection"; import { CursorSection } from "./CursorSection"; import { PrivacySection } from "./PrivacySection"; import { UpdatesSection, type UpdateAction } from "./UpdatesSection"; +import { linuxInputDescription, linuxInputUnavailable } from "../platform"; +import { SettingGroup } from "./controls"; type SettingsTabId = "general" | "pointer" | "cursor" | "privacy" | "updates"; @@ -54,7 +56,9 @@ export function SettingsView({ state, settings, onChange, chooseTelemetry, updat {active === "general" && } - {active === "pointer" && } + {active === "pointer" && (linuxInputUnavailable(state) + ?

Settings will become available when Linux input support is enabled.

+ : )} {active === "cursor" && } {active === "privacy" && } {active === "updates" && } diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index c5303d01..09eae9c1 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -5,7 +5,7 @@ import { api, browserState } from "../api"; import type { AppSettings } from "../types"; const defaultBrowserSettings = structuredClone(browserState.settings); -const defaultCapabilities = structuredClone(browserState.capabilities); +const defaultCapabilities = { platform: "windows" as const, grid3: false, uiAccess: false, displayNavigation: false, cursorOverlay: true }; function stateWithSettings(settings: AppSettings) { return { ...structuredClone(browserState), settings: structuredClone(settings) }; @@ -15,6 +15,7 @@ const selectTab = (name: string) => fireEvent.click(screen.getByRole("tab", { na describe("Switchify PC settings", () => { beforeEach(() => { + browserState.accessibility = "required"; browserState.settings = structuredClone(defaultBrowserSettings); browserState.capabilities = structuredClone(defaultCapabilities); browserState.bluetooth = "initializing"; @@ -32,6 +33,22 @@ describe("Switchify PC settings", () => { vi.restoreAllMocks(); }); + it("does not offer ineffective input settings in the Linux development build", async () => { + browserState.capabilities = { ...defaultCapabilities, platform: "linux", cursorOverlay: false }; + browserState.bluetooth = "unsupported"; + browserState.accessibility = "unavailable"; + const save = vi.spyOn(api, "saveSettings"); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + selectTab("Controls"); + expect(screen.getByText("Settings will become available when Linux input support is enabled.")).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: "Dwell to click" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "50% pointer speed" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Cursor appearance" })).not.toBeInTheDocument(); + expect(save).not.toHaveBeenCalled(); + }); + it("shows update progress and exposes cancellation in Settings", async () => { browserState.updater = { status: "downloading", version: "1.0.0-beta.2", downloadedBytes: 50, totalBytes: 200, error: null, retryAction: null }; const cancel = vi.spyOn(api, "cancelUpdateDownload").mockResolvedValue(structuredClone(browserState));