diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01d86a0..c54b135 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,20 @@ jobs: uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt + - name: Install Linux desktop dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev \ + build-essential \ + libssl-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libxdo-dev \ + libasound2-dev \ + libudev-dev \ + pkg-config - name: Install cargo-tarpaulin uses: taiki-e/install-action@v2 with: diff --git a/CLAUDE.md b/CLAUDE.md index 39f5c32..ea89148 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -258,13 +258,28 @@ API로 읽는다. 두 가지를 모르면 시간을 크게 버린다. ## 플랫폼 범위 -MVP는 **macOS + Windows**다. +MVP 데스크톱 범위는 Tauri의 크로스플랫폼 대상인 **macOS + Windows + Linux**다. + +### 앞에 있는 앱 식별 + +Wave 2에서는 플랫폼별 활성 앱 조회를 하나의 경계로 분리해 다음의 **원시 식별자**를 +읽는다. + +- macOS: bundle ID +- Windows: 전체 경로가 아닌 실행 파일 basename +- Linux X11: desktop ID와 `WM_CLASS` 값 + +Wayland에서 안전한 compositor/desktop 어댑터가 없으면 활성 앱 식별을 추측하지 않고 +`None`으로 둔다. 이때 앱별 프리셋은 선택하지 않으며 기본 4칸만 유지한다. + +Windows와 Linux X11에서 읽은 원시 식별자를 프리셋으로 인식·적용하는 일, 그리고 +레지스트리 다운로드·캐싱은 Wave 2 범위가 아니다. 별도 검토 Wave에서 다룬다. iOS/iPadOS는 서드파티 앱이 전역 키 입력 주입이나 시스템 오버레이를 할 수 없어, '앱이 다른 앱을 제어'하는 구조 자체가 OS 정책상 불가능하다. 태블릿은 하드웨어 스위치를 BT HID로 붙여 OS 내장 '스위치 제어'와 연동하고, 우리 앱은 설정·프로필 제공에 한정한다. **이 제약을 우회하는 방법을 찾으려 시도하지 않는다.** -Android(AccessibilityService + 오버레이)와 Linux는 MVP 이후 과제다. +Android(AccessibilityService + 오버레이)는 MVP 이후 과제다. ## 하지 말 것 diff --git a/Cargo.lock b/Cargo.lock index da867d0..104f33f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -891,7 +891,7 @@ dependencies = [ "objc2-app-kit", "objc2-foundation", "windows", - "x11rb", + "x11rb 0.13.2", "xkbcommon", "xkeysym", ] @@ -1343,7 +1343,7 @@ dependencies = [ "serde", "thiserror 2.0.18", "windows-sys 0.59.0", - "x11rb", + "x11rb 0.13.2", "xkeysym", ] @@ -1428,6 +1428,8 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-global-shortcut", + "windows-sys 0.61.2", + "x11rb 0.14.0", ] [[package]] @@ -5134,7 +5136,18 @@ checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", "rustix", - "x11rb-protocol", + "x11rb-protocol 0.13.2", +] + +[[package]] +name = "x11rb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8885a854a8bfdf87a301e53e41b17c5f8f33639903131338b997b1eb614f44" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol 0.14.0", ] [[package]] @@ -5143,6 +5156,12 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x11rb-protocol" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf4d1bc32aa46eec18caa634ec3cf4c05bfa151f12b93b510b15190f69a1ca8" + [[package]] name = "xkbcommon" version = "0.9.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 7ae20f3..5131418 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -34,5 +34,11 @@ accessibility-sys = "0.2.0" core-foundation = "0.10.1" objc2 = "0.6.4" objc2-app-kit = "0.3.2" + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } + +[target.'cfg(target_os = "linux")'.dependencies] +x11rb = "0.14.0" # floating 창이 대상 앱의 포커스를 뺏지 않도록 NSPanel(nonactivating)로 승격할 때 사용한다. # cargo add objc2 objc2-app-kit diff --git a/apps/desktop/src-tauri/src/focused_application/linux.rs b/apps/desktop/src-tauri/src/focused_application/linux.rs new file mode 100644 index 0000000..248eee1 --- /dev/null +++ b/apps/desktop/src-tauri/src/focused_application/linux.rs @@ -0,0 +1,382 @@ +//! Linux focused-application discovery through X11, with a safe Wayland fallback. + +#[cfg(any(target_os = "linux", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionBackend { + WaylandUnsupported, + X11, + Unavailable, +} + +#[cfg(any(target_os = "linux", test))] +fn session_backend( + session_type: Option<&str>, + wayland_display: Option<&str>, + display: Option<&str>, +) -> SessionBackend { + if session_type.is_some_and(|value| value.eq_ignore_ascii_case("wayland")) + || wayland_display.is_some_and(|value| !value.is_empty()) + { + SessionBackend::WaylandUnsupported + } else if display.is_some_and(|value| !value.is_empty()) { + SessionBackend::X11 + } else { + SessionBackend::Unavailable + } +} + +#[cfg(any(target_os = "linux", test))] +fn parse_wm_class(value: &[u8]) -> Vec { + let Some(value) = value.strip_suffix(b"\0") else { + return Vec::new(); + }; + let mut fields = value.split(|byte| *byte == b'\0'); + let Some(instance) = fields.next().filter(|field| !field.is_empty()) else { + return Vec::new(); + }; + let Some(class) = fields.next().filter(|field| !field.is_empty()) else { + return Vec::new(); + }; + if fields.next().is_some() { + return Vec::new(); + } + + let (Ok(instance), Ok(class)) = ( + String::from_utf8(instance.to_vec()), + String::from_utf8(class.to_vec()), + ) else { + return Vec::new(); + }; + vec![instance, class] +} + +#[cfg(any(target_os = "linux", test))] +fn parse_text(value: &[u8]) -> Option { + let value = value + .iter() + .rposition(|byte| *byte != b'\0') + .map_or(&[][..], |last_non_nul| &value[..=last_non_nul]); + (!value.is_empty()) + .then(|| String::from_utf8(value.to_vec()).ok()) + .flatten() +} + +#[cfg(any(target_os = "linux", test))] +fn parse_desktop_id(value: &[u8]) -> Option { + let desktop_id = parse_text(value)?; + if desktop_id.ends_with(".desktop") && desktop_id.contains('/') { + desktop_id + .rsplit('/') + .next() + .filter(|filename| !filename.is_empty()) + .map(ToOwned::to_owned) + } else { + Some(desktop_id) + } +} + +#[cfg(any(target_os = "linux", test))] +fn optional_atom(atom: u32) -> Option { + (atom != 0).then_some(atom) +} + +#[cfg(target_os = "linux")] +use std::env; + +#[cfg(target_os = "linux")] +use x11rb::{ + connection::Connection, + protocol::xproto::{Atom, AtomEnum, ConnectionExt, Window}, + rust_connection::RustConnection, +}; + +#[cfg(target_os = "linux")] +use super::{FocusedApplication, FocusedApplicationSource}; + +#[cfg(target_os = "linux")] +const MAX_PROPERTY_LONGS: u32 = 256; + +#[cfg(target_os = "linux")] +pub struct LinuxSource { + backend: Backend, +} + +#[cfg(target_os = "linux")] +enum Backend { + X11(Box), + WaylandUnsupported, + Unavailable, +} + +#[cfg(target_os = "linux")] +struct X11Backend { + connection: RustConnection, + root: Window, + atoms: Atoms, +} + +#[cfg(target_os = "linux")] +struct Atoms { + active_window: Option, + wm_pid: Option, + gtk_application_id: Option, + kde_desktop_file: Option, + bamf_desktop_file: Option, + utf8_string: Option, +} + +#[cfg(target_os = "linux")] +impl Atoms { + fn resolve(connection: &RustConnection) -> Self { + Self { + active_window: intern_atom(connection, b"_NET_ACTIVE_WINDOW"), + wm_pid: intern_atom(connection, b"_NET_WM_PID"), + gtk_application_id: intern_atom(connection, b"_GTK_APPLICATION_ID"), + kde_desktop_file: intern_atom(connection, b"_KDE_NET_WM_DESKTOP_FILE"), + bamf_desktop_file: intern_atom(connection, b"_BAMF_DESKTOP_FILE"), + utf8_string: intern_atom(connection, b"UTF8_STRING"), + } + } + + fn refresh(&mut self, connection: &RustConnection) { + self.active_window = self + .active_window + .or_else(|| intern_atom(connection, b"_NET_ACTIVE_WINDOW")); + self.wm_pid = self + .wm_pid + .or_else(|| intern_atom(connection, b"_NET_WM_PID")); + self.gtk_application_id = self + .gtk_application_id + .or_else(|| intern_atom(connection, b"_GTK_APPLICATION_ID")); + self.kde_desktop_file = self + .kde_desktop_file + .or_else(|| intern_atom(connection, b"_KDE_NET_WM_DESKTOP_FILE")); + self.bamf_desktop_file = self + .bamf_desktop_file + .or_else(|| intern_atom(connection, b"_BAMF_DESKTOP_FILE")); + self.utf8_string = self + .utf8_string + .or_else(|| intern_atom(connection, b"UTF8_STRING")); + } +} + +#[cfg(target_os = "linux")] +impl LinuxSource { + pub fn new() -> Self { + let session_type = env::var("XDG_SESSION_TYPE").ok(); + let wayland_display = env::var("WAYLAND_DISPLAY").ok(); + let display = env::var("DISPLAY").ok(); + + let backend = match session_backend( + session_type.as_deref(), + wayland_display.as_deref(), + display.as_deref(), + ) { + SessionBackend::WaylandUnsupported => Backend::WaylandUnsupported, + SessionBackend::X11 => X11Backend::connect(display.as_deref()) + .map(|source| Backend::X11(Box::new(source))) + .unwrap_or(Backend::Unavailable), + SessionBackend::Unavailable => Backend::Unavailable, + }; + + Self { backend } + } +} + +#[cfg(target_os = "linux")] +impl FocusedApplicationSource for LinuxSource { + fn current(&mut self) -> Option { + match &mut self.backend { + Backend::X11(source) => source.current(), + Backend::WaylandUnsupported | Backend::Unavailable => None, + } + } +} + +#[cfg(target_os = "linux")] +impl X11Backend { + fn connect(display: Option<&str>) -> Option { + let (connection, screen) = RustConnection::connect(display).ok()?; + let root = connection.setup().roots.get(screen)?.root; + let atoms = Atoms::resolve(&connection); + + Some(Self { + connection, + root, + atoms, + }) + } + + fn current(&mut self) -> Option { + self.atoms.refresh(&self.connection); + let window = self.active_window()?; + let pid = self.window_pid(window); + let wm_classes = self.wm_classes(window); + let desktop_id = self.desktop_id(window); + + if desktop_id.is_none() && wm_classes.is_empty() { + return None; + } + + Some(FocusedApplication::linux(pid, desktop_id, wm_classes)) + } + + fn active_window(&self) -> Option { + let reply = self.property( + self.root, + self.atoms.active_window?, + AtomEnum::WINDOW.into(), + )?; + (reply.type_ == u32::from(AtomEnum::WINDOW) + && reply.format == 32 + && reply.bytes_after == 0) + .then_some(())?; + let mut windows = reply.value32()?; + let window = windows.next()?; + (windows.next().is_none() && window != 0).then_some(window) + } + + fn window_pid(&self, window: Window) -> Option { + let reply = self.property(window, self.atoms.wm_pid?, AtomEnum::CARDINAL.into())?; + (reply.type_ == u32::from(AtomEnum::CARDINAL) + && reply.format == 32 + && reply.bytes_after == 0) + .then_some(())?; + let mut pids = reply.value32()?; + let pid = pids.next()?; + (pids.next().is_none()).then_some(())?; + i32::try_from(pid).ok().filter(|pid| *pid > 0) + } + + fn wm_classes(&self, window: Window) -> Vec { + let Some(reply) = self.property(window, AtomEnum::WM_CLASS.into(), AtomEnum::STRING.into()) + else { + return Vec::new(); + }; + if reply.type_ != u32::from(AtomEnum::STRING) || reply.format != 8 || reply.bytes_after != 0 + { + return Vec::new(); + } + + reply + .value8() + .map(|value| parse_wm_class(&value.collect::>())) + .unwrap_or_default() + } + + fn desktop_id(&self, window: Window) -> Option { + let gtk_desktop_id = self + .text_property(window, self.atoms.gtk_application_id) + .and_then(|value| parse_text(&value)); + let kde_desktop_id = self + .text_property(window, self.atoms.kde_desktop_file) + .and_then(|value| parse_text(&value)); + let bamf_desktop_id = self + .text_property(window, self.atoms.bamf_desktop_file) + .and_then(|value| parse_desktop_id(&value)); + + gtk_desktop_id.or(kde_desktop_id).or(bamf_desktop_id) + } + + fn text_property(&self, window: Window, property: Option) -> Option> { + let property = property?; + let reply = self.property(window, property, AtomEnum::ANY.into())?; + ((self.atoms.utf8_string == Some(reply.type_) + || reply.type_ == u32::from(AtomEnum::STRING)) + && reply.format == 8 + && reply.bytes_after == 0) + .then_some(())?; + reply.value8().map(|value| value.collect()) + } + + fn property( + &self, + window: Window, + property: Atom, + property_type: Atom, + ) -> Option { + self.connection + .get_property( + false, + window, + property, + property_type, + 0, + MAX_PROPERTY_LONGS, + ) + .ok()? + .reply() + .ok() + } +} + +#[cfg(target_os = "linux")] +fn intern_atom(connection: &RustConnection, name: &[u8]) -> Option { + connection + .intern_atom(true, name) + .ok()? + .reply() + .ok() + .and_then(|reply| optional_atom(reply.atom)) +} + +#[cfg(test)] +mod tests { + use super::{SessionBackend, optional_atom, parse_desktop_id, parse_wm_class, session_backend}; + + #[test] + fn parses_both_wm_class_strings() { + assert_eq!( + parse_wm_class(b"spotify\0Spotify\0"), + vec!["spotify".to_string(), "Spotify".to_string()] + ); + } + + #[test] + fn rejects_a_wm_class_without_a_final_nul_terminator() { + assert_eq!(parse_wm_class(b"spotify\0Spotify"), Vec::::new()); + } + + #[test] + fn rejects_a_wm_class_with_more_than_two_fields() { + assert_eq!( + parse_wm_class(b"spotify\0Spotify\0Extra\0"), + Vec::::new() + ); + } + + #[test] + fn rejects_a_wm_class_with_an_empty_field() { + assert_eq!(parse_wm_class(b"spotify\0\0"), Vec::::new()); + } + + #[test] + fn rejects_a_wm_class_with_invalid_utf8() { + assert_eq!(parse_wm_class(b"spotify\0\xff\0"), Vec::::new()); + } + + #[test] + fn wayland_wins_over_an_xwayland_display() { + assert_eq!( + session_backend(Some("wayland"), Some("wayland-0"), Some(":0")), + SessionBackend::WaylandUnsupported + ); + } + + #[test] + fn bamf_path_becomes_a_desktop_id() { + assert_eq!( + parse_desktop_id(b"/usr/share/applications/spotify.desktop\0"), + Some("spotify.desktop".to_string()) + ); + } + + #[test] + fn absent_optional_atom_can_be_refreshed_when_it_later_exists() { + let cached_atom = optional_atom(0); + assert_eq!(cached_atom, None); + + let refreshed_atom = cached_atom.or_else(|| optional_atom(321)); + assert_eq!(refreshed_atom, Some(321)); + } +} diff --git a/apps/desktop/src-tauri/src/focused_application/macos.rs b/apps/desktop/src-tauri/src/focused_application/macos.rs new file mode 100644 index 0000000..6e93932 --- /dev/null +++ b/apps/desktop/src-tauri/src/focused_application/macos.rs @@ -0,0 +1,15 @@ +use objc2_app_kit::NSWorkspace; + +use super::{FocusedApplication, FocusedApplicationSource}; + +pub struct MacOsSource; + +impl FocusedApplicationSource for MacOsSource { + fn current(&mut self) -> Option { + let app = NSWorkspace::sharedWorkspace().frontmostApplication()?; + Some(FocusedApplication::macos( + app.processIdentifier(), + app.bundleIdentifier().map(|id| id.to_string()), + )) + } +} diff --git a/apps/desktop/src-tauri/src/focused_application/mod.rs b/apps/desktop/src-tauri/src/focused_application/mod.rs new file mode 100644 index 0000000..5644121 --- /dev/null +++ b/apps/desktop/src-tauri/src/focused_application/mod.rs @@ -0,0 +1,134 @@ +//! 플랫폼별 활성 앱 조회를 스캔 루프에서 분리한다. + +#[cfg(any(target_os = "linux", test))] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ApplicationIdentity { + MacOs { + bundle_id: Option, + }, + Windows { + executable: String, + }, + Linux { + desktop_id: Option, + wm_classes: Vec, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FocusedApplication { + pid: Option, + identity: ApplicationIdentity, +} + +impl FocusedApplication { + pub fn macos(pid: i32, bundle_id: Option) -> Self { + Self { + pid: Some(pid), + identity: ApplicationIdentity::MacOs { bundle_id }, + } + } + + pub fn windows(pid: i32, executable: String) -> Self { + Self { + pid: Some(pid), + identity: ApplicationIdentity::Windows { executable }, + } + } + + pub fn linux(pid: Option, desktop_id: Option, wm_classes: Vec) -> Self { + Self { + pid, + identity: ApplicationIdentity::Linux { + desktop_id, + wm_classes, + }, + } + } + + pub fn pid(&self) -> Option { + self.pid + } + + pub fn identity(&self) -> &ApplicationIdentity { + &self.identity + } + + pub fn macos_bundle_id(&self) -> Option<&str> { + match &self.identity { + ApplicationIdentity::MacOs { bundle_id } => bundle_id.as_deref(), + ApplicationIdentity::Windows { .. } | ApplicationIdentity::Linux { .. } => None, + } + } +} + +pub trait FocusedApplicationSource { + fn current(&mut self) -> Option; +} + +#[cfg(target_os = "macos")] +pub fn system_source() -> Box { + Box::new(macos::MacOsSource) +} + +#[cfg(target_os = "windows")] +pub fn system_source() -> Box { + Box::new(windows::WindowsSource) +} + +#[cfg(target_os = "linux")] +pub fn system_source() -> Box { + Box::new(linux::LinuxSource::new()) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +pub fn system_source() -> Box { + Box::new(UnsupportedSource) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +struct UnsupportedSource; + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +impl FocusedApplicationSource for UnsupportedSource { + fn current(&mut self) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn macos_identity_exposes_bundle_without_platform_branching() { + let app = FocusedApplication::macos(42, Some("com.apple.Preview".into())); + assert_eq!(app.pid(), Some(42)); + assert_eq!(app.macos_bundle_id(), Some("com.apple.Preview")); + } + + #[test] + fn non_macos_identity_has_no_bundle_id() { + let app = FocusedApplication::windows(7, "Acrobat.exe".into()); + assert_eq!(app.macos_bundle_id(), None); + } + + #[test] + fn linux_identity_retains_a_missing_process_identifier() { + let app = FocusedApplication::linux(None, None, vec!["Spotify".into()]); + assert_eq!(app.pid(), None); + assert_eq!( + app.identity(), + &ApplicationIdentity::Linux { + desktop_id: None, + wm_classes: vec!["Spotify".into()], + } + ); + } +} diff --git a/apps/desktop/src-tauri/src/focused_application/windows.rs b/apps/desktop/src-tauri/src/focused_application/windows.rs new file mode 100644 index 0000000..5909d3c --- /dev/null +++ b/apps/desktop/src-tauri/src/focused_application/windows.rs @@ -0,0 +1,101 @@ +use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE}, + System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, + }, + UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId}, +}; + +use super::{FocusedApplication, FocusedApplicationSource}; + +const PROCESS_IMAGE_PATH_CAPACITY: usize = 32_768; + +pub struct WindowsSource; + +impl FocusedApplicationSource for WindowsSource { + fn current(&mut self) -> Option { + let window = unsafe { GetForegroundWindow() }; + if window.is_null() { + return None; + } + + let mut pid = 0; + unsafe { GetWindowThreadProcessId(window, &mut pid) }; + if pid == 0 { + return None; + } + + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + let process = ProcessHandle::new(handle)?; + + let mut path = [0_u16; PROCESS_IMAGE_PATH_CAPACITY]; + let mut path_length = path.len() as u32; + if unsafe { QueryFullProcessImageNameW(process.0, 0, path.as_mut_ptr(), &mut path_length) } + == 0 + { + return None; + } + + let path = String::from_utf16(&path[..path_length as usize]).ok()?; + let executable = executable_basename(&path)?; + Some(FocusedApplication::windows(focused_pid(pid)?, executable)) + } +} + +struct ProcessHandle(HANDLE); + +impl ProcessHandle { + fn new(handle: HANDLE) -> Option { + (!handle.is_null()).then_some(Self(handle)) + } +} + +impl Drop for ProcessHandle { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } +} + +fn executable_basename(path: &str) -> Option { + path.rsplit(['\\', '/']) + .next() + .filter(|basename| !basename.is_empty()) + .map(ToOwned::to_owned) +} + +fn focused_pid(pid: u32) -> Option { + i32::try_from(pid).ok() +} + +#[cfg(test)] +mod tests { + use super::{executable_basename, focused_pid}; + + #[test] + fn rejects_a_process_identifier_that_does_not_fit_in_i32() { + assert_eq!(focused_pid(u32::MAX), None); + } + + #[test] + fn keeps_only_windows_executable_basename() { + assert_eq!( + executable_basename(r"C:\Program Files\Adobe\Acrobat.exe"), + Some("Acrobat.exe".to_string()) + ); + } + + #[test] + fn rejects_a_path_without_a_file_name() { + assert_eq!(executable_basename(r"C:\Program Files\Adobe\"), None); + } + + #[test] + fn accepts_forward_slashes_as_path_separators() { + assert_eq!( + executable_basename("C:/Program Files/Adobe/Acrobat.exe"), + Some("Acrobat.exe".to_string()) + ); + } +} diff --git a/apps/desktop/src-tauri/src/foreground.rs b/apps/desktop/src-tauri/src/foreground.rs index 6e8c67e..545c812 100644 --- a/apps/desktop/src-tauri/src/foreground.rs +++ b/apps/desktop/src-tauri/src/foreground.rs @@ -11,6 +11,7 @@ use std::time::Duration; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; +use crate::focused_application::{self, FocusedApplication}; use crate::occlusion::{self, MARGIN}; use crate::profile::Profile; use crate::scan::Scanner; @@ -33,30 +34,6 @@ struct CoverPayload { percent: u8, } -/// 지금 앞에 있는 앱. -struct Frontmost { - pid: i32, - bundle_id: Option, -} - -#[cfg(target_os = "macos")] -fn frontmost() -> Option { - use objc2_app_kit::NSWorkspace; - - let app = NSWorkspace::sharedWorkspace().frontmostApplication()?; - Some(Frontmost { - pid: app.processIdentifier(), - bundle_id: app.bundleIdentifier().map(|id| id.to_string()), - }) -} - -/// Windows는 아직 활성 앱을 읽지 않는다. 앱별 칸이 붙지 않고 가림 판정도 -/// 하지 않을 뿐, 앞 4칸은 그대로 동작한다. -#[cfg(not(target_os = "macos"))] -fn frontmost() -> Option { - None -} - fn log_enabled() -> bool { static ENABLED: OnceLock = OnceLock::new(); *ENABLED.get_or_init(|| std::env::var("HANBEON_LOG").is_ok()) @@ -65,23 +42,22 @@ fn log_enabled() -> bool { pub fn watch(app: AppHandle, profile: Arc>, scanner: Scanner) { thread::spawn(move || { let mut covered = false; + let mut source = focused_application::system_source(); loop { thread::sleep(POLL); - let front = frontmost(); + let front = source.current(); // 우리 자신이 앞에 있는 경우(설정 창을 열었을 때)는 둘 다 건너뛴다. // 포커스 요소로 우리 웹뷰가 잡혀 창이 자기 자신을 가린다고 판정되고, // 스캔 대상도 설정 창을 여닫을 때마다 두 번씩 바뀐다. - let ours = front - .as_ref() - .is_some_and(|front| front.pid == std::process::id() as i32); + let ours = is_ours(front.as_ref(), std::process::id() as i32); if !ours { scanner.apply_preset( &app, - front.as_ref().and_then(|front| front.bundle_id.as_deref()), + front.as_ref().and_then(FocusedApplication::macos_bundle_id), ); } @@ -97,7 +73,8 @@ pub fn watch(app: AppHandle, profile: Arc>, scanner: Scanner) { .and_then(occlusion::window_rect); let element = front .as_ref() - .and_then(|front| occlusion::focused_element_rect(front.pid)); + .and_then(FocusedApplication::pid) + .and_then(occlusion::focused_element_rect); let verdict = match (window, element) { (Some(window), Some(element)) => window.overlaps(&element, MARGIN), @@ -123,3 +100,21 @@ pub fn watch(app: AppHandle, profile: Arc>, scanner: Scanner) { } }); } + +fn is_ours(front: Option<&FocusedApplication>, own_pid: i32) -> bool { + front.and_then(FocusedApplication::pid) == Some(own_pid) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::focused_application::FocusedApplication; + + #[test] + fn own_focused_application_is_ignored() { + let front = FocusedApplication::macos(42, Some("devfive.hanbeon".into())); + assert!(is_ours(Some(&front), 42)); + assert!(!is_ours(Some(&front), 7)); + assert!(!is_ours(None, 42)); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index aeca389..3200d89 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod adapt; pub mod arduino; mod audio; mod emit; +pub mod focused_application; mod foreground; mod input; mod journal; diff --git a/bun.lock b/bun.lock index 64e55ae..db4c121 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,7 @@ "workspaces": { "": { "devDependencies": { - "@devup-ui/bun-plugin": "^1.0", + "@devup-ui/bun-plugin": "^1.0.12", "@types/bun": "^1.3", "bun-test-env-dom": "^1.0", "eslint-plugin-devup": "^2.0.19", @@ -86,19 +86,19 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@devup-ui/bun-plugin": ["@devup-ui/bun-plugin@1.0.7", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.4", "@devup-ui/wasm": "^1.0.68" } }, "sha512-Sd3jeZ1swtAL+wf1STTW+Ay60jbZ9emWIJhnXfUK9+A/h+LLqiMpu9fI+m8IXxKM+5ijDF12lcrN+Vm3rT4o/g=="], + "@devup-ui/bun-plugin": ["@devup-ui/bun-plugin@1.0.12", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.8", "@devup-ui/wasm": "^1.0.75" } }, "sha512-V5eJqpy3oFinm0TZfnD7hBQsMBh1vfSM9Kb/cOwmXQUrqV+YKv8ThA/nUKS7zKGMJhaafAbKJeLtALEnR2ZE1Q=="], "@devup-ui/eslint-plugin": ["@devup-ui/eslint-plugin@1.0.14", "", { "dependencies": { "@typescript-eslint/utils": "^8.57", "typescript-eslint": "^8.57" } }, "sha512-HLoIDIHgUsEJ4z8a0VGMx48DYIKfnv/jZPIQWFlK6s67n6x+R33loY+5O/mggbIDntGY2lknGTKFfKgD4hahPQ=="], "@devup-ui/next-plugin": ["@devup-ui/next-plugin@1.0.74", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.4", "@devup-ui/wasm": "^1.0.68", "@devup-ui/webpack-plugin": "^1.0.58" }, "peerDependencies": { "next": "*" } }, "sha512-GU2tz7RNZchnlzVxfXB7Sp2AMd/k3mjUyW/Tf6tDqhYgavYUz06DCV7lz4uojsMe/e1Q62IpwONINXicFBCH8Q=="], - "@devup-ui/plugin-utils": ["@devup-ui/plugin-utils@1.0.4", "", {}, "sha512-zAWxUawHphhzdcouy1vTVVkief0C7poCnDToxxQ4/rg9IGumIqmox7lSDRi579145qcd1jEH90uElCC0LpUiDA=="], + "@devup-ui/plugin-utils": ["@devup-ui/plugin-utils@1.0.10", "", {}, "sha512-jA8RDae2BXAN0Xsm0JPG7NZvlwsf8iRP2q1Do9G5V9zKZ5mO+qGUd/MVvYc8Xk4A6Av+OsPHDnctdyXC7VBXQw=="], "@devup-ui/react": ["@devup-ui/react@1.0.35", "", { "dependencies": { "csstype-extra": "latest", "react": "^19.2" } }, "sha512-OrgmKxziAkUgtVuZQX9I+pfKT/Fwa33JSqVUORJ171jqjjyLXGqtCyXsBvJVWfI4dmorMkJXJFVV7hh2ceJPNg=="], "@devup-ui/reset-css": ["@devup-ui/reset-css@1.0.23", "", { "dependencies": { "@devup-ui/react": "^1.0.35" } }, "sha512-tJ+YKODanxR6hIHBAMK8Ldwsw7bHR3djrnL0GBtAZ0B6VDOytuFa2XgsD+Jj+VSR3PQLoDBQSbClmgm5l17dTQ=="], - "@devup-ui/wasm": ["@devup-ui/wasm@1.0.72", "", {}, "sha512-hrnTY9cyTLDt8LMqWTRYG5eMs+7CGyaHObi4onA5TiCtmicb4/NWOM6THL4A0d4f5EzC6WzRDuobSMm+2wiIxA=="], + "@devup-ui/wasm": ["@devup-ui/wasm@1.0.75", "", {}, "sha512-MqANK1YxKqrYYxpFN8jb89nVzbLOGrlgh/oshfCwm9VaMFdx6qbWxAETlkHkXmkOp5UAhFOlgJbFLVm0MT1t2g=="], "@devup-ui/webpack-plugin": ["@devup-ui/webpack-plugin@1.0.58", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.4", "@devup-ui/wasm": "^1.0.68" } }, "sha512-JrQQd3p0kQ1kASL5E783OsfXLdzh4Aaiugy1901wN7enF7fcKvZthIxDMd3ionLDXym4ZAb+l0QnRFKlMW2Gcg=="], @@ -1114,6 +1114,14 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@devup-ui/next-plugin/@devup-ui/plugin-utils": ["@devup-ui/plugin-utils@1.0.4", "", {}, "sha512-zAWxUawHphhzdcouy1vTVVkief0C7poCnDToxxQ4/rg9IGumIqmox7lSDRi579145qcd1jEH90uElCC0LpUiDA=="], + + "@devup-ui/next-plugin/@devup-ui/wasm": ["@devup-ui/wasm@1.0.72", "", {}, "sha512-hrnTY9cyTLDt8LMqWTRYG5eMs+7CGyaHObi4onA5TiCtmicb4/NWOM6THL4A0d4f5EzC6WzRDuobSMm+2wiIxA=="], + + "@devup-ui/webpack-plugin/@devup-ui/plugin-utils": ["@devup-ui/plugin-utils@1.0.4", "", {}, "sha512-zAWxUawHphhzdcouy1vTVVkief0C7poCnDToxxQ4/rg9IGumIqmox7lSDRi579145qcd1jEH90uElCC0LpUiDA=="], + + "@devup-ui/webpack-plugin/@devup-ui/wasm": ["@devup-ui/wasm@1.0.72", "", {}, "sha512-hrnTY9cyTLDt8LMqWTRYG5eMs+7CGyaHObi4onA5TiCtmicb4/NWOM6THL4A0d4f5EzC6WzRDuobSMm+2wiIxA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], diff --git a/docs/PRD.md b/docs/PRD.md index 36f7e06..e488f08 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -104,7 +104,7 @@ PC·태블릿 사용. 경수 손상으로 손가락 미세 조작은 어렵지 ### 5.1 MVP에 포함 -- macOS·Windows floating 컨트롤러 (기본 4칸 스캔) +- macOS·Windows·Linux Tauri floating 컨트롤러 (기본 4칸 스캔) - 앱별 확장 칸 (PDF 뷰어·음악 앱, 앞 4칸 뒤에 최대 3칸) - 스위치 입력 전역 수신, 짧게/길게 누름 판정 - `Tab` / `Shift+Tab` / `Enter` 전역 주입 @@ -121,20 +121,23 @@ PC·태블릿 사용. 경수 손상으로 손가락 미세 조작은 어렵지 띄워 준다고 전제한다(아래 5.4). - 사용자가 직접 만드는 앱별 칸(내장 프리셋만 제공) — M4 이후 - 마우스 포인터를 쓰는 명령 세트(위·아래·좌·우) — M4 이후 -- Android(AccessibilityService + 오버레이), Linux +- Android(AccessibilityService + 오버레이) - 프로필 서버 동기화 — MVP는 로컬 저장만 제공 - 문자 입력(온스크린 키보드), 마우스 포인터 제어 - 예측 입력·단어 완성 ### 5.3 플랫폼 -| 플랫폼 | floating 오버레이 | 전역 키 주입 | MVP | 비고 | -| ------------- | ----------------- | ------------ | ---- | ----------------------------------------------- | -| macOS | 가능 (NSPanel) | 가능 (접근성 권한) | ○ | 손쉬운 사용 권한 필수 | -| Windows | 가능 (WS_EX_NOACTIVATE) | 가능 (SendInput) | ○ | 관리자 권한 앱 제어는 제한 | -| iOS / iPadOS | **불가** | **불가** | × | OS 정책. 아래 참조 | +| 플랫폼 | floating 오버레이 | 전역 키 주입 | MVP | 앞에 있는 앱 식별 / 비고 | +| ------------- | ----------------- | ------------ | ---- | ------------------------------------------------ | +| macOS | 가능 (NSPanel) | 가능 (접근성 권한) | ○ | bundle ID; 손쉬운 사용 권한 필수 | +| Windows | 가능 (WS_EX_NOACTIVATE) | 가능 (SendInput) | ○ | 실행 파일 basename; 관리자 권한 앱 제어는 제한 | +| Linux (X11) | 가능 | 가능 | ○ | desktop ID와 `WM_CLASS` | +| iOS / iPadOS | **불가** | **불가** | × | OS 정책. 아래 참조 | | Android | 가능 (오버레이 권한) | 가능 (AccessibilityService) | × | M5 이후 | -| Linux (X11) | 가능 | 가능 | × | Wayland는 제약이 큼 | + +Linux Wayland에서 안전한 compositor/desktop 어댑터가 없으면 활성 앱 식별을 추측하지 +않는다. 이 경우 앱별 프리셋은 선택하지 않고 기본 4칸을 유지한다. **iOS·iPadOS는 구조적으로 불가능하다.** 서드파티 앱은 시스템 전역 오버레이를 띄울 수 없고, 다른 앱에 키 입력을 주입할 수도 없다. 우회 방법을 찾지 않는다. @@ -423,6 +426,12 @@ M4 이후에 자주 쓰는 앱을 바로 여는 칸을 검토한다. 지금 넣 음악 앱의 재생 제어는 미디어 키다. 둘 다 `Tab`·`Enter`로는 도달할 수 없어, 그 앱에서 컨트롤러는 사실상 쓸모가 없었다. +Wave 2의 활성 앱 조회는 macOS bundle ID, Windows 실행 파일 basename, Linux X11 +desktop ID와 `WM_CLASS`를 원시 값으로 읽는다. 기존 macOS 내장 프리셋은 bundle ID를 +계속 사용한다. Windows·Linux X11 식별자를 프리셋으로 인식·적용하거나 레지스트리를 +다운로드·캐싱하는 일은 이 Wave에 포함하지 않는다. Linux Wayland에서 안전한 +compositor/desktop 어댑터가 없으면 앱별 프리셋을 선택하지 않고 기본 4칸을 유지한다. + - 지금 앞에 있는 앱에 맞춰 **기본 칸과 설정 사이에 최대 3칸**을 더 붙인다. - **기본 칸의 순서와 자리는 어떤 앱에서도 바뀌지 않는다.** 사용자는 자리로 동작을 기억한다. diff --git a/package.json b/package.json index 97940fc..bd9e02e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "oxlint": "1.64", "husky": "^9.1", "bun-test-env-dom": "^1.0", - "@devup-ui/bun-plugin": "^1.0", + "@devup-ui/bun-plugin": "^1.0.12", "@types/bun": "^1.3" }, "workspaces": [