From fb63bdd3c546dc71022c8e392e54f6ba54ca1e94 Mon Sep 17 00:00:00 2001 From: tkhwang Date: Sun, 18 Oct 2026 13:37:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(ide=5Fwindow):=20IDE=20=EC=B0=BD=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=9E=90=EB=8F=99=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새로운 설정 추가로 Zed, VS Code, Cursor 및 Antigravity IDE의 창을 Companion 화면으로 이동하도록 구현 - Accessibility 권한 요청 및 설정 열기 기능 추가 - IDE 창 위치 관련 상태를 관리하는 훅 구현 - 관련 UI 컴포넌트 및 테스트 추가 - 문서 업데이트 --- apps/companion/README.md | 2 + apps/companion/src-tauri/Cargo.lock | 1 + apps/companion/src-tauri/Cargo.toml | 13 +- apps/companion/src-tauri/src/ide_window.rs | 305 +++++++++++++++++ .../src-tauri/src/ide_window/macos.rs | 311 ++++++++++++++++++ .../src-tauri/src/ide_window_tests.rs | 241 ++++++++++++++ apps/companion/src-tauri/src/lib.rs | 17 +- apps/companion/src/App.tsx | 11 + apps/companion/src/application/ideWindow.ts | 43 +++ apps/companion/src/application/preferences.ts | 21 +- .../src/application/useIdeWindowAccess.ts | 86 +++++ .../src/infrastructure/tauriClient.ts | 18 + apps/companion/src/ui/IdeWindowSettings.tsx | 71 ++++ apps/companion/src/ui/SettingsPanel.tsx | 17 +- apps/companion/src/ui/SettingsView.tsx | 4 + apps/companion/tests/preferences.test.ts | 73 +++- .../tests/screensaver-settings.test.tsx | 12 + apps/companion/tests/settings-panel.test.tsx | 143 +++++++- docs/usage.ko.md | 2 + docs/usage.md | 2 + 20 files changed, 1381 insertions(+), 12 deletions(-) create mode 100644 apps/companion/src-tauri/src/ide_window.rs create mode 100644 apps/companion/src-tauri/src/ide_window/macos.rs create mode 100644 apps/companion/src-tauri/src/ide_window_tests.rs create mode 100644 apps/companion/src/application/ideWindow.ts create mode 100644 apps/companion/src/application/useIdeWindowAccess.ts create mode 100644 apps/companion/src/ui/IdeWindowSettings.tsx diff --git a/apps/companion/README.md b/apps/companion/README.md index 5e62ea0..ccc0d24 100644 --- a/apps/companion/README.md +++ b/apps/companion/README.md @@ -10,6 +10,8 @@ Agent runtime state comes from `RuntimeService` (`src-tauri/src/runtime_service. The opt-in screensaver (`src-tauri/src/screensaver.rs` and `src-tauri/src/screensaver/`) keeps the Mac from idle sleep while agents work. When the macOS screen saver starts or the screen locks, it locks the Mac if needed, shows the dashboard (`dashboard.html`, its own Vite entry and `dashboard` capability) on every display above the lock screen, and keeps the display on; input moves the dashboard aside to reveal the lock screen. If locking is unavailable, the lock indicator is hidden and it never locks by itself. A pure state machine (`screensaver/machine.rs`) decides and a `Platform` trait acts, so transitions are unit-tested without the OS. It relies on private macOS APIs, `SACLockScreenImmediate`, a SkyLight space above the lock screen and the webview background that Tauri's `macOSPrivateApi` controls, so the app cannot ship through the Mac App Store. `WORKBRANCH_SCREENSAVER_NO_LOCK=1` starts it as if the lock were unavailable. See `docs/specs/comp/screensaver.md`. +The IDE action moves the windows the IDE opens onto the screen the companion is on (`src-tauri/src/ide_window.rs` and `src-tauri/src/ide_window/macos.rs`). Zed reopens a project's window where it last was, or else where its last window was, and none of the supported IDEs takes a target screen, so `workbranch_run` reads the companion window's monitor, notes every CoreGraphics window number (which covers every Space and needs no permission) before running `workbranch ide`, and then moves each new standard window of Zed, VS Code, Cursor or Antigravity IDE through the Accessibility API, once, centered at its size and shrunk to fit. `_AXUIElementGetWindow`, a private call probed at runtime, maps an Accessibility window to its window number. A repo window that was already open stays where it is. Following stops 10 seconds after the command ends if no new window appears, or 3 seconds after the later of the command's end and the last new window. Nothing moves until Settings › General › IDE Windows is switched on (the `ideWindow` preference, read on each IDE action) and macOS granted the Accessibility permission. Switching it on shows macOS's permission prompt; Settings reads the permission again each time it opens and, while it is missing, offers to open the Accessibility list in System Settings (`ide_window_access`, `ide_window_request_access`, `ide_window_open_access_settings`). Under `pnpm tauri dev` the companion runs as a plain binary under your terminal, so macOS checks and prompts for the terminal app's Accessibility permission instead; test this with the bundled app. Each ad-hoc signed local build counts as a new app to macOS, so remove WorkbranchCompanion from the Accessibility list and allow it again after rebuilding. + Agent usage comes from the Rust `usage_snapshot` command (`src-tauri/src/usage.rs`), which reads only files the agents already write: Claude Code transcripts under `~/.claude/projects` (each response counted once by `message.id` + `requestId`) and the `/usage` result Claude Code caches in `~/.claude.json` (or, when the opt-in Live Claude Limits relay in `src-tauri/src/claude_statusline.rs` is on, the newer `rate_limits` Claude Code passed to its status line, saved in the companion's data directory), plus Codex rollouts under `~/.codex/sessions` and `~/.codex/archived_sessions` (token deltas from `total_token_usage`, with a file's first event counted by its `last_token_usage` and identical events shared by forked rollouts counted once; cache reads and writes split out of input; limits from `rate_limits`, whose reset may be epoch seconds or an RFC3339 string). `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honored. It never signs in or touches the network, keeps parsed files in memory keyed by size and mtime, and returns 15-minute token buckets that the frontend folds into local days. Daily totals match ccusage. ## Development diff --git a/apps/companion/src-tauri/Cargo.lock b/apps/companion/src-tauri/Cargo.lock index be75a82..98c3efc 100644 --- a/apps/companion/src-tauri/Cargo.lock +++ b/apps/companion/src-tauri/Cargo.lock @@ -1993,6 +1993,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", "objc2-foundation", diff --git a/apps/companion/src-tauri/Cargo.toml b/apps/companion/src-tauri/Cargo.toml index e873a22..0650e9b 100644 --- a/apps/companion/src-tauri/Cargo.toml +++ b/apps/companion/src-tauri/Cargo.toml @@ -40,12 +40,13 @@ undocumented_unsafe_blocks = "deny" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2" -# Typed AppKit/Foundation bindings for the screensaver (COMP001, COMP002); the -# same versions Tauri already pulls in, so no new crates enter the build. +# Typed AppKit/Foundation bindings for the screensaver (COMP001, COMP002) and +# for moving new IDE windows to the companion's screen; the same versions +# Tauri already pulls in, so no new crates enter the build. [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" objc2 = "0.6.4" -objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] } -objc2-core-graphics = { version = "0.3.2", default-features = false, features = ["std", "CGEventSource", "CGEventTypes", "CGSession"] } -objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "block2", "NSDistributedNotificationCenter", "NSNotification", "NSObject", "NSOperation", "NSProcessInfo", "NSString"] } -objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] } +objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["std", "CFArray", "CFCGTypes", "CFDictionary", "CFNumber", "CFString"] } +objc2-core-graphics = { version = "0.3.2", default-features = false, features = ["std", "CGEventSource", "CGEventTypes", "CGSession", "CGWindow"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "block2", "NSArray", "NSDistributedNotificationCenter", "NSNotification", "NSObject", "NSOperation", "NSProcessInfo", "NSString"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "libc", "NSApplication", "NSResponder", "NSRunningApplication", "NSWindow"] } diff --git a/apps/companion/src-tauri/src/ide_window.rs b/apps/companion/src-tauri/src/ide_window.rs new file mode 100644 index 0000000..f99a94f --- /dev/null +++ b/apps/companion/src-tauri/src/ide_window.rs @@ -0,0 +1,305 @@ +//! Moves the window an IDE opens onto the screen the companion is on. +//! +//! Zed opens a window where that project's window last was, or else where +//! the last window was, often on another display, and none of the supported +//! IDEs can be told which screen to use. So while `workbranch ide` runs, the +//! companion notes every window that already exists, then moves each window +//! one of these IDEs opens afterwards onto the companion's screen through +//! the Accessibility API. A window the IDE only brings forward stays where +//! it is. The `ideWindow` switch in Settings turns this on, and Settings +//! asks for the Accessibility permission; until both are on, the IDEs place +//! their windows themselves. + +#[cfg(target_os = "macos")] +mod macos; + +use std::collections::HashSet; +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::time::{Duration, Instant}; + +use tauri::{Runtime, WebviewWindow}; +use tauri_plugin_store::StoreExt; + +use crate::menu_bar::PREFERENCES_STORE_FILE; + +/// The preference key Settings saves the switch under +/// (`src/application/ideWindow.ts` holds the same shape). +const CONFIG_KEY: &str = "ideWindow"; +/// The Accessibility list in System Settings. +#[cfg(target_os = "macos")] +const ACCESS_SETTINGS_URL: &str = + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; + +/// Bundle identifiers of the IDEs whose new windows follow the companion's +/// screen: Zed, VS Code, Cursor and Antigravity IDE, with the preview +/// channels of Zed and VS Code. +pub(crate) const IDE_BUNDLE_IDS: [&str; 6] = [ + "dev.zed.Zed", + "dev.zed.Zed-Preview", + "com.microsoft.VSCode", + "com.microsoft.VSCodeInsiders", + // Cursor + "com.todesktop.230313mzl4w4u92", + "com.google.antigravity-ide", +]; + +/// How long after `workbranch ide` finishes a new window is still awaited: +/// long enough for an IDE that was not running to start. +const WAIT_FOR_FIRST: Duration = Duration::from_secs(10); +/// Once a new window arrived and the command finished, how long to wait for +/// the windows of a task's other repos. +const WAIT_FOR_MORE: Duration = Duration::from_secs(3); +/// Stops following even if the command never finishes. +const MAX_FOLLOW: Duration = Duration::from_secs(120); +const POLL: Duration = Duration::from_millis(100); + +/// A rectangle in global screen points, with the origin at the top-left of +/// the main display: the space both CoreGraphics and the Accessibility API +/// use. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Frame { + pub(crate) x: f64, + pub(crate) y: f64, + pub(crate) width: f64, + pub(crate) height: f64, +} + +impl Frame { + fn contains(&self, x: f64, y: f64) -> bool { + x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height + } +} + +/// The screen new IDE windows move to, or `None` while the Settings switch +/// is off or the Accessibility permission is missing. +pub(crate) fn screen_to_follow(window: &WebviewWindow) -> Option { + let switched_on = window + .store(PREFERENCES_STORE_FILE) + .is_ok_and(|store| enabled_in_store_value(store.get(CONFIG_KEY).as_ref())); + (switched_on && access_granted()) + .then(|| companion_screen(window)) + .flatten() +} + +/// Reads the switch the way `sanitizeIdeWindowConfig()` does: anything but +/// `{ "enabled": true }` is off. +pub(crate) fn enabled_in_store_value(value: Option<&serde_json::Value>) -> bool { + value + .and_then(|value| value.get("enabled")) + .and_then(serde_json::Value::as_bool) + .unwrap_or_default() +} + +/// The usable area, without the menu bar and the Dock, of the monitor the +/// companion window is on. +fn companion_screen(window: &WebviewWindow) -> Option { + let monitor = window.current_monitor().ok().flatten()?; + let area = monitor.work_area(); + screen_frame( + area.position.x, + area.position.y, + area.size.width, + area.size.height, + monitor.scale_factor(), + ) +} + +/// Tauri reports a monitor's area in points multiplied by that monitor's +/// scale factor, so dividing by the factor gives the points back. +pub(crate) fn screen_frame(x: i32, y: i32, width: u32, height: u32, scale: f64) -> Option { + (scale > 0.0 && width > 0 && height > 0).then(|| Frame { + x: f64::from(x) / scale, + y: f64::from(y) / scale, + width: f64::from(width) / scale, + height: f64::from(height) / scale, + }) +} + +/// Where a window goes on `screen`: nowhere when its center is already on +/// that screen, else centered there at its own size, shrunk to fit. +pub(crate) fn placement(window: Frame, screen: Frame) -> Option { + if screen.contains( + window.x + window.width / 2.0, + window.y + window.height / 2.0, + ) { + return None; + } + let width = window.width.min(screen.width); + let height = window.height.min(screen.height); + Some(Frame { + x: (screen.x + (screen.width - width) / 2.0).round(), + y: (screen.y + (screen.height - height) / 2.0).round(), + width, + height, + }) +} + +/// An IDE window the OS can move right now. +pub(crate) struct OpenWindow { + /// The CoreGraphics window number, stable for the window's life. + pub(crate) id: u32, + pub(crate) frame: Frame, + pub(crate) handle: H, +} + +/// What following asks of the OS. The macOS implementation lives in +/// `macos.rs`; tests use a fake. +pub(crate) trait Desktop { + type Handle; + /// The supported IDEs' standard windows that can be moved now, so not + /// minimized or full screen. + fn ide_windows(&self) -> Vec>; + fn move_window(&self, handle: &Self::Handle, frame: Frame); +} + +/// Moves each IDE window that did not exist when the command started onto +/// the companion's screen, once, so the user can move it away again. +pub(crate) struct Follower { + screen: Frame, + /// Window numbers that existed before the command, or were handled. + seen: HashSet, +} + +impl Follower { + pub(crate) fn new(screen: Frame, existing: HashSet) -> Self { + Self { + screen, + seen: existing, + } + } + + /// One look at the IDE windows; returns how many were new. + pub(crate) fn step(&mut self, desktop: &impl Desktop) -> usize { + let mut new_windows = 0; + for window in desktop.ide_windows() { + if !self.seen.insert(window.id) { + continue; + } + new_windows += 1; + if let Some(frame) = placement(window.frame, self.screen) { + desktop.move_window(&window.handle, frame); + } + } + new_windows + } +} + +/// Whether to keep looking for new windows, given the time since the +/// command finished (`None` while it runs) and since the last new window +/// (`None` before the first). A window can open before the command ends, +/// so the quiet time counts from whichever came later. +pub(crate) fn keep_following( + since_finished: Option, + since_new_window: Option, +) -> bool { + let Some(since_finished) = since_finished else { + return true; + }; + since_finished < WAIT_FOR_FIRST + && since_new_window.is_none_or(|since_new| since_new.min(since_finished) < WAIT_FOR_MORE) +} + +/// Looks for new IDE windows until `keep_following` says to stop. The +/// command counts as finished once `command_running` disconnects. +pub(crate) fn run(desktop: &impl Desktop, mut follower: Follower, command_running: &Receiver<()>) { + let started = Instant::now(); + let mut finished_at = None; + let mut new_window_at = None; + loop { + if finished_at.is_none() + && matches!(command_running.try_recv(), Err(TryRecvError::Disconnected)) + { + finished_at = Some(Instant::now()); + } + if follower.step(desktop) > 0 { + new_window_at = Some(Instant::now()); + } + let now = Instant::now(); + let since = |at: Instant| now.duration_since(at); + if since(started) >= MAX_FOLLOW + || !keep_following(finished_at.map(since), new_window_at.map(since)) + { + return; + } + std::thread::sleep(POLL); + } +} + +/// Follows one `workbranch ide` run. Take it right before the command and +/// drop it once the command returns: dropping it starts the wait for +/// windows that open late. +pub(crate) struct Following { + #[cfg(target_os = "macos")] + _command_running: std::sync::mpsc::Sender<()>, +} + +/// Notes the windows that exist now, then moves the new IDE windows onto +/// `screen` from a background thread. +#[cfg(target_os = "macos")] +pub(crate) fn follow(screen: Frame) -> Following { + Following { + _command_running: macos::follow(screen), + } +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn follow(_screen: Frame) -> Following { + Following {} +} + +/// Whether macOS lets the companion move other apps' windows. +#[cfg(target_os = "macos")] +fn access_granted() -> bool { + macos::access_granted() +} + +#[cfg(not(target_os = "macos"))] +fn access_granted() -> bool { + false +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IdeWindowAccess { + granted: bool, +} + +/// Lets Settings show whether the Accessibility permission is granted. It +/// can change in System Settings at any time, so Settings asks again each +/// time it opens. +#[tauri::command] +pub(crate) fn ide_window_access() -> IdeWindowAccess { + IdeWindowAccess { + granted: access_granted(), + } +} + +/// Shows macOS's prompt for the Accessibility permission, which also lists +/// the companion in System Settings, unless it is granted already. +#[tauri::command] +pub(crate) fn ide_window_request_access() -> IdeWindowAccess { + #[cfg(target_os = "macos")] + let granted = macos::request_access(); + #[cfg(not(target_os = "macos"))] + let granted = false; + IdeWindowAccess { granted } +} + +/// Opens the Accessibility list in System Settings, where the permission is +/// turned on, or off again. +#[tauri::command] +pub(crate) fn ide_window_open_access_settings() -> Result<(), String> { + #[cfg(target_os = "macos")] + let opened = std::process::Command::new("/usr/bin/open") + .arg(ACCESS_SETTINGS_URL) + .spawn() + .map(drop) + .map_err(|error| format!("failed to open System Settings: {error}")); + #[cfg(not(target_os = "macos"))] + let opened = Err("the Accessibility permission exists on macOS only".to_owned()); + opened +} + +#[cfg(test)] +#[path = "ide_window_tests.rs"] +mod tests; diff --git a/apps/companion/src-tauri/src/ide_window/macos.rs b/apps/companion/src-tauri/src/ide_window/macos.rs new file mode 100644 index 0000000..442ecea --- /dev/null +++ b/apps/companion/src-tauri/src/ide_window/macos.rs @@ -0,0 +1,311 @@ +//! The macOS side of `ide_window`. CoreGraphics lists the window numbers on +//! every Space without any permission; the Accessibility API, which needs +//! the user's permission, reads and moves the IDE windows. + +use std::collections::HashSet; +use std::ffi::{CStr, c_void}; +use std::ptr::NonNull; +use std::sync::OnceLock; +use std::sync::mpsc::{self, Sender}; + +use objc2::rc::autoreleasepool; +use objc2_app_kit::NSRunningApplication; +use objc2_core_foundation::{ + CFArray, CFBoolean, CFDictionary, CFNumber, CFRetained, CFString, CFType, CGPoint, CGSize, +}; +use objc2_core_graphics::{CGWindowListCopyWindowInfo, CGWindowListOption, kCGWindowNumber}; +use objc2_foundation::NSString; + +use super::{Desktop, Follower, Frame, IDE_BUNDLE_IDS, OpenWindow}; + +type AXError = i32; +const AX_SUCCESS: AXError = 0; +/// Seconds an Accessibility call waits for a busy app, instead of six. +const AX_TIMEOUT_SECONDS: f32 = 1.0; + +const APPLICATION_SERVICES: &CStr = + c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices"; +/// `_AXUIElementGetWindow`: the CoreGraphics window number of an +/// Accessibility window. Private, so it is probed rather than linked; +/// macOS window managers have relied on it for years. +const GET_WINDOW_SYMBOL: &CStr = c"_AXUIElementGetWindow"; +type GetWindow = unsafe extern "C" fn(*const CFType, *mut u32) -> AXError; + +#[link(name = "ApplicationServices", kind = "framework")] +unsafe extern "C" { + safe fn AXIsProcessTrusted() -> u8; + fn AXIsProcessTrustedWithOptions(options: *const c_void) -> u8; + safe fn AXUIElementCreateSystemWide() -> *mut CFType; + safe fn AXUIElementCreateApplication(pid: libc::pid_t) -> *mut CFType; + fn AXUIElementCopyAttributeValue( + element: &CFType, + attribute: &CFString, + value: *mut *mut CFType, + ) -> AXError; + fn AXUIElementSetAttributeValue( + element: &CFType, + attribute: &CFString, + value: &CFType, + ) -> AXError; + fn AXUIElementSetMessagingTimeout(element: &CFType, timeout_seconds: f32) -> AXError; + fn AXValueCreate(value_type: u32, value: *const c_void) -> *mut CFType; + fn AXValueGetValue(value: &CFType, value_type: u32, value_out: *mut c_void) -> u8; + static kAXTrustedCheckOptionPrompt: &'static CFString; +} + +pub(super) fn access_granted() -> bool { + AXIsProcessTrusted() != 0 +} + +/// Shows macOS's prompt that leads to the Accessibility list in System +/// Settings, unless the permission is granted already; returns whether it +/// is. +pub(super) fn request_access() -> bool { + // SAFETY: a constant string HIServices exports. + let prompt_key = unsafe { kAXTrustedCheckOptionPrompt }; + let options = + CFDictionary::::from_slices(&[prompt_key], &[CFBoolean::new(true)]); + let options_pointer = CFRetained::as_ptr(&options).as_ptr().cast_const().cast(); + // SAFETY: a live dictionary that holds the prompt option. + unsafe { AXIsProcessTrustedWithOptions(options_pointer) != 0 } +} + +/// Notes every window that exists now, before the command opens any, then +/// follows from a background thread until the returned sender is dropped +/// and late windows had their time. +pub(super) fn follow(screen: Frame) -> Sender<()> { + let (command_running, receiver) = mpsc::channel(); + let existing = window_numbers(); + // A thread that fails to start leaves the windows where the IDE puts them. + let _thread = std::thread::Builder::new() + .name("ide-window".into()) + .spawn(move || { + if let Some(desktop) = MacDesktop::new() { + super::run(&desktop, Follower::new(screen, existing), &receiver); + } + }); + command_running +} + +/// The numbers of every window, on every Space and of every app. A window +/// keeps its number for life, so one missing here opened later. +fn window_numbers() -> HashSet { + let Some(windows) = CGWindowListCopyWindowInfo(CGWindowListOption::OptionAll, 0) else { + return HashSet::new(); + }; + // SAFETY: the window list is an array of dictionaries from CFString + // keys to CF values. + let windows: CFRetained>> = + unsafe { CFRetained::cast_unchecked(windows) }; + // SAFETY: a constant string CoreGraphics exports. + let number_key = unsafe { kCGWindowNumber }; + windows + .iter() + .filter_map(|window| { + let number = window.get(number_key)?.downcast::().ok()?; + u32::try_from(number.as_i64()?).ok() + }) + .collect() +} + +struct MacDesktop { + get_window: GetWindow, +} + +impl MacDesktop { + fn new() -> Option { + if let Some(system) = NonNull::new(AXUIElementCreateSystemWide()) { + // SAFETY: AXUIElementCreateSystemWide returns a new reference. + let system = unsafe { CFRetained::from_raw(system) }; + // SAFETY: a live element; on the system-wide element the timeout + // applies to every Accessibility call of this process. + let _error = unsafe { AXUIElementSetMessagingTimeout(&system, AX_TIMEOUT_SECONDS) }; + } + Some(Self { + get_window: get_window()?, + }) + } + + fn window_number(&self, window: &CFType) -> Option { + let mut number = 0; + // SAFETY: `get_window` is _AXUIElementGetWindow, which reads an + // Accessibility element and writes a CGWindowID. + let error = unsafe { (self.get_window)(std::ptr::from_ref(window), &raw mut number) }; + (error == AX_SUCCESS && number != 0).then_some(number) + } +} + +impl Desktop for MacDesktop { + type Handle = CFRetained; + + fn ide_windows(&self) -> Vec> { + ide_pids() + .into_iter() + .flat_map(application_windows) + .filter(|window| is_movable(window)) + .filter_map(|window| { + Some(OpenWindow { + id: self.window_number(&window)?, + frame: frame(&window)?, + handle: window, + }) + }) + .collect() + } + + fn move_window(&self, window: &Self::Handle, frame: Frame) { + let origin = CGPoint { + x: frame.x, + y: frame.y, + }; + let size = CGSize { + width: frame.width, + height: frame.height, + }; + // Moving first lets the window take a size that only fits on the + // new screen; moving again settles an origin macOS adjusted for the + // old size. + write_value(window, "AXPosition", origin); + write_value(window, "AXSize", size); + write_value(window, "AXPosition", origin); + } +} + +fn get_window() -> Option { + static GET_WINDOW: OnceLock> = OnceLock::new(); + *GET_WINDOW.get_or_init(|| { + // SAFETY: a valid framework path; the handle is never closed, so the + // symbol stays valid for the process's life. + let handle = unsafe { libc::dlopen(APPLICATION_SERVICES.as_ptr(), libc::RTLD_LAZY) }; + if handle.is_null() { + return None; + } + // SAFETY: a live handle and a valid C symbol name. + let symbol = unsafe { libc::dlsym(handle, GET_WINDOW_SYMBOL.as_ptr()) }; + if symbol.is_null() { + return None; + } + // SAFETY: _AXUIElementGetWindow has the C signature `GetWindow` + // spells out. + Some(unsafe { std::mem::transmute::<*mut c_void, GetWindow>(symbol) }) + }) +} + +fn ide_pids() -> Vec { + // This runs on a background thread, which has no autorelease pool of + // its own. + autoreleasepool(|_| { + IDE_BUNDLE_IDS + .iter() + .flat_map(|bundle_id| { + NSRunningApplication::runningApplicationsWithBundleIdentifier(&NSString::from_str( + bundle_id, + )) + .to_vec() + }) + .map(|application| application.processIdentifier()) + .filter(|pid| *pid > 0) + .collect() + }) +} + +fn application_windows(pid: libc::pid_t) -> Vec> { + let Some(application) = NonNull::new(AXUIElementCreateApplication(pid)) else { + return Vec::new(); + }; + // SAFETY: AXUIElementCreateApplication returns a new reference. + let application = unsafe { CFRetained::from_raw(application) }; + let Some(windows) = + attribute(&application, "AXWindows").and_then(|value| value.downcast::().ok()) + else { + return Vec::new(); + }; + // SAFETY: an app's AXWindows holds Accessibility elements, CF objects. + let windows: CFRetained> = unsafe { CFRetained::cast_unchecked(windows) }; + windows.to_vec() +} + +/// A document window the user could drag: not a dialog or panel, and not +/// minimized or in its own full-screen Space. +fn is_movable(window: &CFType) -> bool { + let standard = attribute(window, "AXSubrole") + .and_then(|subrole| subrole.downcast::().ok()) + .is_some_and(|subrole| *subrole == *CFString::from_static_str("AXStandardWindow")); + standard && !flag(window, "AXMinimized") && !flag(window, "AXFullScreen") +} + +fn flag(element: &CFType, name: &'static str) -> bool { + attribute(element, name) + .and_then(|value| value.downcast::().ok()) + .is_some_and(|value| value.as_bool()) +} + +fn frame(window: &CFType) -> Option { + let origin = read_value(window, "AXPosition", CGPoint { x: 0.0, y: 0.0 })?; + let size = read_value( + window, + "AXSize", + CGSize { + width: 0.0, + height: 0.0, + }, + )?; + Some(Frame { + x: origin.x, + y: origin.y, + width: size.width, + height: size.height, + }) +} + +fn attribute(element: &CFType, name: &'static str) -> Option> { + let mut value = std::ptr::null_mut(); + // SAFETY: a live element and attribute name; on success the call stores + // a new reference in `value`. + let error = unsafe { + AXUIElementCopyAttributeValue(element, &CFString::from_static_str(name), &raw mut value) + }; + if error != AX_SUCCESS { + return None; + } + // SAFETY: the reference the call handed over, now owned here. + NonNull::new(value).map(|value| unsafe { CFRetained::from_raw(value) }) +} + +/// A geometry an AXValue holds, with the `AXValueType` that names it. +trait AxGeometry: Copy { + const KIND: u32; +} + +impl AxGeometry for CGPoint { + /// `kAXValueCGPointType` + const KIND: u32 = 1; +} + +impl AxGeometry for CGSize { + /// `kAXValueCGSizeType` + const KIND: u32 = 2; +} + +fn read_value(element: &CFType, name: &'static str, mut out: T) -> Option { + let value = attribute(element, name)?; + // SAFETY: `T::KIND` names the C type of `T`, so the call writes a whole + // `T`; it returns false and writes nothing when the value holds another + // type. + let read = unsafe { AXValueGetValue(&value, T::KIND, (&raw mut out).cast()) }; + (read != 0).then_some(out) +} + +fn write_value(element: &CFType, name: &'static str, geometry: T) { + // SAFETY: `T::KIND` names the C type of `T`, which the call copies. + let value = unsafe { AXValueCreate(T::KIND, (&raw const geometry).cast()) }; + let Some(value) = NonNull::new(value) else { + return; + }; + // SAFETY: AXValueCreate returns a new reference. + let value = unsafe { CFRetained::from_raw(value) }; + // SAFETY: a live element, an attribute name and an AXValue of the type + // the attribute takes. + let _error = + unsafe { AXUIElementSetAttributeValue(element, &CFString::from_static_str(name), &value) }; +} diff --git a/apps/companion/src-tauri/src/ide_window_tests.rs b/apps/companion/src-tauri/src/ide_window_tests.rs new file mode 100644 index 0000000..9e515eb --- /dev/null +++ b/apps/companion/src-tauri/src/ide_window_tests.rs @@ -0,0 +1,241 @@ +use std::cell::RefCell; +use std::collections::HashSet; +use std::time::Duration; + +use super::*; + +fn frame(x: f64, y: f64, width: f64, height: f64) -> Frame { + Frame { + x, + y, + width, + height, + } +} + +/// A laptop's area below the menu bar, at the global origin. +const LAPTOP: Frame = Frame { + x: 0.0, + y: 33.0, + width: 1512.0, + height: 949.0, +}; +/// A larger display arranged to the laptop's left and higher up. +const EXTERNAL: Frame = Frame { + x: -2560.0, + y: -400.0, + width: 2560.0, + height: 1415.0, +}; + +#[derive(Default)] +struct FakeDesktop { + windows: RefCell>, + moves: RefCell>, +} + +impl FakeDesktop { + fn open(&self, id: u32, frame: Frame) { + self.windows.borrow_mut().push((id, frame)); + } + + fn moves(&self) -> Vec<(u32, Frame)> { + self.moves.borrow().clone() + } +} + +impl Desktop for FakeDesktop { + type Handle = u32; + + fn ide_windows(&self) -> Vec> { + self.windows + .borrow() + .iter() + .map(|&(id, frame)| OpenWindow { + id, + frame, + handle: id, + }) + .collect() + } + + fn move_window(&self, handle: &u32, frame: Frame) { + self.moves.borrow_mut().push((*handle, frame)); + } +} + +#[test] +fn window_already_on_the_screen_stays() { + let window = frame(100.0, 100.0, 1200.0, 800.0); + + assert_eq!(placement(window, LAPTOP), None); +} + +#[test] +fn window_on_another_screen_moves_to_the_center_at_its_size() { + let window = frame(-2400.0, -300.0, 1200.0, 800.0); + + assert_eq!( + placement(window, LAPTOP), + Some(frame(156.0, 108.0, 1200.0, 800.0)) + ); +} + +#[test] +fn window_reaching_into_the_screen_moves_when_its_center_is_elsewhere() { + // The window's left part overlaps the laptop, but its center does not. + let window = frame(-1000.0, 100.0, 1600.0, 800.0); + + assert_eq!( + placement(window, LAPTOP), + Some(frame(0.0, 108.0, 1512.0, 800.0)) + ); +} + +#[test] +fn window_larger_than_the_screen_shrinks_to_fit() { + let window = frame(-2560.0, -400.0, 2560.0, 1415.0); + + assert_eq!(placement(window, LAPTOP), Some(LAPTOP)); +} + +#[test] +fn window_moves_onto_a_screen_left_of_and_above_the_main_display() { + let window = frame(100.0, 100.0, 1200.0, 800.0); + + assert_eq!( + placement(window, EXTERNAL), + Some(frame(-1880.0, -93.0, 1200.0, 800.0)) + ); +} + +#[test] +fn screen_frame_turns_scaled_pixels_back_into_points() { + // A Retina display at twice its point size, left of the main display. + assert_eq!(screen_frame(-5120, -800, 5120, 2830, 2.0), Some(EXTERNAL)); + assert_eq!(screen_frame(0, 33, 1512, 949, 1.0), Some(LAPTOP)); +} + +#[test] +fn screen_frame_rejects_an_empty_area_or_scale() { + assert_eq!(screen_frame(0, 0, 1512, 949, 0.0), None); + assert_eq!(screen_frame(0, 0, 0, 949, 2.0), None); +} + +#[test] +fn follower_leaves_windows_that_existed_before_the_command() { + let desktop = FakeDesktop::default(); + desktop.open(7, frame(-2400.0, -300.0, 1200.0, 800.0)); + let mut follower = Follower::new(LAPTOP, HashSet::from([7])); + + assert_eq!(follower.step(&desktop), 0); + assert_eq!(desktop.moves(), Vec::new()); +} + +#[test] +fn follower_moves_a_new_window_once() { + let desktop = FakeDesktop::default(); + desktop.open(7, frame(-2400.0, -300.0, 1200.0, 800.0)); + let mut follower = Follower::new(LAPTOP, HashSet::new()); + + assert_eq!(follower.step(&desktop), 1); + // The fake keeps reporting the old frame, as if the user dragged the + // window back; it is not moved a second time. + assert_eq!(follower.step(&desktop), 0); + assert_eq!( + desktop.moves(), + vec![(7, frame(156.0, 108.0, 1200.0, 800.0))] + ); +} + +#[test] +fn follower_counts_a_new_window_already_on_the_screen_without_moving_it() { + let desktop = FakeDesktop::default(); + desktop.open(8, frame(100.0, 100.0, 1200.0, 800.0)); + let mut follower = Follower::new(LAPTOP, HashSet::new()); + + assert_eq!(follower.step(&desktop), 1); + assert_eq!(desktop.moves(), Vec::new()); +} + +#[test] +fn follower_moves_each_window_of_a_task_as_it_appears() { + let desktop = FakeDesktop::default(); + desktop.open(1, frame(100.0, 100.0, 1200.0, 800.0)); + let mut follower = Follower::new(LAPTOP, HashSet::from([1])); + desktop.open(2, frame(-2400.0, -300.0, 1200.0, 800.0)); + + assert_eq!(follower.step(&desktop), 1); + desktop.open(3, frame(-1200.0, -300.0, 1000.0, 700.0)); + assert_eq!(follower.step(&desktop), 1); + + assert_eq!( + desktop.moves(), + vec![ + (2, frame(156.0, 108.0, 1200.0, 800.0)), + (3, frame(256.0, 158.0, 1000.0, 700.0)), + ] + ); +} + +fn seconds(value: f64) -> Duration { + Duration::from_secs_f64(value) +} + +#[test] +fn following_continues_while_the_command_runs() { + assert!(keep_following(None, None)); + assert!(keep_following(None, Some(seconds(30.0)))); +} + +#[test] +fn following_waits_for_a_starting_ide_until_the_limit() { + assert!(keep_following(Some(seconds(9.9)), None)); + assert!(!keep_following(Some(seconds(10.0)), None)); +} + +#[test] +fn following_stops_once_new_windows_stop_arriving() { + assert!(keep_following(Some(seconds(5.0)), Some(seconds(2.9)))); + assert!(!keep_following(Some(seconds(5.0)), Some(seconds(3.0)))); +} + +#[test] +fn quiet_time_counts_from_the_end_of_the_command() { + // A window opened long before a multi-repo command finished; the other + // repos' windows still get time after it. + assert!(keep_following(Some(seconds(1.0)), Some(seconds(20.0)))); + assert!(!keep_following(Some(seconds(3.0)), Some(seconds(20.0)))); +} + +#[test] +fn every_supported_ide_is_listed_once() { + let unique: HashSet<_> = IDE_BUNDLE_IDS.iter().collect(); + + assert_eq!(unique.len(), IDE_BUNDLE_IDS.len()); + for bundle_id in [ + "dev.zed.Zed", + "com.microsoft.VSCode", + "com.todesktop.230313mzl4w4u92", + "com.google.antigravity-ide", + ] { + assert!(IDE_BUNDLE_IDS.contains(&bundle_id), "{bundle_id}"); + } +} + +#[test] +fn the_settings_switch_is_on_only_when_stored_as_true() { + let on = serde_json::json!({ "enabled": true }); + assert!(enabled_in_store_value(Some(&on))); + + for off in [ + serde_json::json!({ "enabled": false }), + serde_json::json!({ "enabled": "yes" }), + serde_json::json!({}), + serde_json::json!(true), + ] { + assert!(!enabled_in_store_value(Some(&off)), "{off}"); + } + // Stores from before the switch existed have no key at all. + assert!(!enabled_in_store_value(None)); +} diff --git a/apps/companion/src-tauri/src/lib.rs b/apps/companion/src-tauri/src/lib.rs index ce358be..81da9a2 100644 --- a/apps/companion/src-tauri/src/lib.rs +++ b/apps/companion/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ use tauri::{AppHandle, Emitter, Manager, State}; use thiserror::Error; mod claude_statusline; +mod ide_window; mod menu_bar; mod process_env; mod runtime_service; @@ -162,9 +163,16 @@ async fn usage_snapshot( #[tauri::command] async fn workbranch_run( + window: tauri::WebviewWindow, action: CompanionCommand, cwd: String, ) -> Result { + // Read while the companion still shows on the screen the user looks at; + // the IDE's new windows move there when Settings turned that on. + let ide_screen = match action { + CompanionCommand::Ide { .. } => ide_window::screen_to_follow(&window), + _ => None, + }; tauri::async_runtime::spawn_blocking(move || { let cwd_path = PathBuf::from(cwd); match action { @@ -173,7 +181,11 @@ async fn workbranch_run( let args = workbranch_args_for(&command).ok_or_else(|| { std::io::Error::other("companion command has no workbranch argv") })?; - run_workbranch(&resolve_workbranch_bin(None)?, &args, &cwd_path) + let bin = resolve_workbranch_bin(None)?; + let following = ide_screen.map(ide_window::follow); + let result = run_workbranch(&bin, &args, &cwd_path); + drop(following); + result } } }) @@ -326,6 +338,9 @@ pub fn run() { usage_snapshot, claude_statusline::claude_limit_relay_status, claude_statusline::claude_limit_relay_set, + ide_window::ide_window_access, + ide_window::ide_window_request_access, + ide_window::ide_window_open_access_settings, menu_bar::menu_bar_refresh, watch_roots, quit_app, diff --git a/apps/companion/src/App.tsx b/apps/companion/src/App.tsx index b923854..0bedf25 100644 --- a/apps/companion/src/App.tsx +++ b/apps/companion/src/App.tsx @@ -10,6 +10,7 @@ import { updateTargets } from "./application/updates"; import { useClaudeLimitRelay } from "./application/useClaudeLimitRelay"; import { useCompanionSettings } from "./application/useCompanionSettings"; import { useConnections } from "./application/useConnections"; +import { useIdeWindowAccess } from "./application/useIdeWindowAccess"; import { useRepoNotes } from "./application/useRepoNotes"; import { useRuntimeSessions } from "./application/useRuntimeSessions"; import { useUpdates } from "./application/useUpdates"; @@ -132,6 +133,10 @@ export function App() { onError: showError, onStatus: showStatus, }); + const ideWindowAccess = useIdeWindowAccess(currentView === "settings", { + onError: showError, + onStatus: showStatus, + }); const { notes, saveNote } = useRepoNotes({ onError: showError, onStatus: showStatus, @@ -437,6 +442,12 @@ export function App() { onChange: (enabled) => void claudeLimitRelay.update(enabled), }} connections={connectionPanel(false)} + ideWindowAccess={{ + granted: ideWindowAccess.granted, + loading: ideWindowAccess.loading, + onRequest: () => void ideWindowAccess.request(), + onOpenSettings: () => void ideWindowAccess.openSettings(), + }} preferences={preferences} launchAtLogin={launchAtLogin} launchAtLoginLoading={launchAtLoginLoading} diff --git a/apps/companion/src/application/ideWindow.ts b/apps/companion/src/application/ideWindow.ts new file mode 100644 index 0000000..8bb128d --- /dev/null +++ b/apps/companion/src/application/ideWindow.ts @@ -0,0 +1,43 @@ +// The IDE window setting. When it is on and macOS granted the Accessibility +// permission, a window that Zed, VS Code, Cursor or Antigravity IDE opens for +// the IDE action moves to the screen Companion is on. The Rust side reads the +// `ideWindow` preference key each time the IDE action runs +// (src-tauri/src/ide_window.rs mirrors this shape). + +export type IdeWindowConfig = { + readonly enabled: boolean; +}; + +export const DEFAULT_IDE_WINDOW: IdeWindowConfig = { + enabled: false, +}; + +/** An unknown switch falls back to off. */ +export function sanitizeIdeWindowConfig(value: unknown): { + readonly config: IdeWindowConfig; + readonly sanitized: boolean; +} { + const input = + typeof value === "object" && value !== null + ? (value as Readonly>) + : {}; + const config: IdeWindowConfig = { + enabled: + typeof input["enabled"] === "boolean" + ? input["enabled"] + : DEFAULT_IDE_WINDOW.enabled, + }; + return { + config, + sanitized: + config.enabled !== input["enabled"] || + Object.keys(input).some((key) => key !== "enabled"), + }; +} + +export function sameIdeWindowConfig( + left: IdeWindowConfig, + right: IdeWindowConfig, +): boolean { + return left.enabled === right.enabled; +} diff --git a/apps/companion/src/application/preferences.ts b/apps/companion/src/application/preferences.ts index e25eeb3..a76ae1a 100644 --- a/apps/companion/src/application/preferences.ts +++ b/apps/companion/src/application/preferences.ts @@ -1,4 +1,10 @@ import { load } from "@tauri-apps/plugin-store"; +import { + DEFAULT_IDE_WINDOW, + type IdeWindowConfig, + sameIdeWindowConfig, + sanitizeIdeWindowConfig, +} from "./ideWindow"; import { DEFAULT_MENU_BAR, type MenuBarConfig, @@ -50,6 +56,7 @@ export type CompanionPreferences = { readonly theme: CompanionTheme; readonly menuBar: MenuBarConfig; readonly screensaver: ScreensaverConfig; + readonly ideWindow: IdeWindowConfig; }; export type CompanionFontOption = { @@ -74,6 +81,7 @@ export type PreferenceStoreEntries = { readonly theme: CompanionTheme; readonly menuBar: MenuBarConfig; readonly screensaver: ScreensaverConfig; + readonly ideWindow: IdeWindowConfig; }; export type CompanionPreferenceStore = { @@ -88,6 +96,7 @@ export const DEFAULT_COMPANION_PREFERENCES: CompanionPreferences = { theme: "claude", menuBar: DEFAULT_MENU_BAR, screensaver: DEFAULT_SCREENSAVER, + ideWindow: DEFAULT_IDE_WINDOW, }; export const COMPANION_FONT_OPTIONS: readonly CompanionFontOption[] = [ @@ -165,6 +174,7 @@ export function sanitizeCompanionPreferences(input: { readonly themeMode?: unknown; readonly menuBar?: unknown; readonly screensaver?: unknown; + readonly ideWindow?: unknown; }): PreferenceSanitizationResult { const font = isCompanionFont(input.font) ? input.font @@ -179,6 +189,7 @@ export function sanitizeCompanionPreferences(input: { : DEFAULT_COMPANION_PREFERENCES.theme; const menuBar = sanitizeMenuBarConfig(input.menuBar); const screensaver = sanitizeScreensaverConfig(input.screensaver); + const ideWindow = sanitizeIdeWindowConfig(input.ideWindow); return { preferences: { font, @@ -186,13 +197,15 @@ export function sanitizeCompanionPreferences(input: { theme, menuBar: menuBar.config, screensaver: screensaver.config, + ideWindow: ideWindow.config, }, sanitized: font !== input.font || fontSize !== input.fontSize || theme !== input.theme || menuBar.sanitized || - screensaver.sanitized, + screensaver.sanitized || + ideWindow.sanitized, }; } @@ -205,7 +218,8 @@ export function shouldRestoreFailedPreferenceUpdate( current.fontSize === attempted.fontSize && current.theme === attempted.theme && sameMenuBarConfig(current.menuBar, attempted.menuBar) && - sameScreensaverConfig(current.screensaver, attempted.screensaver) + sameScreensaverConfig(current.screensaver, attempted.screensaver) && + sameIdeWindowConfig(current.ideWindow, attempted.ideWindow) ); } @@ -227,6 +241,7 @@ export function preferencesToStoreEntries( theme: preferences.theme, menuBar: preferences.menuBar, screensaver: preferences.screensaver, + ideWindow: preferences.ideWindow, }; } @@ -248,6 +263,7 @@ export async function readCompanionPreferences( themeMode: await store.get("themeMode"), menuBar: await store.get("menuBar"), screensaver: await store.get("screensaver"), + ideWindow: await store.get("ideWindow"), }); } @@ -261,5 +277,6 @@ export async function writeCompanionPreferences( await store.set("theme", entries.theme); await store.set("menuBar", entries.menuBar); await store.set("screensaver", entries.screensaver); + await store.set("ideWindow", entries.ideWindow); await store.save(); } diff --git a/apps/companion/src/application/useIdeWindowAccess.ts b/apps/companion/src/application/useIdeWindowAccess.ts new file mode 100644 index 0000000..ad189bc --- /dev/null +++ b/apps/companion/src/application/useIdeWindowAccess.ts @@ -0,0 +1,86 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useState } from "react"; +import { + onWindowFocused, + openIdeWindowAccessSettings, + readIdeWindowAccess, + requestIdeWindowAccess, +} from "../infrastructure/tauriClient"; + +export type IdeWindowAccessState = { + /** `undefined` until the first read comes back. */ + readonly granted: boolean | undefined; + readonly loading: boolean; + readonly request: () => Promise; + readonly openSettings: () => Promise; +}; + +type Options = { + readonly onError: (error: unknown) => void; + readonly onStatus: (status: string) => void; +}; + +/** + * Reads the Accessibility permission each time Settings opens and each time + * the window comes back, because it is granted in System Settings, outside + * the app. + */ +export function useIdeWindowAccess( + active: boolean, + { onError, onStatus }: Options, +): IdeWindowAccessState { + const [granted, setGranted] = useState(); + const [loading, setLoading] = useState(false); + const tauriRuntimeAvailable = isTauri(); + + useEffect(() => { + if (!active || !tauriRuntimeAvailable) return; + let cancelled = false; + let stop: (() => void) | undefined; + const read = () => { + setLoading(true); + readIdeWindowAccess() + .then((access) => { + if (!cancelled) setGranted(access.granted); + }) + .catch((error: unknown) => { + if (!cancelled) onError(error); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + }; + read(); + void onWindowFocused(read).then((unlisten) => { + if (cancelled) unlisten(); + else stop = unlisten; + }); + return () => { + cancelled = true; + stop?.(); + }; + }, [active, onError, tauriRuntimeAvailable]); + + const request = useCallback(async () => { + if (!tauriRuntimeAvailable) { + onStatus("Tauri runtime unavailable"); + return; + } + try { + const access = await requestIdeWindowAccess(); + setGranted(access.granted); + } catch (error) { + onError(error); + } + }, [onError, onStatus, tauriRuntimeAvailable]); + + const openSettings = useCallback(async () => { + if (!tauriRuntimeAvailable) { + onStatus("Tauri runtime unavailable"); + return; + } + await openIdeWindowAccessSettings().catch(onError); + }, [onError, onStatus, tauriRuntimeAvailable]); + + return { granted, loading, request, openSettings }; +} diff --git a/apps/companion/src/infrastructure/tauriClient.ts b/apps/companion/src/infrastructure/tauriClient.ts index d8cc77c..ddc8be7 100644 --- a/apps/companion/src/infrastructure/tauriClient.ts +++ b/apps/companion/src/infrastructure/tauriClient.ts @@ -286,3 +286,21 @@ export async function setClaudeLimitRelay( ): Promise { return invoke("claude_limit_relay_set", { enabled }); } + +/** Whether macOS lets Companion move the IDE's new windows. */ +export type IdeWindowAccess = { + readonly granted: boolean; +}; + +export async function readIdeWindowAccess(): Promise { + return invoke("ide_window_access"); +} + +/** Shows macOS's Accessibility prompt unless the permission is granted. */ +export async function requestIdeWindowAccess(): Promise { + return invoke("ide_window_request_access"); +} + +export async function openIdeWindowAccessSettings(): Promise { + await invoke("ide_window_open_access_settings"); +} diff --git a/apps/companion/src/ui/IdeWindowSettings.tsx b/apps/companion/src/ui/IdeWindowSettings.tsx new file mode 100644 index 0000000..9d5fa4e --- /dev/null +++ b/apps/companion/src/ui/IdeWindowSettings.tsx @@ -0,0 +1,71 @@ +import type { IdeWindowConfig } from "../application/ideWindow"; +import { SettingsGroup, SettingsRow } from "./SettingsLayout"; + +/** The Accessibility permission, which macOS grants in System Settings. */ +export type IdeWindowAccessProps = { + /** `undefined` until the first read comes back. */ + readonly granted: boolean | undefined; + readonly loading: boolean; + /** Shows macOS's prompt, which also lists Companion in System Settings. */ + readonly onRequest: () => void; + readonly onOpenSettings: () => void; +}; + +type Props = IdeWindowAccessProps & { + readonly value: IdeWindowConfig; + readonly onChange: (config: IdeWindowConfig) => void; +}; + +function hint({ value, granted, loading }: Props): string { + if (!value.enabled) + return "Off: Zed, VS Code, Cursor and Antigravity IDE open a new window where they choose, often on the display they used last."; + if (granted === undefined) + return loading + ? "Checking the Accessibility permission" + : "The Accessibility permission could not be read"; + if (!granted) + return "Waiting for the Accessibility permission. Until it is on, the IDE places its windows itself."; + return "On: a window the IDE action opens moves to the middle of the screen Companion is on. Windows that were already open stay where they are."; +} + +/** + * Moving another app's window needs the Accessibility permission, so turning + * the switch on asks macOS for it; the switch alone changes nothing until + * the permission is granted. + */ +export function IdeWindowSettings(props: Props) { + const { value, onChange, granted, onRequest, onOpenSettings } = props; + return ( + + + { + const enabled = event.currentTarget.checked; + onChange({ ...value, enabled }); + if (enabled && granted !== true) onRequest(); + }} + role="switch" + type="checkbox" + /> + + {value.enabled && granted === false ? ( + + + + ) : null} + + ); +} diff --git a/apps/companion/src/ui/SettingsPanel.tsx b/apps/companion/src/ui/SettingsPanel.tsx index fce4bc7..a507d9d 100644 --- a/apps/companion/src/ui/SettingsPanel.tsx +++ b/apps/companion/src/ui/SettingsPanel.tsx @@ -10,6 +10,10 @@ import { type ClaudeLimitRelayProps, ClaudeLimitRelaySettings, } from "./ClaudeLimitRelaySettings"; +import { + type IdeWindowAccessProps, + IdeWindowSettings, +} from "./IdeWindowSettings"; import { MenuBarSettings } from "./MenuBarSettings"; import { ScreensaverSettings } from "./ScreensaverSettings"; import { @@ -28,6 +32,7 @@ type Props = { /** Update and connection status, shown first on General. */ readonly status?: ReactNode; readonly claudeLimitRelay: ClaudeLimitRelayProps; + readonly ideWindowAccess: IdeWindowAccessProps; readonly preferences: CompanionPreferences; readonly launchAtLogin: boolean; readonly launchAtLoginLoading: boolean; @@ -80,14 +85,17 @@ export function SettingsPanel(props: Props) { function generalPage({ status, claudeLimitRelay, + ideWindowAccess, + preferences, launchAtLogin, launchAtLoginLoading, onLaunchAtLoginChange, + onPreferencesChange, }: Props) { return ( {status} @@ -117,6 +125,13 @@ function generalPage({ /> + + onPreferencesChange({ ...preferences, ideWindow }) + } + value={preferences.ideWindow} + /> diff --git a/apps/companion/src/ui/SettingsView.tsx b/apps/companion/src/ui/SettingsView.tsx index e75c4ec..7df6e2d 100644 --- a/apps/companion/src/ui/SettingsView.tsx +++ b/apps/companion/src/ui/SettingsView.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import type { CompanionPreferences } from "../application/preferences"; import type { ClaudeLimitRelayProps } from "./ClaudeLimitRelaySettings"; +import type { IdeWindowAccessProps } from "./IdeWindowSettings"; import { type SettingsAttention, type SettingsCategory, @@ -16,6 +17,7 @@ type Props = { readonly status?: ReactNode; readonly connections?: ReactNode; readonly claudeLimitRelay: ClaudeLimitRelayProps; + readonly ideWindowAccess: IdeWindowAccessProps; readonly preferences: CompanionPreferences; readonly launchAtLogin: boolean; readonly launchAtLoginLoading: boolean; @@ -31,6 +33,7 @@ export function SettingsView({ status, connections, claudeLimitRelay, + ideWindowAccess, preferences, launchAtLogin, launchAtLoginLoading, @@ -51,6 +54,7 @@ export function SettingsView({ ({ load: vi.fn() })); describe("companion preferences", () => { @@ -48,6 +58,7 @@ describe("companion preferences", () => { theme: "claude", menuBar: DEFAULT_MENU_BAR, screensaver: DEFAULT_SCREENSAVER, + ideWindow: DEFAULT_IDE_WINDOW, }); }); @@ -80,6 +91,7 @@ describe("companion preferences", () => { theme: "claude", menuBar: DEFAULT_MENU_BAR, screensaver: DEFAULT_SCREENSAVER, + ideWindow: DEFAULT_IDE_WINDOW, }, sanitized: true, }); @@ -95,6 +107,7 @@ describe("companion preferences", () => { theme: "codex", menuBar: DEFAULT_MENU_BAR, screensaver: DEFAULT_SCREENSAVER, + ideWindow: DEFAULT_IDE_WINDOW, }, sanitized: true, }); @@ -114,6 +127,7 @@ describe("companion preferences", () => { theme: "codex", menuBar: DEFAULT_MENU_BAR, screensaver: DEFAULT_SCREENSAVER, + ideWindow: DEFAULT_IDE_WINDOW, }, sanitized: true, }); @@ -151,6 +165,7 @@ describe("companion preferences", () => { theme: "claude", menuBar: DEFAULT_MENU_BAR, screensaver: DEFAULT_SCREENSAVER, + ideWindow: DEFAULT_IDE_WINDOW, }, sanitized: true, }); @@ -165,6 +180,7 @@ describe("companion preferences", () => { theme: "codex", menuBar: CUSTOM_MENU_BAR, screensaver: CUSTOM_SCREENSAVER, + ideWindow: CUSTOM_IDE_WINDOW, }), ).toEqual({ preferences: { @@ -173,6 +189,7 @@ describe("companion preferences", () => { theme: "codex", menuBar: CUSTOM_MENU_BAR, screensaver: CUSTOM_SCREENSAVER, + ideWindow: CUSTOM_IDE_WINDOW, }, sanitized: false, }); @@ -227,6 +244,7 @@ describe("companion preferences", () => { theme: "claude", menuBar: DEFAULT_MENU_BAR, screensaver: DEFAULT_SCREENSAVER, + ideWindow: DEFAULT_IDE_WINDOW, } as const; const newerCurrent = { ...failedAttempt, theme: "codex" } as const; const newerSize = { ...failedAttempt, fontSize: "large" } as const; @@ -238,6 +256,10 @@ describe("companion preferences", () => { ...failedAttempt, screensaver: CUSTOM_SCREENSAVER, } as const; + const newerIdeWindow = { + ...failedAttempt, + ideWindow: CUSTOM_IDE_WINDOW, + } as const; expect( shouldRestoreFailedPreferenceUpdate(newerCurrent, failedAttempt), @@ -251,6 +273,9 @@ describe("companion preferences", () => { expect( shouldRestoreFailedPreferenceUpdate(newerScreensaver, failedAttempt), ).toBe(false); + expect( + shouldRestoreFailedPreferenceUpdate(newerIdeWindow, failedAttempt), + ).toBe(false); expect( shouldRestoreFailedPreferenceUpdate(failedAttempt, failedAttempt), ).toBe(true); @@ -281,13 +306,14 @@ describe("companion preferences", () => { expect(writes).toEqual(["font", "theme"]); }); - it("serializes only font, fontSize, theme, menuBar, and screensaver store keys", () => { + it("serializes only font, fontSize, theme, menuBar, screensaver, and ideWindow store keys", () => { const entries = preferencesToStoreEntries({ font: "menlo", fontSize: "large", theme: "codex", menuBar: CUSTOM_MENU_BAR, screensaver: CUSTOM_SCREENSAVER, + ideWindow: CUSTOM_IDE_WINDOW, }); expect(entries).toEqual({ @@ -296,6 +322,7 @@ describe("companion preferences", () => { theme: "codex", menuBar: CUSTOM_MENU_BAR, screensaver: CUSTOM_SCREENSAVER, + ideWindow: CUSTOM_IDE_WINDOW, }); expect(Object.keys(entries)).toEqual([ "font", @@ -303,6 +330,7 @@ describe("companion preferences", () => { "theme", "menuBar", "screensaver", + "ideWindow", ]); }); @@ -325,6 +353,7 @@ describe("companion preferences", () => { theme: "claude", menuBar: CUSTOM_MENU_BAR, screensaver: CUSTOM_SCREENSAVER, + ideWindow: CUSTOM_IDE_WINDOW, }); expect(writes).toEqual([ @@ -333,6 +362,7 @@ describe("companion preferences", () => { ["theme", "claude"], ["menuBar", CUSTOM_MENU_BAR], ["screensaver", CUSTOM_SCREENSAVER], + ["ideWindow", CUSTOM_IDE_WINDOW], ]); expect(saveCount).toBe(1); }); @@ -385,3 +415,44 @@ describe("screensaver preference", () => { ); }); }); + +describe("IDE window preference", () => { + it("defaults to off", () => { + expect(DEFAULT_IDE_WINDOW).toEqual({ enabled: false }); + expect(DEFAULT_COMPANION_PREFERENCES.ideWindow).toEqual(DEFAULT_IDE_WINDOW); + }); + + it("backfills the default on stores written before the switch existed", () => { + expect(sanitizeIdeWindowConfig(undefined)).toEqual({ + config: DEFAULT_IDE_WINDOW, + sanitized: true, + }); + }); + + it("keeps a valid config untouched", () => { + expect(sanitizeIdeWindowConfig(CUSTOM_IDE_WINDOW)).toEqual({ + config: CUSTOM_IDE_WINDOW, + sanitized: false, + }); + }); + + it("falls back to off for an unusable switch and drops unknown keys", () => { + expect(sanitizeIdeWindowConfig({ enabled: "yes" })).toEqual({ + config: { enabled: false }, + sanitized: true, + }); + expect(sanitizeIdeWindowConfig({ enabled: true, screen: 2 })).toEqual({ + config: { enabled: true }, + sanitized: true, + }); + }); + + it("compares configs by value", () => { + expect( + sameIdeWindowConfig(CUSTOM_IDE_WINDOW, { ...CUSTOM_IDE_WINDOW }), + ).toBe(true); + expect(sameIdeWindowConfig(CUSTOM_IDE_WINDOW, DEFAULT_IDE_WINDOW)).toBe( + false, + ); + }); +}); diff --git a/apps/companion/tests/screensaver-settings.test.tsx b/apps/companion/tests/screensaver-settings.test.tsx index 07f3c36..f40e568 100644 --- a/apps/companion/tests/screensaver-settings.test.tsx +++ b/apps/companion/tests/screensaver-settings.test.tsx @@ -6,6 +6,7 @@ const tauri = vi.hoisted(() => ({ invoke: vi.fn(), listen: vi.fn() })); vi.mock("@tauri-apps/api/core", () => ({ invoke: tauri.invoke })); vi.mock("@tauri-apps/api/event", () => ({ listen: tauri.listen })); +import { DEFAULT_IDE_WINDOW } from "../src/application/ideWindow"; import { DEFAULT_MENU_BAR } from "../src/application/menuBar"; import type { CompanionPreferences } from "../src/application/preferences"; import { @@ -69,6 +70,14 @@ const preferences: CompanionPreferences = { theme: "claude", menuBar: DEFAULT_MENU_BAR, screensaver: DEFAULT_SCREENSAVER, + ideWindow: DEFAULT_IDE_WINDOW, +}; + +const ideWindowAccess = { + granted: true, + loading: false, + onRequest: () => undefined, + onOpenSettings: () => undefined, }; describe("ScreensaverSettings", () => { @@ -122,6 +131,7 @@ describe("SettingsPanel screen saver section", () => { loading: false, onChange: () => undefined, }} + ideWindowAccess={ideWindowAccess} preferences={preferences} launchAtLogin={false} launchAtLoginLoading={false} @@ -144,6 +154,7 @@ describe("SettingsPanel screen saver section", () => { loading: false, onChange: () => undefined, }, + ideWindowAccess, preferences, launchAtLogin: false, launchAtLoginLoading: false, @@ -242,6 +253,7 @@ describe("Screen saver preview", () => { loading: false, onChange: () => undefined, }, + ideWindowAccess, preferences, launchAtLogin: false, launchAtLoginLoading: false, diff --git a/apps/companion/tests/settings-panel.test.tsx b/apps/companion/tests/settings-panel.test.tsx index f3d73d5..6372421 100644 --- a/apps/companion/tests/settings-panel.test.tsx +++ b/apps/companion/tests/settings-panel.test.tsx @@ -6,6 +6,10 @@ import { } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { + DEFAULT_IDE_WINDOW, + type IdeWindowConfig, +} from "../src/application/ideWindow"; import { DEFAULT_MENU_BAR, type MenuBarConfig, @@ -17,6 +21,10 @@ import type { } from "../src/application/preferences"; import { DEFAULT_SCREENSAVER } from "../src/application/screensaver"; import { AgentThemePicker } from "../src/ui/AgentThemePicker"; +import { + type IdeWindowAccessProps, + IdeWindowSettings, +} from "../src/ui/IdeWindowSettings"; import { MenuBarSettings } from "../src/ui/MenuBarSettings"; import { SegmentedControl } from "../src/ui/SettingsLayout"; import { @@ -86,6 +94,7 @@ const preferences: CompanionPreferences = { theme: "claude", menuBar: DEFAULT_MENU_BAR, screensaver: DEFAULT_SCREENSAVER, + ideWindow: DEFAULT_IDE_WINDOW, }; const relay = { @@ -94,6 +103,18 @@ const relay = { onChange: () => undefined, }; +function access( + granted: boolean | undefined, + calls: string[] = [], +): IdeWindowAccessProps { + return { + granted, + loading: false, + onRequest: () => calls.push("request"), + onOpenSettings: () => calls.push("open settings"), + }; +} + function panelProps( category: PreferenceCategory, overrides: { @@ -101,11 +122,13 @@ function panelProps( readonly launchAtLoginLoading?: boolean; readonly onLaunchAtLoginChange?: (enabled: boolean) => void; readonly onPreferencesChange?: (next: CompanionPreferences) => void; + readonly ideWindowAccess?: IdeWindowAccessProps; } = {}, ) { return { category, claudeLimitRelay: relay, + ideWindowAccess: overrides.ideWindowAccess ?? access(true), preferences: overrides.preferences ?? preferences, launchAtLogin: false, launchAtLoginLoading: overrides.launchAtLoginLoading ?? false, @@ -136,10 +159,15 @@ describe("SettingsPanel", () => { html.indexOf("Open at Login"), ); expect(html).toContain('aria-label="Startup"'); + expect(html).toContain('aria-label="IDE Windows"'); expect(html).toContain('aria-label="Claude Limits"'); expect(html).toContain('for="launch-at-login"'); + expect(html).toContain('for="ide-window-enabled"'); expect(html).toContain('for="claude-limit-relay"'); - expect(html.match(/role="switch"/g)).toHaveLength(2); + expect(html.indexOf('aria-label="Startup"')).toBeLessThan( + html.indexOf('aria-label="IDE Windows"'), + ); + expect(html.match(/role="switch"/g)).toHaveLength(3); expect(html).not.toContain(" { }); }); +describe("IdeWindowSettings", () => { + function render( + value: IdeWindowConfig, + granted: boolean | undefined, + loading = false, + ): string { + return renderToStaticMarkup( + undefined} + value={value} + />, + ); + } + + it("starts switched off and leaves window placement to the IDE", () => { + const html = render(DEFAULT_IDE_WINDOW, false); + + expect(html).toMatch(/id="ide-window-enabled"(?![^>]*checked)/); + expect(html).toContain("open a new window where they choose"); + expect(html).not.toContain("Open System Settings"); + }); + + it("explains the move once the switch is on and access is granted", () => { + const html = render({ enabled: true }, true); + + expect(html).toMatch(/id="ide-window-enabled"[^>]*checked=""/); + expect(html).toContain("moves to the middle of the screen Companion is on"); + expect(html).not.toContain("Open System Settings"); + }); + + it("offers System Settings while the switch waits for access", () => { + const html = render({ enabled: true }, false); + + expect(html).toContain("Waiting for the Accessibility permission"); + expect(html).toContain("Privacy & Security › Accessibility"); + expect(html).toContain(">Open System Settings"); + }); + + it("says when the permission is still being read", () => { + expect(render({ enabled: true }, undefined, true)).toContain( + "Checking the Accessibility permission", + ); + }); + + it("asks macOS for access when switched on without it", () => { + const changes: IdeWindowConfig[] = []; + const calls: string[] = []; + const element = IdeWindowSettings({ + ...access(false, calls), + onChange: (config) => changes.push(config), + value: DEFAULT_IDE_WINDOW, + }); + collect(element, "input") + .find((input) => input.id === "ide-window-enabled") + ?.onChange?.({ currentTarget: { checked: true } }); + + expect(changes).toEqual([{ enabled: true }]); + expect(calls).toEqual(["request"]); + }); + + it("does not ask again when access is granted or the switch turns off", () => { + const calls: string[] = []; + for (const [granted, value, checked] of [ + [true, DEFAULT_IDE_WINDOW, true], + [false, { enabled: true }, false], + ] as const) { + const element = IdeWindowSettings({ + ...access(granted, calls), + onChange: () => undefined, + value, + }); + collect(element, "input") + .find((input) => input.id === "ide-window-enabled") + ?.onChange?.({ currentTarget: { checked } }); + } + + expect(calls).toEqual([]); + }); + + it("opens System Settings from the waiting row", () => { + const calls: string[] = []; + const element = IdeWindowSettings({ + ...access(false, calls), + onChange: () => undefined, + value: { enabled: true }, + }); + collect(element, "button")[0]?.onClick?.(); + + expect(calls).toEqual(["open settings"]); + }); + + it("saves the switch through the General page", () => { + const preferenceCalls: CompanionPreferences[] = []; + const general = SettingsPanel( + panelProps("general", { + onPreferencesChange: (next) => { + preferenceCalls.push(next); + }, + }), + ); + collect<{ readonly onChange: (config: IdeWindowConfig) => void }>( + general, + IdeWindowSettings, + )[0]?.onChange({ enabled: true }); + + expect(preferenceCalls).toEqual([ + { ...preferences, ideWindow: { enabled: true } }, + ]); + }); +}); + describe("MenuBarSettings", () => { function render(value: MenuBarConfig): string { return renderToStaticMarkup( diff --git a/docs/usage.ko.md b/docs/usage.ko.md index 5ac0d0d..494de6d 100644 --- a/docs/usage.ko.md +++ b/docs/usage.ko.md @@ -138,6 +138,8 @@ workbranch terminal login # terminal로 task root 열기 내장 macOS IDE preset은 VS Code 계열 app에서 repo path마다 별도 IDE window를 엽니다. config directive는 `IDE `입니다. preset 순서는 Cursor, Antigravity, Windsurf, Zed, Sublime Text, Xcode, VS Code, Tern입니다. VS Code 계열 preset(Cursor, Antigravity, Windsurf, VS Code)은 `workbranch ide` 실행 시 설치된 app 안의 CLI(`.app/Contents/Resources/app/bin/`, `/Applications` 다음 `~/Applications` 순으로 탐색)를 ` --new-window `로 실행합니다. 해당 IDE가 이미 실행 중이면 CLI가 띄운 임시 helper 인스턴스가 종료된 뒤 `open -a .app`으로 앱을 앞으로 가져옵니다. 그 helper가 종료되면서 macOS 포커스가 호출한 쪽으로 돌아가고, 이미 IDE의 key window인 repo 창만 focus해서는 앱이 앞으로 나오지 않기 때문입니다. 이미 열린 repo는 기존 window가 focus되면서 IDE가 앞으로 나오고, 열리지 않은 repo는 새 window로 열리며, IDE가 실행 중이 아니면 새로 시작합니다. 이 경로는 번들 CLI가 끝나기를 기다린 뒤 활성화까지 확인하므로 `workbranch ide`가 repo당 약 3~4초 뒤에 반환됩니다. repo 창 자체는 약 1초 안에 나타납니다. 번들 CLI를 찾지 못하거나 실행 권한이 없으면 설정된 `open -na ... --args --new-window` 명령을 그대로 실행합니다. `IDE open -a Cursor`, `IDE open -a "Antigravity IDE"`, `IDE open -a "Visual Studio Code"`, `IDE open -a Windsurf` 형태도 같은 방식으로 보정됩니다. Zed preset은 `open -na Zed`를 저장하지만, `workbranch ide` 실행 시에는 Zed 번들 CLI(`Zed.app/Contents/MacOS/cli --classic `)를 repo마다 한 번씩 실행합니다. 그래서 multi-repo task의 각 repo가 각자의 Zed window로 열리고, 이미 Zed project root로 열린 repo는 기존 window가 focus됩니다. task root로 연 Zed window는 해당하지 않으므로 repo는 여전히 각자의 window로 열립니다. `--classic`은 `zed --help`에 나오지 않는 숨은 옵션이지만 Zed CLI가 받아들입니다(Zed 1.23.2에서 확인). `--classic` 없이는 Zed가 폴더를 현재 window sidebar에 추가하거나 여는 방식을 물을 수 있고, repo마다 `open -na Zed`를 실행하면 첫 repo만 남았습니다. 번들 CLI를 찾지 못하면 설정된 `open -na Zed` 명령을 그대로 실행합니다. Tern preset은 `open -a Tern`을 저장하며, Tern 인스턴스를 새로 띄우지 않고 실행 중인 Tern window에 repo path마다 tab을 추가합니다. +CLI는 window 위치를 IDE에 맡기고, Zed는 project window를 그 project가 마지막으로 있던 위치, 없으면 마지막 window 위치에 다시 열기 때문에 다른 display에 뜨는 경우가 많습니다. Settings › General › IDE Windows › Open on Companion's screen을 켜면, Companion의 IDE 액션으로 Zed, VS Code, Cursor, Antigravity IDE가 새로 연 window가 Companion이 떠 있는 화면 가운데로 옮겨지며, 크기는 유지하되 화면보다 크면 화면에 맞게 줄어듭니다. 이미 열려 있던 repo window는 있던 자리에서 focus만 됩니다. 이 스위치는 기본으로 꺼져 있습니다. 다른 app의 window를 옮기려면 macOS 손쉬운 사용(Accessibility) 권한이 필요하므로, 스위치를 켜면 macOS 권한 요청 창이 뜨고, 권한이 없는 동안 Settings에 System Settings의 개인정보 보호 및 보안 › 손쉬운 사용을 여는 버튼이 나옵니다. 둘 다 켜지기 전까지는 IDE가 window 위치를 스스로 정합니다. + 필요하면 repo 하나로 제한합니다. ```bash diff --git a/docs/usage.md b/docs/usage.md index c3112b6..34df0ee 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -138,6 +138,8 @@ workbranch terminal login # task root in the terminal Built-in macOS IDE presets open each repo path in a separate IDE window for VS Code-like apps. The config directive is `IDE `; preset order is Cursor, Antigravity, Windsurf, Zed, Sublime Text, Xcode, VS Code, then Tern. For the VS Code-like presets (Cursor, Antigravity, Windsurf, VS Code), `workbranch ide` runs the CLI bundled inside the installed app (`.app/Contents/Resources/app/bin/`, looked up under `/Applications` and then `~/Applications`) as ` --new-window `. When that IDE is already running, `open -a .app` then brings it to the front once the CLI's short-lived helper instance has exited, because that helper hands macOS focus back to the caller and focusing a repo window that is already the IDE's key window does not bring the app forward on its own. A repo that is already open is focused in its existing window and the IDE comes to the front; a repo that is not open gets a new window; an IDE that is not running is launched. This path waits for the bundled CLI and confirms the activation, so `workbranch ide` takes roughly three to four seconds per repo before it returns; the repo window itself appears within about a second. When the bundled CLI is not found or not executable, the configured `open -na ... --args --new-window` command runs instead. Existing `IDE open -a Cursor`, `IDE open -a "Antigravity IDE"`, `IDE open -a "Visual Studio Code"`, or `IDE open -a Windsurf` command shapes are normalized the same way. The Zed preset stores `open -na Zed`; at launch time `workbranch ide` runs Zed's bundled CLI (`Zed.app/Contents/MacOS/cli --classic `) once per repo, so each repo of a multi-repo task opens in its own Zed window and a repo that is already open as a Zed project root has its window focused; a Zed window opened at the task root does not count, so the repo still opens in its own window. `--classic` is hidden from `zed --help` but accepted by Zed's CLI (verified with Zed 1.23.2). Without `--classic`, Zed may add folders to the current window's sidebar or ask how to open them; `open -na Zed` per repo kept only the first repo. When the bundled CLI is not found, the configured `open -na Zed` command runs. The Tern preset stores `open -a Tern`, which adds a tab in each repo path to the running Tern window instead of starting another Tern instance. +The CLI leaves window placement to the IDE, and Zed reopens a project's window where it last was, or else where its last window was, often on another display. With Settings › General › IDE Windows › Open on Companion's screen switched on, each window that Zed, VS Code, Cursor or Antigravity IDE opens for the Companion's IDE action moves onto the screen the Companion is on, centered at its own size and shrunk to fit; a repo window that was already open is only focused where it is. The switch is off by default. Moving another app's window needs the macOS Accessibility permission, so switching it on shows macOS's permission prompt, and while the permission is missing Settings offers a button that opens Privacy & Security › Accessibility in System Settings. Until both are on, the IDE places its windows itself. + Limit to one repo when needed: ```bash From 28f2b817811f515730efab2d403b1fe483e76884 Mon Sep 17 00:00:00 2001 From: tkhwang Date: Sun, 18 Oct 2026 13:55:53 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(ide=5Fwindow):=20=EB=8A=A6=EA=B2=8C=20?= =?UTF-8?q?=EC=97=B4=EB=A6=B0=20=EC=B0=BD=20=EB=8C=80=EA=B8=B0=EC=99=80=20?= =?UTF-8?q?=EC=B0=BD=20=EC=9D=B4=EB=8F=99=20=EC=9E=AC=EC=8B=9C=EB=8F=84,?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=20=EC=83=81=ED=83=9C=20=EA=B2=BD=ED=95=A9?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 창이 늦게 열려도 다른 repo 창을 3초 동안 기다리도록, 10초 대기는 첫 창이 나오기 전에만 적용 - Accessibility 쓰기가 실패한 새 창을 처리 완료로 두지 않고 최대 5번 다시 옮기며, 재시도가 남아 있는 동안 추적을 이어감 - 권한 요청 응답보다 먼저 시작된 권한 읽기 결과가 나중에 도착하면 버림 Co-Authored-By: Claude Opus 5.5 --- apps/companion/README.md | 2 +- apps/companion/src-tauri/src/ide_window.rs | 68 +++++++++++++------ .../src-tauri/src/ide_window/macos.rs | 19 +++--- .../src-tauri/src/ide_window_tests.rs | 51 +++++++++++++- .../src/application/useIdeWindowAccess.ts | 42 ++++++++++-- .../companion/tests/ide-window-access.test.ts | 34 ++++++++++ 6 files changed, 180 insertions(+), 36 deletions(-) create mode 100644 apps/companion/tests/ide-window-access.test.ts diff --git a/apps/companion/README.md b/apps/companion/README.md index ccc0d24..9b7f1a9 100644 --- a/apps/companion/README.md +++ b/apps/companion/README.md @@ -10,7 +10,7 @@ Agent runtime state comes from `RuntimeService` (`src-tauri/src/runtime_service. The opt-in screensaver (`src-tauri/src/screensaver.rs` and `src-tauri/src/screensaver/`) keeps the Mac from idle sleep while agents work. When the macOS screen saver starts or the screen locks, it locks the Mac if needed, shows the dashboard (`dashboard.html`, its own Vite entry and `dashboard` capability) on every display above the lock screen, and keeps the display on; input moves the dashboard aside to reveal the lock screen. If locking is unavailable, the lock indicator is hidden and it never locks by itself. A pure state machine (`screensaver/machine.rs`) decides and a `Platform` trait acts, so transitions are unit-tested without the OS. It relies on private macOS APIs, `SACLockScreenImmediate`, a SkyLight space above the lock screen and the webview background that Tauri's `macOSPrivateApi` controls, so the app cannot ship through the Mac App Store. `WORKBRANCH_SCREENSAVER_NO_LOCK=1` starts it as if the lock were unavailable. See `docs/specs/comp/screensaver.md`. -The IDE action moves the windows the IDE opens onto the screen the companion is on (`src-tauri/src/ide_window.rs` and `src-tauri/src/ide_window/macos.rs`). Zed reopens a project's window where it last was, or else where its last window was, and none of the supported IDEs takes a target screen, so `workbranch_run` reads the companion window's monitor, notes every CoreGraphics window number (which covers every Space and needs no permission) before running `workbranch ide`, and then moves each new standard window of Zed, VS Code, Cursor or Antigravity IDE through the Accessibility API, once, centered at its size and shrunk to fit. `_AXUIElementGetWindow`, a private call probed at runtime, maps an Accessibility window to its window number. A repo window that was already open stays where it is. Following stops 10 seconds after the command ends if no new window appears, or 3 seconds after the later of the command's end and the last new window. Nothing moves until Settings › General › IDE Windows is switched on (the `ideWindow` preference, read on each IDE action) and macOS granted the Accessibility permission. Switching it on shows macOS's permission prompt; Settings reads the permission again each time it opens and, while it is missing, offers to open the Accessibility list in System Settings (`ide_window_access`, `ide_window_request_access`, `ide_window_open_access_settings`). Under `pnpm tauri dev` the companion runs as a plain binary under your terminal, so macOS checks and prompts for the terminal app's Accessibility permission instead; test this with the bundled app. Each ad-hoc signed local build counts as a new app to macOS, so remove WorkbranchCompanion from the Accessibility list and allow it again after rebuilding. +The IDE action moves the windows the IDE opens onto the screen the companion is on (`src-tauri/src/ide_window.rs` and `src-tauri/src/ide_window/macos.rs`). Zed reopens a project's window where it last was, or else where its last window was, and none of the supported IDEs takes a target screen, so `workbranch_run` reads the companion window's monitor, notes every CoreGraphics window number (which covers every Space and needs no permission) before running `workbranch ide`, and then moves each new standard window of Zed, VS Code, Cursor or Antigravity IDE through the Accessibility API, once, centered at its size and shrunk to fit. `_AXUIElementGetWindow`, a private call probed at runtime, maps an Accessibility window to its window number. A repo window that was already open stays where it is. Following stops 10 seconds after the command ends if no new window appears, or else 3 seconds after the later of the command's end and the last new window; a move the IDE does not take, such as an Accessibility call that times out while it is busy, is tried again up to five times. Nothing moves until Settings › General › IDE Windows is switched on (the `ideWindow` preference, read on each IDE action) and macOS granted the Accessibility permission. Switching it on shows macOS's permission prompt; Settings reads the permission again each time it opens and, while it is missing, offers to open the Accessibility list in System Settings (`ide_window_access`, `ide_window_request_access`, `ide_window_open_access_settings`). Under `pnpm tauri dev` the companion runs as a plain binary under your terminal, so macOS checks and prompts for the terminal app's Accessibility permission instead; test this with the bundled app. Each ad-hoc signed local build counts as a new app to macOS, so remove WorkbranchCompanion from the Accessibility list and allow it again after rebuilding. Agent usage comes from the Rust `usage_snapshot` command (`src-tauri/src/usage.rs`), which reads only files the agents already write: Claude Code transcripts under `~/.claude/projects` (each response counted once by `message.id` + `requestId`) and the `/usage` result Claude Code caches in `~/.claude.json` (or, when the opt-in Live Claude Limits relay in `src-tauri/src/claude_statusline.rs` is on, the newer `rate_limits` Claude Code passed to its status line, saved in the companion's data directory), plus Codex rollouts under `~/.codex/sessions` and `~/.codex/archived_sessions` (token deltas from `total_token_usage`, with a file's first event counted by its `last_token_usage` and identical events shared by forked rollouts counted once; cache reads and writes split out of input; limits from `rate_limits`, whose reset may be epoch seconds or an RFC3339 string). `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honored. It never signs in or touches the network, keeps parsed files in memory keyed by size and mtime, and returns 15-minute token buckets that the frontend folds into local days. Daily totals match ccusage. diff --git a/apps/companion/src-tauri/src/ide_window.rs b/apps/companion/src-tauri/src/ide_window.rs index f99a94f..ca34987 100644 --- a/apps/companion/src-tauri/src/ide_window.rs +++ b/apps/companion/src-tauri/src/ide_window.rs @@ -13,7 +13,7 @@ #[cfg(target_os = "macos")] mod macos; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::mpsc::{Receiver, TryRecvError}; use std::time::{Duration, Instant}; @@ -43,12 +43,15 @@ pub(crate) const IDE_BUNDLE_IDS: [&str; 6] = [ "com.google.antigravity-ide", ]; -/// How long after `workbranch ide` finishes a new window is still awaited: -/// long enough for an IDE that was not running to start. +/// How long after `workbranch ide` finishes the first new window is still +/// awaited: long enough for an IDE that was not running to start. const WAIT_FOR_FIRST: Duration = Duration::from_secs(10); /// Once a new window arrived and the command finished, how long to wait for -/// the windows of a task's other repos. +/// the windows of a task's other repos, however late the first one came. const WAIT_FOR_MORE: Duration = Duration::from_secs(3); +/// How many times a new window's move is tried. A busy IDE can let an +/// Accessibility call time out, and the next poll may get through. +const MAX_MOVE_ATTEMPTS: u32 = 5; /// Stops following even if the command never finishes. const MAX_FOLLOW: Duration = Duration::from_secs(120); const POLL: Duration = Duration::from_millis(100); @@ -149,45 +152,66 @@ pub(crate) trait Desktop { /// The supported IDEs' standard windows that can be moved now, so not /// minimized or full screen. fn ide_windows(&self) -> Vec>; - fn move_window(&self, handle: &Self::Handle, frame: Frame); + /// Returns whether the window took the new position. + fn move_window(&self, handle: &Self::Handle, frame: Frame) -> bool; } /// Moves each IDE window that did not exist when the command started onto /// the companion's screen, once, so the user can move it away again. pub(crate) struct Follower { screen: Frame, - /// Window numbers that existed before the command, or were handled. - seen: HashSet, + /// Window numbers that existed before the command, or are done: moved, + /// already on the screen, or out of attempts. + settled: HashSet, + /// New windows whose move failed, with the attempts made so far. + retrying: HashMap, } impl Follower { pub(crate) fn new(screen: Frame, existing: HashSet) -> Self { Self { screen, - seen: existing, + settled: existing, + retrying: HashMap::new(), } } - /// One look at the IDE windows; returns how many were new. + /// One look at the IDE windows; returns how many were new. A window + /// whose move failed is tried again on the next look, up to + /// `MAX_MOVE_ATTEMPTS` times, and counts as new only the first time. pub(crate) fn step(&mut self, desktop: &impl Desktop) -> usize { let mut new_windows = 0; for window in desktop.ide_windows() { - if !self.seen.insert(window.id) { + if self.settled.contains(&window.id) { continue; } - new_windows += 1; - if let Some(frame) = placement(window.frame, self.screen) { - desktop.move_window(&window.handle, frame); + let attempts = self.retrying.remove(&window.id).unwrap_or_else(|| { + new_windows += 1; + 0 + }) + 1; + let placed = placement(window.frame, self.screen) + .is_none_or(|frame| desktop.move_window(&window.handle, frame)); + if placed || attempts >= MAX_MOVE_ATTEMPTS { + self.settled.insert(window.id); + } else { + self.retrying.insert(window.id, attempts); } } new_windows } + + /// Whether a new window still waits for another try at its move. + pub(crate) fn retrying(&self) -> bool { + !self.retrying.is_empty() + } } /// Whether to keep looking for new windows, given the time since the /// command finished (`None` while it runs) and since the last new window -/// (`None` before the first). A window can open before the command ends, -/// so the quiet time counts from whichever came later. +/// (`None` before the first). Before the first window an IDE that is still +/// starting gets `WAIT_FOR_FIRST`; after it, the other repos' windows get +/// `WAIT_FOR_MORE` of quiet, counted from whichever came later, since a +/// window can open before the command ends. pub(crate) fn keep_following( since_finished: Option, since_new_window: Option, @@ -195,12 +219,15 @@ pub(crate) fn keep_following( let Some(since_finished) = since_finished else { return true; }; - since_finished < WAIT_FOR_FIRST - && since_new_window.is_none_or(|since_new| since_new.min(since_finished) < WAIT_FOR_MORE) + match since_new_window { + None => since_finished < WAIT_FOR_FIRST, + Some(since_new) => since_new.min(since_finished) < WAIT_FOR_MORE, + } } -/// Looks for new IDE windows until `keep_following` says to stop. The -/// command counts as finished once `command_running` disconnects. +/// Looks for new IDE windows until `keep_following` says to stop and no +/// move waits for another try. The command counts as finished once +/// `command_running` disconnects. pub(crate) fn run(desktop: &impl Desktop, mut follower: Follower, command_running: &Receiver<()>) { let started = Instant::now(); let mut finished_at = None; @@ -217,7 +244,8 @@ pub(crate) fn run(desktop: &impl Desktop, mut follower: Follower, command_runnin let now = Instant::now(); let since = |at: Instant| now.duration_since(at); if since(started) >= MAX_FOLLOW - || !keep_following(finished_at.map(since), new_window_at.map(since)) + || !(keep_following(finished_at.map(since), new_window_at.map(since)) + || follower.retrying()) { return; } diff --git a/apps/companion/src-tauri/src/ide_window/macos.rs b/apps/companion/src-tauri/src/ide_window/macos.rs index 442ecea..138d13f 100644 --- a/apps/companion/src-tauri/src/ide_window/macos.rs +++ b/apps/companion/src-tauri/src/ide_window/macos.rs @@ -153,7 +153,7 @@ impl Desktop for MacDesktop { .collect() } - fn move_window(&self, window: &Self::Handle, frame: Frame) { + fn move_window(&self, window: &Self::Handle, frame: Frame) -> bool { let origin = CGPoint { x: frame.x, y: frame.y, @@ -164,10 +164,11 @@ impl Desktop for MacDesktop { }; // Moving first lets the window take a size that only fits on the // new screen; moving again settles an origin macOS adjusted for the - // old size. - write_value(window, "AXPosition", origin); - write_value(window, "AXSize", size); - write_value(window, "AXPosition", origin); + // old size. A window that cannot be resized keeps its size, so only + // the moves decide whether it got there. + let moved = write_value(window, "AXPosition", origin); + let _resized = write_value(window, "AXSize", size); + moved && write_value(window, "AXPosition", origin) } } @@ -296,16 +297,18 @@ fn read_value(element: &CFType, name: &'static str, mut out: T) - (read != 0).then_some(out) } -fn write_value(element: &CFType, name: &'static str, geometry: T) { +/// Returns whether the app took the value. +fn write_value(element: &CFType, name: &'static str, geometry: T) -> bool { // SAFETY: `T::KIND` names the C type of `T`, which the call copies. let value = unsafe { AXValueCreate(T::KIND, (&raw const geometry).cast()) }; let Some(value) = NonNull::new(value) else { - return; + return false; }; // SAFETY: AXValueCreate returns a new reference. let value = unsafe { CFRetained::from_raw(value) }; // SAFETY: a live element, an attribute name and an AXValue of the type // the attribute takes. - let _error = + let error = unsafe { AXUIElementSetAttributeValue(element, &CFString::from_static_str(name), &value) }; + error == AX_SUCCESS } diff --git a/apps/companion/src-tauri/src/ide_window_tests.rs b/apps/companion/src-tauri/src/ide_window_tests.rs index 9e515eb..077d1b1 100644 --- a/apps/companion/src-tauri/src/ide_window_tests.rs +++ b/apps/companion/src-tauri/src/ide_window_tests.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::time::Duration; @@ -32,6 +32,9 @@ const EXTERNAL: Frame = Frame { struct FakeDesktop { windows: RefCell>, moves: RefCell>, + /// How many of the next moves fail, as when a busy IDE lets the + /// Accessibility call time out. + failing_moves: Cell, } impl FakeDesktop { @@ -59,8 +62,11 @@ impl Desktop for FakeDesktop { .collect() } - fn move_window(&self, handle: &u32, frame: Frame) { + fn move_window(&self, handle: &u32, frame: Frame) -> bool { self.moves.borrow_mut().push((*handle, frame)); + let failing = self.failing_moves.get(); + self.failing_moves.set(failing.saturating_sub(1)); + failing == 0 } } @@ -194,6 +200,14 @@ fn following_waits_for_a_starting_ide_until_the_limit() { assert!(!keep_following(Some(seconds(10.0)), None)); } +#[test] +fn a_late_first_window_still_gives_the_other_repos_their_quiet_time() { + // The first window came 9.5 seconds after the command; the next one may + // open past the 10-second wait for the first. + assert!(keep_following(Some(seconds(11.0)), Some(seconds(1.5)))); + assert!(!keep_following(Some(seconds(12.5)), Some(seconds(3.0)))); +} + #[test] fn following_stops_once_new_windows_stop_arriving() { assert!(keep_following(Some(seconds(5.0)), Some(seconds(2.9)))); @@ -239,3 +253,36 @@ fn the_settings_switch_is_on_only_when_stored_as_true() { // Stores from before the switch existed have no key at all. assert!(!enabled_in_store_value(None)); } + +#[test] +fn follower_tries_a_failed_move_again_without_counting_the_window_twice() { + let desktop = FakeDesktop::default(); + desktop.failing_moves.set(1); + desktop.open(7, frame(-2400.0, -300.0, 1200.0, 800.0)); + let mut follower = Follower::new(LAPTOP, HashSet::new()); + + assert_eq!(follower.step(&desktop), 1); + assert!(follower.retrying()); + assert_eq!(follower.step(&desktop), 0); + assert!(!follower.retrying()); + // Moved now, so a later look leaves it alone. + assert_eq!(follower.step(&desktop), 0); + + let target = frame(156.0, 108.0, 1200.0, 800.0); + assert_eq!(desktop.moves(), vec![(7, target), (7, target)]); +} + +#[test] +fn follower_gives_up_on_a_window_that_never_moves() { + let desktop = FakeDesktop::default(); + desktop.failing_moves.set(u32::MAX); + desktop.open(7, frame(-2400.0, -300.0, 1200.0, 800.0)); + let mut follower = Follower::new(LAPTOP, HashSet::new()); + + for _ in 0..10 { + follower.step(&desktop); + } + + assert!(!follower.retrying()); + assert_eq!(desktop.moves().len(), 5); +} diff --git a/apps/companion/src/application/useIdeWindowAccess.ts b/apps/companion/src/application/useIdeWindowAccess.ts index ad189bc..a5a173c 100644 --- a/apps/companion/src/application/useIdeWindowAccess.ts +++ b/apps/companion/src/application/useIdeWindowAccess.ts @@ -1,5 +1,5 @@ import { isTauri } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { onWindowFocused, openIdeWindowAccessSettings, @@ -20,6 +20,36 @@ type Options = { readonly onStatus: (status: string) => void; }; +export type AccessAnswers = { + /** Call as a read starts; the returned function applies its answer. */ + readonly read: () => (granted: boolean) => void; + /** Applies a request's answer and outdates the reads still running. */ + readonly request: (granted: boolean) => void; +}; + +/** + * Tauri does not order the answers of separate calls, so a read that + * started before a request can answer after it with the older state. Such a + * read is dropped. + */ +export function accessAnswers( + apply: (granted: boolean) => void, +): AccessAnswers { + let generation = 0; + return { + read: () => { + const started = generation; + return (granted) => { + if (started === generation) apply(granted); + }; + }, + request: (granted) => { + generation += 1; + apply(granted); + }, + }; +} + /** * Reads the Accessibility permission each time Settings opens and each time * the window comes back, because it is granted in System Settings, outside @@ -31,6 +61,7 @@ export function useIdeWindowAccess( ): IdeWindowAccessState { const [granted, setGranted] = useState(); const [loading, setLoading] = useState(false); + const answers = useMemo(() => accessAnswers(setGranted), []); const tauriRuntimeAvailable = isTauri(); useEffect(() => { @@ -38,10 +69,11 @@ export function useIdeWindowAccess( let cancelled = false; let stop: (() => void) | undefined; const read = () => { + const answer = answers.read(); setLoading(true); readIdeWindowAccess() .then((access) => { - if (!cancelled) setGranted(access.granted); + if (!cancelled) answer(access.granted); }) .catch((error: unknown) => { if (!cancelled) onError(error); @@ -59,7 +91,7 @@ export function useIdeWindowAccess( cancelled = true; stop?.(); }; - }, [active, onError, tauriRuntimeAvailable]); + }, [active, answers, onError, tauriRuntimeAvailable]); const request = useCallback(async () => { if (!tauriRuntimeAvailable) { @@ -68,11 +100,11 @@ export function useIdeWindowAccess( } try { const access = await requestIdeWindowAccess(); - setGranted(access.granted); + answers.request(access.granted); } catch (error) { onError(error); } - }, [onError, onStatus, tauriRuntimeAvailable]); + }, [answers, onError, onStatus, tauriRuntimeAvailable]); const openSettings = useCallback(async () => { if (!tauriRuntimeAvailable) { diff --git a/apps/companion/tests/ide-window-access.test.ts b/apps/companion/tests/ide-window-access.test.ts new file mode 100644 index 0000000..29591b8 --- /dev/null +++ b/apps/companion/tests/ide-window-access.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { accessAnswers } from "../src/application/useIdeWindowAccess"; + +describe("accessAnswers", () => { + it("applies a read that nothing overtook", () => { + const applied: boolean[] = []; + const answers = accessAnswers((granted) => applied.push(granted)); + + answers.read()(true); + + expect(applied).toEqual([true]); + }); + + it("drops a read that started before a request answered", () => { + const applied: boolean[] = []; + const answers = accessAnswers((granted) => applied.push(granted)); + + const staleRead = answers.read(); + answers.request(true); + staleRead(false); + + expect(applied).toEqual([true]); + }); + + it("applies reads that start after the request", () => { + const applied: boolean[] = []; + const answers = accessAnswers((granted) => applied.push(granted)); + + answers.request(false); + answers.read()(true); + + expect(applied).toEqual([false, true]); + }); +});