diff --git a/gpui/installer/Cargo.lock b/gpui/installer/Cargo.lock index 1471012..067b1b7 100644 --- a/gpui/installer/Cargo.lock +++ b/gpui/installer/Cargo.lock @@ -4388,6 +4388,8 @@ dependencies = [ "dirs 6.0.0", "gpui-kit", "mslnk", + "serde", + "serde_json", "winreg 0.52.0", "winres", ] diff --git a/gpui/installer/Cargo.toml b/gpui/installer/Cargo.toml index e6c38a6..5882b9f 100644 --- a/gpui/installer/Cargo.toml +++ b/gpui/installer/Cargo.toml @@ -20,6 +20,8 @@ path = "src/main.rs" gpui-kit = "0.7" anyhow = "1.0" dirs = "6" +serde = { version = "1", features = ["derive"] } +serde_json = "1" [target.'cfg(windows)'.dependencies] winreg = "0.52" diff --git a/gpui/installer/src/devices.rs b/gpui/installer/src/devices.rs new file mode 100644 index 0000000..3c14bd6 --- /dev/null +++ b/gpui/installer/src/devices.rs @@ -0,0 +1,70 @@ +//! Finds the supported devices that are plugged in, so the installer can say +//! what OpenMouse will work with before anything is installed. +//! +//! Detection lives in the app itself, so rather than duplicate it the +//! installer writes the embedded app to a temporary folder and runs it with +//! `--list-supported ` (see `gpui/src/supported_devices.rs`), which lists +//! the devices exactly as the Overview page would and exits. + +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +use crate::payload; + +/// How long to wait for the app before giving up on the list. +const TIMEOUT: Duration = Duration::from_secs(25); +/// Mirrors `gpui/src/supported_devices.rs`'s `ARG`. +const LIST_ARG: &str = "--list-supported"; + +#[derive(Debug, Clone, Deserialize)] +pub struct Device { + pub name: String, + pub brand: String, +} + +/// Blocking; run it off the UI thread. +pub fn scan() -> Result, String> { + let dir = std::env::temp_dir().join(format!("openmouse-scan-{}", std::process::id())); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let result = run(&dir); + let _ = std::fs::remove_dir_all(&dir); + result +} + +fn run(dir: &std::path::Path) -> Result, String> { + let exe = dir.join(payload::APP_EXE_NAME); + let out = dir.join("devices.json"); + std::fs::write(&exe, payload::APP_EXE).map_err(|e| e.to_string())?; + + let mut command = std::process::Command::new(&exe); + command + .arg(LIST_ARG) + .arg(&out) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = command.spawn().map_err(|e| e.to_string())?; + + let started = Instant::now(); + loop { + match child.try_wait().map_err(|e| e.to_string())? { + Some(_) => break, + None if started.elapsed() > TIMEOUT => { + let _ = child.kill(); + let _ = child.wait(); + return Err("timed out".to_string()); + } + None => std::thread::sleep(Duration::from_millis(100)), + } + } + + let text = std::fs::read_to_string(&out).map_err(|e| e.to_string())?; + serde_json::from_str(&text).map_err(|e| e.to_string()) +} diff --git a/gpui/installer/src/lib.rs b/gpui/installer/src/lib.rs index 5aa949f..478a242 100644 --- a/gpui/installer/src/lib.rs +++ b/gpui/installer/src/lib.rs @@ -1,4 +1,5 @@ pub mod colors; +pub mod devices; pub mod paths; pub mod payload; pub mod registry; diff --git a/gpui/installer/src/main.rs b/gpui/installer/src/main.rs index 9f0b177..88776c0 100644 --- a/gpui/installer/src/main.rs +++ b/gpui/installer/src/main.rs @@ -47,16 +47,94 @@ enum Screen { Done, } +/// What the device scan on the welcome screen has found so far. +enum DeviceScan { + Scanning, + Found(Vec), + /// The scan could not run; the card says so rather than claiming none. + Unavailable, +} + struct InstallerView { uninstall_mode: bool, screen: Screen, error: Option, desktop_shortcut: bool, + devices: DeviceScan, } impl InstallerView { fn new(uninstall_mode: bool) -> Self { - Self { uninstall_mode, screen: Screen::Start, error: None, desktop_shortcut: true } + Self { uninstall_mode, screen: Screen::Start, error: None, desktop_shortcut: true, devices: DeviceScan::Scanning } + } + + /// Looks for plugged-in supported devices in the background and shows them + /// on the welcome screen. Install does not wait on it. + fn scan_devices(&mut self, cx: &mut Context) { + cx.spawn(async move |view, cx| { + let result = cx.background_executor().spawn(async { openmouse_installer::devices::scan() }).await; + let _ = view.update(cx, |view, cx| { + view.devices = match result { + Ok(found) => DeviceScan::Found(found), + Err(_) => DeviceScan::Unavailable, + }; + cx.notify(); + }); + }) + .detach(); + } + + /// The "your devices" card: the supported devices that are plugged in now. + fn devices_card(&self, cx: &mut Context) -> impl IntoElement { + let fg = cx.theme().foreground; + let muted = cx.theme().muted_foreground; + let accent = cx.theme().primary; + let border = cx.theme().border; + const SHOWN: usize = 4; + let heading = div().text_xs().font_weight(FontWeight::SEMIBOLD).text_color(muted).child("SUPPORTED DEVICES DETECTED"); + let body: AnyElement = match &self.devices { + DeviceScan::Scanning => h_flex() + .gap_2() + .items_center() + .child(Spinner::new().with_size(Size::Small).color(accent).into_any_element()) + .child(div().text_sm().text_color(muted).child("Looking for your mice…")) + .into_any_element(), + DeviceScan::Unavailable => { + div().text_sm().text_color(muted).child("Couldn’t check for devices. OpenMouse will find them when it starts.").into_any_element() + } + DeviceScan::Found(found) if found.is_empty() => div() + .text_sm() + .text_color(muted) + .child("None plugged in right now. OpenMouse picks a device up as soon as you plug one in.") + .into_any_element(), + DeviceScan::Found(found) => div() + .flex() + .flex_col() + .gap_1p5() + .children(found.iter().take(SHOWN).map(|device| { + let name = if device.name.trim().is_empty() { device.brand.clone() } else { device.name.clone() }; + h_flex() + .gap_2() + .items_center() + .child(Icon::new(IconName::Check).size(px(14.)).text_color(accent)) + .child(div().text_sm().text_color(fg).child(name)) + .child(div().text_xs().text_color(muted).child(device.brand.clone())) + })) + .when(found.len() > SHOWN, |col| { + col.child(div().text_xs().text_color(muted).child(format!("and {} more", found.len() - SHOWN))) + }) + .into_any_element(), + }; + div() + .flex() + .flex_col() + .gap_2() + .p_3() + .rounded_lg() + .border_1() + .border_color(border) + .child(heading) + .child(body) } fn step_index(&self) -> usize { @@ -199,6 +277,7 @@ impl Render for InstallerView { .text_color(muted) .child(format!("Installs to {}", paths::install_dir().display())), ) + .child(self.devices_card(cx)) .child( Checkbox::new("desktop-shortcut") .checked(self.desktop_shortcut) @@ -313,7 +392,7 @@ fn main() { init(cx); // No explicit `window_bounds` otherwise defaults to filling the // display — this is a small dialog, not a resizable app window. - let window_size = size(px(480.), px(340.)); + let window_size = size(px(480.), px(440.)); let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered(None, window_size, cx))), window_min_size: Some(window_size), @@ -321,7 +400,7 @@ fn main() { window_background: WindowBackgroundAppearance::Transparent, ..TitleBar::window_options() }; - let (handle, _view) = open_window(options, cx, |_window, cx| cx.new(|_| InstallerView::new(uninstall))) + let (handle, view) = open_window(options, cx, |_window, cx| cx.new(|_| InstallerView::new(uninstall))) .expect("failed to open installer window"); // Applied after the window exists, not inside the build closure: // `Theme::change`/`Theme::update` mid-construction poisons first @@ -330,6 +409,9 @@ fn main() { Theme::change(ThemeMode::Dark, Some(window), cx); openmouse_installer::colors::paint(cx); }); + if !uninstall { + view.update(cx, |view, cx| view.scan_devices(cx)); + } cx.activate(true); }); } diff --git a/gpui/src/bin/main.rs b/gpui/src/bin/main.rs index 49520c6..8500fb7 100644 --- a/gpui/src/bin/main.rs +++ b/gpui/src/bin/main.rs @@ -6,6 +6,12 @@ use gpui_kit::*; use openmouse_gpui::app::AppView; fn main() { + // Headless mode for the installer: list the plugged-in supported devices + // and exit before any window or background work starts. + if let Some(path) = openmouse_gpui::supported_devices::requested_path() { + openmouse_gpui::supported_devices::write(&path); + return; + } application().with_assets(assets::AllAssets).run(|cx| { init(cx); diff --git a/gpui/src/lib.rs b/gpui/src/lib.rs index 8b347d6..b6e0bf5 100644 --- a/gpui/src/lib.rs +++ b/gpui/src/lib.rs @@ -12,6 +12,7 @@ pub mod presence_text; pub mod proc; pub mod profiles; pub mod settings; +pub mod supported_devices; pub mod theme; pub mod update_check; #[cfg(windows)] diff --git a/gpui/src/supported_devices.rs b/gpui/src/supported_devices.rs new file mode 100644 index 0000000..c9b8db9 --- /dev/null +++ b/gpui/src/supported_devices.rs @@ -0,0 +1,45 @@ +//! `openmouse-gpui --list-supported `: writes the supported devices that +//! are plugged in right now to `` as JSON and exits, without opening a +//! window. The installer runs this to show what it will work with; it is the +//! same detection the Overview page uses, so the two always agree. +//! +//! A file rather than stdout, because the release build has no console. + +use serde::Serialize; + +pub const ARG: &str = "--list-supported"; + +#[derive(Serialize)] +struct Entry { + name: String, + brand: String, +} + +/// The path after [`ARG`], when the app was started that way. +pub fn requested_path() -> Option { + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if arg == ARG { + return args.next().map(Into::into); + } + } + None +} + +/// Lists the devices and writes them to `path`. Writes `[]` if the device +/// runtime cannot be reached, so a caller always finds a readable file. +pub fn write(path: &std::path::Path) { + let service = crate::hid::HidService::start(); + let entries: Vec = match futures::executor::block_on(service.list_candidates()) { + Ok(Ok(candidates)) => candidates + .into_iter() + .map(|candidate| Entry { + name: candidate.info.product_string, + brand: candidate.brands.into_iter().next().unwrap_or_default(), + }) + .collect(), + _ => Vec::new(), + }; + let text = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string()); + let _ = std::fs::write(path, text); +}