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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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'
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ name: CodeQL

on:
pull_request:
branches:
- main
push:
branches:
- main
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
138 changes: 138 additions & 0 deletions docs/linux-support-plan.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src-tauri/src/dwell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -228,6 +229,12 @@ fn schedule_tick(app: AppHandle, generation: u64) {
});
}

#[cfg(target_os = "linux")]
fn sample_pointer(_app: &AppHandle) -> Result<PointerSample, String> {
Err("Dwell pointer tracking is unavailable on Linux.".into())
}

#[cfg(not(target_os = "linux"))]
fn sample_pointer(app: &AppHandle) -> Result<PointerSample, String> {
let (position, displays) = display_navigation::displays(app)
.map_err(|_| "The dwell pointer position could not be read.".to_string())?;
Expand Down
66 changes: 59 additions & 7 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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};
Expand Down Expand Up @@ -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")
}
Expand All @@ -115,6 +140,7 @@ fn show_tray_menu_on_left_click() -> bool {
struct PendingNavigation(Mutex<Option<String>>);

impl PendingNavigation {
#[cfg(any(not(target_os = "linux"), test))]
fn set(&self, destination: &str) {
*self
.0
Expand All @@ -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();
Expand Down Expand Up @@ -349,6 +376,7 @@ async fn disconnect_all_on_main_thread(app: AppHandle) -> Result<AppState, Strin
Ok(finish_disconnect(&app, &model, &overlay, &modifier_overlay))
}

#[cfg(not(target_os = "linux"))]
fn disconnect_all_inner(
app: &AppHandle,
model: &AppModel,
Expand Down Expand Up @@ -1126,6 +1154,7 @@ fn export_diagnostics(model: State<'_, AppModel>) -> Result<AppState, String> {
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>)?;
Expand Down Expand Up @@ -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") {
Expand All @@ -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::<AppModel>();
Expand Down Expand Up @@ -1310,7 +1340,7 @@ pub fn run() {
_ => {}
}
}
if has_start_hidden_argument(&std::env::args().collect::<Vec<_>>()) {
if should_start_hidden(&std::env::args().collect::<Vec<_>>()) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
Expand All @@ -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![
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
Expand Down
91 changes: 91 additions & 0 deletions src-tauri/src/linux_runtime.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading