chore(repo): combine the kfw and kfm stacks for the e2e gate

Candidate for the Windows e2e gate: the Windows Backend switches and lock order (wip-u-kfw) with the macOS registry and Window methods (wip-u-kfm). Merge of two independent stacks that both touch crates/komorebi-backend; conflicts, if any, are resolved in this revision.
This commit is contained in:
LGUG2Z committed 2026-10-31 10:55:01 -07:00
commit b65a32446e
16 files changed
+655 -107

No files matched your search

+2
View File
@@ -31,6 +31,7 @@ mod error;
pub mod fake;
mod id;
mod info;
mod registry;
pub use backend::Backend;
pub use backend::Batch;
@@ -41,4 +42,5 @@ pub use id::MonitorId;
pub use id::WindowId;
pub use info::MonitorInfo;
pub use info::WindowInfo;
pub use registry::Registry;
pub use komorebi_layouts::Rect;
+312
View File
@@ -0,0 +1,312 @@
//! A thread-safe table from a [`WindowId`] to whatever a platform needs in order to act on that
//! window from nothing but the id.
//!
//! macOS is the platform that needs it: a window there is an Accessibility element with the pid
//! of its application and the observer that reports its events, and nothing can be done to a
//! window without them. The table is generic over the handle (`H`) so that its rules can be
//! tested on any host with a stand-in handle; macOS instantiates it with its Accessibility types.
//!
//! # Rules
//!
//! 1. **Thread-safe.** Any thread may call any method.
//! 2. **Nothing of the platform runs under the lock.** [`Registry::get`] clones the handle (the
//! handle must be cheap to clone: a retained reference) and returns; the caller acts on the
//! clone with the lock released. A method that removes an entry hands the removed handle back
//! to the caller instead of dropping it inside, because dropping a macOS handle can make an
//! Accessibility round trip. This is what keeps a registry call from ever waiting on the main
//! queue, or on an application, while holding the lock (`docs/dev/seam.md` section 3.3).
//! 3. **A stale id is an error, not a panic.** Looking up an id that was never registered, was
//! removed, or was removed with its application answers [`Error::WindowGone`].
//! 4. **Entries end** when the window is destroyed ([`Registry::remove`]), when its application
//! quits ([`Registry::remove_pid`]), and when a call on the window shows that it is gone. An
//! entry that nobody removes is a leak of a retained element; the platform removes at every
//! place it learns of a death.
//! 5. **Re-registering an id replaces the entry** and returns the old handle (a tab that is
//! now shown by another element keeps the window id).
//!
//! ```
//! use komorebi_backend::Registry;
//! use komorebi_backend::WindowId;
//!
//! let registry: Registry<&'static str> = Registry::new();
//! let id = WindowId::from_u32(7);
//! registry.insert(id, 100, "element");
//! assert_eq!(registry.get(id).unwrap(), "element");
//!
//! registry.remove(id);
//! assert!(registry.get(id).unwrap_err().is_window_gone());
//! ```
use crate::Error;
use crate::Result;
use crate::WindowId;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::MutexGuard;
struct Entry<H> {
pid: i32,
handle: H,
}
/// The table. See the [module documentation](self) for its rules.
pub struct Registry<H> {
entries: Mutex<HashMap<WindowId, Entry<H>>>,
}
impl<H> Default for Registry<H> {
fn default() -> Self {
Self::new()
}
}
impl<H> Registry<H> {
/// Returns an empty registry.
pub fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
}
}
// a thread that panicked while holding the lock cannot have left an entry half written (every
// method is a single map operation), so a poisoned lock is still good
fn lock(&self) -> MutexGuard<'_, HashMap<WindowId, Entry<H>>> {
self.entries.lock().unwrap_or_else(|e| e.into_inner())
}
/// Registers the window, owned by the process `pid`. Returns the handle that this replaces,
/// if the id was registered: the caller drops it, with no lock held.
#[must_use = "drop the replaced handle yourself: it is not dropped under the lock"]
pub fn insert(&self, id: WindowId, pid: i32, handle: H) -> Option<H> {
self.lock().insert(id, Entry { pid, handle }).map(|e| e.handle)
}
/// Replaces the handle of a window that is registered, keeping its pid. Returns the old
/// handle, or [`Error::WindowGone`] (and registers nothing) if the id is not registered.
pub fn replace(&self, id: WindowId, handle: H) -> Result<H> {
match self.lock().get_mut(&id) {
Some(entry) => Ok(std::mem::replace(&mut entry.handle, handle)),
None => Err(Error::WindowGone(id)),
}
}
/// Forgets the window. Returns its handle (for the caller to drop with no lock held), or
/// `None` if it was not registered, which is not an error: a death is usually seen twice.
#[must_use = "drop the removed handle yourself: it is not dropped under the lock"]
pub fn remove(&self, id: WindowId) -> Option<H> {
self.lock().remove(&id).map(|e| e.handle)
}
/// Forgets every window of the process, as when its application quits. Returns the ids and
/// handles removed, sorted by id.
#[must_use = "drop the removed handles yourself: they are not dropped under the lock"]
pub fn remove_pid(&self, pid: i32) -> Vec<(WindowId, H)> {
let mut entries = self.lock();
let ids: Vec<WindowId> = entries
.iter()
.filter(|(_, e)| e.pid == pid)
.map(|(id, _)| *id)
.collect();
let mut removed: Vec<(WindowId, H)> = ids
.into_iter()
.filter_map(|id| entries.remove(&id).map(|e| (id, e.handle)))
.collect();
drop(entries);
removed.sort_by_key(|(id, _)| *id);
removed
}
/// Whether the id is registered.
pub fn contains(&self, id: WindowId) -> bool {
self.lock().contains_key(&id)
}
/// The pid of the registered window, or [`Error::WindowGone`].
pub fn pid(&self, id: WindowId) -> Result<i32> {
self.lock()
.get(&id)
.map(|e| e.pid)
.ok_or(Error::WindowGone(id))
}
/// How many windows are registered.
pub fn len(&self) -> usize {
self.lock().len()
}
/// Whether nothing is registered.
pub fn is_empty(&self) -> bool {
self.lock().is_empty()
}
/// The registered ids, sorted.
pub fn ids(&self) -> Vec<WindowId> {
let mut ids: Vec<_> = self.lock().keys().copied().collect();
ids.sort();
ids
}
}
impl<H: Clone> Registry<H> {
/// A clone of the handle of the window, or [`Error::WindowGone`] for an id that is not
/// registered. The lock is released before this returns.
pub fn get(&self, id: WindowId) -> Result<H> {
self.lock()
.get(&id)
.map(|e| e.handle.clone())
.ok_or(Error::WindowGone(id))
}
/// Clones of every handle with their ids, sorted by id.
pub fn snapshot(&self) -> Vec<(WindowId, H)> {
let mut all: Vec<_> = self
.lock()
.iter()
.map(|(id, e)| (*id, e.handle.clone()))
.collect();
all.sort_by_key(|(id, _)| *id);
all
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
fn id(n: u32) -> WindowId {
WindowId::from_u32(n)
}
#[test]
fn a_registered_window_is_found_by_its_id() {
let r = Registry::new();
assert!(r.insert(id(1), 10, "a").is_none());
assert!(matches!(r.get(id(1)), Ok("a")));
assert!(matches!(r.pid(id(1)), Ok(10)));
assert!(r.contains(id(1)));
}
#[test]
fn an_unknown_id_is_window_gone_and_not_a_panic() {
let r: Registry<&str> = Registry::new();
assert!(matches!(r.get(id(9)), Err(Error::WindowGone(w)) if w == id(9)));
assert!(matches!(r.pid(id(9)), Err(Error::WindowGone(_))));
assert!(r.get(id(9)).unwrap_err().is_window_gone());
}
#[test]
fn a_removed_window_is_stale() {
let r = Registry::new();
let _ = r.insert(id(1), 10, "a");
assert_eq!(r.remove(id(1)), Some("a"));
assert!(r.get(id(1)).unwrap_err().is_window_gone());
// a death seen twice is not an error
assert_eq!(r.remove(id(1)), None);
}
#[test]
fn reinserting_replaces_and_hands_back_the_old_handle() {
let r = Registry::new();
let _ = r.insert(id(1), 10, "tab one");
assert_eq!(r.insert(id(1), 10, "tab two"), Some("tab one"));
assert!(matches!(r.get(id(1)), Ok("tab two")));
assert_eq!(r.len(), 1);
}
#[test]
fn replace_keeps_the_pid_and_refuses_a_stale_id() {
let r = Registry::new();
let _ = r.insert(id(1), 10, "a");
assert!(matches!(r.replace(id(1), "b"), Ok("a")));
assert!(matches!(r.pid(id(1)), Ok(10)));
assert!(r.replace(id(2), "c").unwrap_err().is_window_gone());
assert!(!r.contains(id(2)));
}
#[test]
fn an_application_quitting_takes_all_its_windows_and_only_them() {
let r = Registry::new();
let _ = r.insert(id(3), 10, "c");
let _ = r.insert(id(1), 10, "a");
let _ = r.insert(id(2), 20, "b");
assert_eq!(r.remove_pid(10), vec![(id(1), "a"), (id(3), "c")]);
assert_eq!(r.ids(), vec![id(2)]);
assert!(r.get(id(1)).unwrap_err().is_window_gone());
assert_eq!(r.remove_pid(10), vec![]);
}
#[test]
fn snapshot_is_sorted_and_independent_of_later_changes() {
let r = Registry::new();
let _ = r.insert(id(2), 1, "b");
let _ = r.insert(id(1), 1, "a");
let snap = r.snapshot();
let _ = r.remove(id(1));
assert_eq!(snap, vec![(id(1), "a"), (id(2), "b")]);
}
/// A handle whose drop would, on macOS, make a round trip: it must not run under the lock.
struct Probe {
registry: Arc<Registry<Arc<Probe>>>,
drops: Arc<AtomicUsize>,
}
impl Drop for Probe {
fn drop(&mut self) {
// would deadlock if the registry dropped the handle while holding its lock
let _ = self.registry.len();
self.drops.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn handles_are_never_dropped_under_the_lock() {
let registry = Arc::new(Registry::new());
let drops = Arc::new(AtomicUsize::new(0));
let probe = |_: u32| {
Arc::new(Probe {
registry: registry.clone(),
drops: drops.clone(),
})
};
let _ = registry.insert(id(1), 10, probe(1));
// the replaced and the removed handles come back to us, so their drop runs here
drop(registry.insert(id(1), 10, probe(2)));
drop(registry.remove(id(1)));
let _ = registry.insert(id(2), 10, probe(3));
drop(registry.remove_pid(10));
assert_eq!(drops.load(Ordering::SeqCst), 3);
assert!(registry.is_empty());
}
#[test]
fn many_threads_can_register_look_up_and_remove() {
let r = Arc::new(Registry::new());
let threads: Vec<_> = (0..8u32)
.map(|t| {
let r = r.clone();
std::thread::spawn(move || {
for n in 0..200u32 {
let wid = id(t * 1000 + n);
let _ = r.insert(wid, t as i32, n);
assert!(matches!(r.get(wid), Ok(v) if v == n));
assert_eq!(r.remove(wid), Some(n));
assert!(r.get(wid).unwrap_err().is_window_gone());
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
assert!(r.is_empty());
}
}
+73
View File
@@ -0,0 +1,73 @@
# macOS hand-check list
Status: written by the `kfm` agent for the user, 2026-10-31. Nothing here has been run: macOS cannot be built, linked or tested on the Linux host, only type-checked through a fake C compiler (`tools/fast-check`). Every revision below is `type-checks, not run`. Revision ids are jj change ids (stable) with the commit id they had when this was written (commit ids change when a revision is rewritten; use `jj log -r <change id>`).
How to use it: build the unified (or, until they exist, the per-tree) macOS `komorebi`, `komorebic` and `komorebi-bar`, run `cargo test --workspace` on the Mac first (the 399+ unit tests have never run against these changes), then walk the sections in order. For before/after capture use `komorebi-recorder` against a build of the baseline `wip-main` ancestor `ff3711dc` and the tip. A section is done when the "look for" column is true.
## A. Revisions that change what the daemon does (BEHAVIOUR)
| Revision | What changed | Look for on a Mac |
|---|---|---|
| `ummqkuvp` 01b9cbed `fix(mac): take the rule-list locks in one order...` | `window_is_eligible` (window.rs) and `apply_globals` (static_config.rs) now lock floating, ignore, manage, regex in that order; before they locked regex, ignore, manage and ignore, regex, manage. The order is documented at the statics in lib.rs. Same fix as the KDE `ef1d3ef0`. | Run `komorebic reload-configuration` (and `komorebic replace-configuration`) in a loop of 50 while opening and closing windows (new Finder windows, Terminal tabs) at the same time. Before the fix this could hang the daemon (the command is never answered, new windows are not tiled). Expected: no hang, every reload answered. Also check that float rules, ignore rules and manage rules from the config and from `komorebic float-rule`/`ignore-rule`/`manage-rule` still apply to new windows. |
| `nvwvptpv` 866b2840 `use the shared command listener` | The UDS command accept loop and per-client reader are the shared `komorebi-transport` code (`NoPolicy`: no read timeouts, as before); `WindowManager` is a `CommandTarget` (lock with `try_lock_for`). | Every `komorebic` command is answered; a command sent while the daemon is busy gets an answer or a clean error, not a hang; kill the daemon mid-command: the client returns. |
| `ywwwwpwm` 587defe3 `use the shared pause filter` | `allowed_while_paused` lists the commands allowed while paused. | `komorebic toggle-pause`; while paused only `toggle-pause`, `state`, `global-state`, `stop` work, everything else is ignored (and the connection ends); unpause and check windows tile again. |
| `kzynysto` b85827e6 `use the shared subscriber push` | Subscriber sockets are pushed to by the shared `notify_sockets`; the macOS override events are kept. | `komorebic subscribe-socket` (and `subscribe-socket-with-options`): events arrive for focus, move, workspace change, monitor change, theme and config reload; a subscriber that goes away is dropped without breaking the others. The bar is a subscriber, so the bar updating is the check. |
| `ovsplvmn` 5cc38da6 `use EventChannel for the window events` | The window-event channel (capacity 20, try_send, drop warning, `ResizeStep` claim flag) is `komorebi_transport::EventChannel`. | Drag a window edge continuously (live resize) and pan with the trackpad: no stalls, no repeated drop warnings in the log, the layout follows. Move many windows at once (workspace switch with 10+ windows). |
| `lrqkuuzq` a4996ac6 `use SocketMessage from komorebi-protocol` and `sowrttxs` 5c129ab5 `carry DebugWindow as i64` | One `SocketMessage` for all platforms; the wire format must be unchanged. `DebugWindow` takes an i64 (a negative id parses and matches nothing). | Every command of the `komorebic` help works against the daemon; `komorebic debug-window <id>` for a real id; `komorebic state` and `global-state` JSON parse. |
| `qqwoptqt` 975f007a, `yptnnkxv` eb903bcc (animation prefix, Bezier doc) | `AnimationPrefix` gains `Transparency` (ignored by the daemon); schema.json changed the shape of per-prefix animation settings. | Config with `animation.style` / `duration` per prefix (movement, workspace) still loads; animations behave as before. Run `komorebic static-config-schema` and diff with `komorebi-for-mac/schema.json` (two description-level changes and the per-prefix shape are intended). |
| `kvtvnouo` 93e4d511, `stxmqnop` dec91984 (StaticConfig from komorebi-config) | The config data types and their defaults come from `komorebi-config`; `komorebic static-config-schema` prints the macOS view. | A real `komorebi.json` of yours loads with no new warning and every key still takes effect (borders, padding, animations, scrolling, rules, workspaces); `komorebic static-config-schema` matches the checked-in `schema.json` up to the intended description lines. |
| `qukywpss` c46320ea, `zwkwptox` 1e902542, `sxptsttt` 0345fccb (komorebic shares clap definitions, launcher, pan) | `komorebic start` and `stop` use the shared Launcher skeleton (the odd `--config` arguments are kept; "Waiting for komorebi to start..." is flushed); pan commands use the shared module. | `komorebic start --bar`, `start --await-configuration`, `stop`, `stop --bar`; `komorebic pan-*` commands; `komorebic --help` and a few `<command> --help` texts (about 15 help texts changed to the Windows wording). |
| `oywqqkuy` f0b1a448 `register every Window in ax_registry and forget the dead` | `Window::new` registers the window in the id registry (`ax_registry`); entries are removed in `Window::drop` of a dead window, in `reap_window`, and (`forget_pid`) when an application terminates. No AX call is added; the table holds retained AX references and observers. | Open and close 50 windows (Finder, Terminal, Safari) and quit several apps, then check the daemon's memory in Activity Monitor does not climb and nothing behaves differently (focus, tiling, borders). If a quit app's windows were not forgotten the symptom is memory growth only. Run with `RUST_LOG=debug` and check no new errors at app quit. |
| `oqnvyqvw` e0687167 `keep ax_registry in step when a native tab changes a window's id` | The two places that swap a window's id and element in place for native tabs also update the registry (`sync`). | A native-tabbed app (Terminal, Finder, Safari tabs): switch tabs, close the first tab, open a new tab, focus the window with `komorebic focus ...`, move and resize it. Must behave as on the baseline (no window lost, no stuck border). |
| Bar: `nottqpms` 086833af, `zvuyspku` b8de056b, `uxzpkmnk` 7cd85bd4, `yopuwouu` 6da27dda (bar-core, platform hooks) | macOS now builds `sysinfo` 0.39 and `starship-battery` 0.11 (the Windows versions); cpu, battery, memory, date, update widgets are the shared code. | Bar renders; CPU %, memory, battery % and charging state show sane values; date and the calendar icon; clicking CPU, battery, network, update (see bug list D below: these click handlers are broken on macOS and stay broken). |
Revisions in section B change no behaviour on purpose (rename, move, route); they still need the sections C walk-through, because a mechanical mistake would show there.
## B. Mechanical macOS revisions of the `kfm` agent (Step 6 and 11(i))
| Revision | Change |
|---|---|
| `vzxkxzzy` 796c3832 | `Window::frame()` (renamed `window_rect` below) for the 28 sites that read a Window's frame through `MacosApi::window_rect(&w.element)` (window.rs, window_manager.rs, workspace.rs, process_event.rs, process_command.rs, settle.rs, macos_backend.rs) |
| `oootynxk` 7c2e1bea | `Window::is_valid` renamed `Window::alive` |
| `roxrxtru` 49a6dbda | `Window::set_position` renamed `Window::set_rect` (10 callers) |
| `vmnnuzzu` 803a1410 | `Window::set_messaging_timeout` (settle.rs: 3 raw AX sites) |
| `upylxxuw` ecaa3e15 | `Window::close` (the same close-button press as `AdhocWindow::close`) |
| `tsymmvll` d30a3eb6 | step 11(i): `MacosBackend` calls only `Window` methods (new `Window::minimized`); still unused by the daemon |
| `qsrxvsmv` 931b17d7 | `Window::frame` renamed `Window::window_rect` (the Backend and the Windows Window use that name) |
| `xrtqmxkp` 4c9094d7 | native: `komorebi_backend::Registry` (the rules of the id registry; 9 unit and 1 doc test run on Linux) |
| `tmmtuosy` 386c5a79 | `ax_registry` module; `MacosBackend` resolves ids through it (the adapter is still unused by the daemon) |
| `kvkxkqnx` ba22e8dc | `ax_registry::identify`: the 7 sites that turn a system-handed element into a window id (input_event_listener, process_command, process_event x5, window_manager) |
| `kpopuvoy` 01b3bd5c | live-resize step (window_manager.rs) reads the dragged window's frame through `Window::window_rect` |
Hand check: C below. If something breaks and is not the lock order, bisect among these seven first: they only rename and route, so a regression means a call was routed to the wrong receiver (type-check cannot tell a Window from another window of the same type).
## C. Walk-through (the list the plan names)
Do each with the baseline build and the tip build and compare (or record with `komorebi-recorder`).
1. **Focus**: `komorebic focus left|right|up|down`, `cycle-focus`, `focus-window <n>`; mouse-follows-focus on/off (the cursor centres in the window: uses the window frame query); focus a window of a tabbed app (Terminal, Finder): the focus uses `element_to_focus`, check the right tab.
2. **Move and resize**: `move`, `resize-axis`, `resize-edge` and a manual drag of a window; after a drag the layout puts the window back (settle reads the frame with a timeout); live resize of one edge moves the neighbour. A window that refuses a width (a fixed size app) is read back and the neighbours adapt.
3. **Stack**: `stack`, `unstack`, `cycle-stack`, `stack-all`; hidden stack members are parked off-screen (`hide`) and restored (`restore`) correctly; no window left at a hidden corner after unstack or quit.
4. **Workspaces**: switch (`focus-workspace`), send a window (`send-to-workspace`), `move-to-workspace`, with 2 monitors if possible; windows of the workspace you left are hidden, those of the new one restored to where they were; workspace rules after a monitor change.
5. **Scrolling layout**: the scrolling layout, `pan` commands, `scroll-*`; windows slide in from the edge (the arrival/slide code reads the frame).
6. **Pan** (trackpad gesture) and **live resize**: `komorebic pan-*`, three-finger style gestures as you configured them.
7. **Borders**: focused/unfocused/stack colours follow focus and follow animated windows; borders vanish and come back on workspace switch; no border left behind after closing a window (the window's close and borders share the observer code).
8. **Animations**: movement and workspace animations on and off; the ghost images; cancel an animation by issuing a new command in the middle.
9. **Display change**: plug or unplug a monitor or change the resolution: work areas update, workspaces go to the right monitors, workspace rules re-apply. (This is the thread that can deadlock, see bug list A.)
10. **Sleep and wake**: close the lid or sleep, wake: windows are where they were, the daemon answers, the bar returns.
11. **Minimize, unminimize, close**: `komorebic minimize`, `close` (the foreground window), un-minimize a window from the Dock; closing a window removes it from its container (the reaper uses `Window::alive`).
12. **Reload the config** repeatedly (see section A, first row).
## D. Bugs noticed, not fixed here
A. **Possible deadlock, main queue against the window-manager lock** (`docs/dev/seam.md` section 3.3, by reading only, never reproduced). A thread holding the WM lock calls a blocking main-queue dispatch (`destroy_all_borders` at process_event.rs lines about 128/139, process_command.rs, `retile_all`; `hide_borders_on` in window_manager.rs; `activate_finder` in process_event.rs) while the monitor reconciliator has dispatched a closure to main that itself takes the WM lock (monitor_reconciliator.rs about 106 and 383). Then the event thread waits for main, main waits for the lock. `parking_lot`'s deadlock detector cannot see it (the dispatch is a semaphore). Window of exposure: a display change at the instant of one of those events. Structural fix: no closure that runs on main may take the WM lock (or dispatch to main only after the lock is dropped). How to provoke it: spam `komorebic retile` / workspace switches (with ghosts) while plugging or unplugging a monitor; the symptom is a daemon that answers no command (`komorebic state` hangs).
B. **cmd.exe and explorer.exe in the macOS bar** (`komorebi-for-mac/komorebi-bar/src/os.rs`, `BarPlatform::open_system_tool`): the battery click spawns `cmd.exe` (power settings), the update click spawns `explorer.exe` with the release URL; neither exists on macOS. The same file's CPU (task manager) and network settings tools should be Activity Monitor and System Settings. The fix is one function in that file; it was preserved on purpose during the bar move.
C. **Update widget refresh thread polls the Windows repo's GitHub API** on macOS too (update.rs has a second URL that does not go through the hook), and the macOS update link is `.../releases/v..` built for the Windows repo.
D. **Other raw AX sites remain outside `Window`**: window.rs (38 inside `Window`/`AdhocWindow`, expected), application.rs 11 (`Application`, not a window), process_event.rs 7 (events that hand over a bare element: `window_id` of an element, the tab check `AccessibilityApi::window_id(&window.element.0)`), animation/animator.rs 6 (lane threads hold `WindowHandle` clones of the element), border_manager 7 (observers on tracked elements), window_manager.rs 4 (timeouts list holds elements, `window_id` of an element), reaper.rs 2 (`notify_if_gone` takes an element so lanes can use it), input_event_listener.rs 1, process_command.rs 1. They need either an id to AX registry (step 11(ii), not started) or a `WindowHandle` type; they were left alone because routing them changes who owns the element.
D2. **Step 11(ii) status.** Done: the id registry (`ax_registry`, over the tested `komorebi_backend::Registry`), populated at `Window::new`, kept in step for native tabs, emptied on death, reap and application quit; `MacosBackend` resolves ids through it. NOT done, on purpose, because each needs a decision that cannot be verified without a Mac: (a) the animator lanes (`WindowHandle`) keep their own snapshot of the element: a lane must keep writing to the element of the move it was given even if a tab swap replaced the registry entry, and replacing the snapshot by a lookup per write changes what a late write hits; (b) borders keep `tracking_element` (observer sources are added and removed on that exact element, on main); (c) `reaper::notify_if_gone` and the window manager's live-resize `timeouts` list take elements, including application elements, that are not registry entries; (d) `application.rs` is the application, not a window. (e) The model (`Window`, containers, workspaces) still holds `Window` values, not bare `WindowId`s: that is step 11(iii), after steps 8-9. A stale registry id answers `Error::WindowGone` (no panic); a window that was deserialised from state (default system-wide element) is never registered.
E. `MacosBackend` (step 7c/11(i)) needs 11(ii) before it can be used: `windows()` answers only what was `register`ed, `hide` needs the corner, `raise` also focuses, `monitors()` reads a cache nobody fills yet (seam.md section 9.2). `AdhocWindow` (bare foreground element) still has its own minimize, close, hide, restore, raise.
F. In `window.rs` `AdhocWindow::hide` / `Window::hide` duplicate the restore-position bookkeeping (`WINDOW_RESTORE_POSITIONS`), one on a `Window` and one on a bare element; they must stay in step.
## E. What is not checked at all
Everything above. In particular: no macOS test (399+ unit tests incl. those in window.rs for drag state) has run against any of these revisions; the unified binaries do not exist yet; no real Accessibility call was made; `komorebi-recorder` captures need a Mac.
@@ -0,0 +1,114 @@
//! The id -> {AX element, pid, observer} table of step 11(ii): what lets anything act on a
//! window from nothing but its `WindowId`.
//!
//! It is `komorebi_backend::Registry` (whose rules are tested natively: thread-safe, nothing of
//! the platform runs under its lock, stale ids are `Error::WindowGone`) instantiated with
//! [`AxEntry`].
//!
//! # Lifetime
//!
//! An entry is created by `Window::new` and by [`sync`] when a window's element or id changes
//! (a native tab). It ends at every place the daemon learns of a death:
//!
//! - `Window::drop`, when the window is no longer alive ([`forget`]);
//! - the reaper, when it reaps a window ([`forget`]);
//! - an application terminating ([`forget_pid`], from `WindowManagerEvent::Destroy`).
//!
//! A window that is alive and never managed stays registered until it dies or its application
//! quits: the entry only holds retained references that the `Window` clones held anyway.
//!
//! # Threads
//!
//! Callable from any thread. Nothing here dispatches to the main queue or takes the window
//! manager's lock (`docs/dev/seam.md` section 3.3); a closure that runs on the main thread may
//! call it. Handles removed from the table are dropped after the table's lock is released.
use crate::AccessibilityObserver;
use crate::AccessibilityUiElement;
use crate::application::Application;
use crate::window::Window;
use komorebi_backend::Registry;
use komorebi_backend::Result;
use komorebi_backend::WindowId;
use std::sync::LazyLock;
/// What it takes to act on one window: its element and observer, and its application (whose
/// element and observer are the application's, and whose `process_id` is the pid).
#[derive(Debug, Clone)]
pub struct AxEntry {
/// The window's Accessibility element
pub element: AccessibilityUiElement,
/// The observer that reports the window's events
pub observer: AccessibilityObserver,
/// The application that owns the window
pub application: Application,
}
impl AxEntry {
/// The pid of the owning application
pub fn pid(&self) -> i32 {
self.application.process_id
}
}
static WINDOWS: LazyLock<Registry<AxEntry>> = LazyLock::new(Registry::new);
/// Registers the window under its id, replacing what was registered under it.
pub fn register(window: &Window) {
let entry = window.entry();
let pid = entry.pid();
// the replaced entry is dropped here, with the table's lock released
drop(WINDOWS.insert(WindowId::from_u32(window.id), pid, entry));
}
/// A window changed its id or its element in place (a native tab that is now shown by another
/// element): forget the old id and register the window as it is.
pub fn sync(old_id: u32, window: &Window) {
if old_id != window.id {
forget(old_id);
}
register(window);
}
/// Forgets a window that is gone. A second call is not an error.
pub fn forget(id: u32) {
drop(WINDOWS.remove(WindowId::from_u32(id)));
}
/// Forgets every window of a process that quit.
pub fn forget_pid(pid: i32) {
drop(WINDOWS.remove_pid(pid));
}
/// The entry of a window, or `Error::WindowGone` if the id is stale.
pub fn get(id: WindowId) -> Result<AxEntry> {
WINDOWS.get(id)
}
/// A `Window` made from the entry of the id, or `Error::WindowGone` if the id is stale.
pub fn window(id: WindowId) -> Result<Window> {
let entry = WINDOWS.get(id)?;
// an id that does not fit a `CGWindowID` cannot have been registered
let raw = id.to_u32().ok_or(komorebi_backend::Error::WindowGone(id))?;
Ok(Window::from_entry(raw, entry))
}
/// Every registered window, by id.
pub fn windows() -> Vec<Window> {
WINDOWS
.snapshot()
.into_iter()
.filter_map(|(id, entry)| Some(Window::from_entry(id.to_u32()?, entry)))
.collect()
}
/// The `CGWindowID` of the window that an Accessibility element is, as handed over by the system
/// (an observer callback, the event tap, the focused window of an application). This is the one
/// way to turn an element into the key of the table; a window is registered under what it
/// returns.
pub fn identify(
element: &objc2_application_services::AXUIElement,
) -> std::result::Result<u32, crate::accessibility::error::AccessibilityError> {
crate::accessibility::AccessibilityApi::window_id(element)
}
@@ -1,4 +1,3 @@
use crate::accessibility::AccessibilityApi;
use crate::macos_api::MacosApi;
use crate::reaper;
use crate::reaper::ReaperNotification;
@@ -93,7 +92,7 @@ extern "C-unwind" fn callback(
}
if pid != 0 {
let window_id = AccessibilityApi::window_id(&element).ok();
let window_id = crate::ax_registry::identify(&element).ok();
// Send MoveEnd to handle pending drag-to-swap operations
// macOS doesn't reliably fire AXWindowMoved after mouse release
+5
View File
@@ -54,6 +54,7 @@ pub mod animation;
pub mod app_kit_notification_constants;
pub mod application;
pub mod arrival;
pub mod ax_registry;
pub mod border_manager;
pub mod container;
pub mod core;
@@ -122,6 +123,10 @@ lazy_static! {
Arc::new(Mutex::new(HashMap::new()));
static ref WORKSPACE_MATCHING_RULES: Arc<Mutex<Vec<WorkspaceMatchingRule>>> =
Arc::new(Mutex::new(Vec::new()));
// LOCK ORDER for the rule lists (as in the KDE tree, minus the lists macOS does not have):
// FLOATING_APPLICATIONS, SESSION_FLOATING_APPLICATIONS, IGNORE_IDENTIFIERS, MANAGE_IDENTIFIERS,
// WORKSPACE_MATCHING_RULES, REGEX_IDENTIFIERS. Take the ones you need in that order and never
// the other way round; a thread that does can deadlock with one that does not.
static ref REGEX_IDENTIFIERS: Arc<Mutex<HashMap<String, Regex>>> =
Arc::new(Mutex::new(HashMap::new()));
static ref MANAGE_IDENTIFIERS: Arc<Mutex<Vec<MatchingRule>>> = Arc::new(Mutex::new(vec![]));
+16 -31
View File
@@ -1,5 +1,6 @@
//! `MacosBackend`: the shared [`komorebi_backend::Backend`] over the existing [`MacosApi`],
//! [`Window`] and [`AdhocWindow`] functions.
//! `MacosBackend`: the shared [`komorebi_backend::Backend`] over the existing [`MacosApi`] and
//! [`Window`] methods (set_rect, focus, raise, hide, restore, minimize, unminimize, close, alive,
//! window_rect and minimized): it holds no Accessibility call of its own.
//!
//! **Nothing uses it yet.** The daemon still holds `Window` values (element, application and
//! observer) and calls them directly; this adapter exists so that the trait is known to be
@@ -8,7 +9,8 @@
//! `docs/dev/seam.md`, section 9, and commented where they are.
//!
//! The trait names a window by an id, and nothing on macOS acts from a bare id: a window is its
//! Accessibility element. So the adapter owns the **id registry** of consequence (1): the daemon
//! Accessibility element. So the backend looks windows up in the **id registry** of consequence
//! (1), `ax_registry`: the daemon
//! registers each `Window` it learns of ([`MacosBackend::register`]) and forgets it when it is
//! gone, and every method looks the element up by id. An id the registry does not hold is a
//! window that is gone.
@@ -19,13 +21,10 @@
//! callback refreshes on main with [`MacosBackend::refresh_monitors`].
use crate::LibraryError;
use crate::accessibility::AccessibilityApi;
use crate::accessibility::attribute_constants::kAXMinimizedAttribute;
use crate::core::Rect;
use crate::core::WindowHidingPosition;
use crate::macos_api::MacosApi;
use crate::skylight::BatchedUpdates;
use crate::window::AdhocWindow;
use crate::window::Window;
use komorebi_backend::Backend;
use komorebi_backend::Error;
@@ -35,9 +34,7 @@ use komorebi_backend::Result;
use komorebi_backend::WindowId;
use komorebi_backend::WindowInfo;
use objc2::MainThreadMarker;
use objc2_core_foundation::CFBoolean;
use parking_lot::Mutex;
use std::collections::HashMap;
/// The shared [`Backend`] for macOS.
///
@@ -45,7 +42,6 @@ use std::collections::HashMap;
/// opacity and no focus follows mouse (`docs/dev/seam.md` section 2.1), so those methods answer
/// `Unsupported`.
pub struct MacosBackend {
windows: Mutex<HashMap<u32, Window>>,
monitors: Mutex<Vec<MonitorInfo>>,
hiding_position: WindowHidingPosition,
batches: Mutex<Vec<BatchedUpdates>>,
@@ -67,7 +63,6 @@ impl MacosBackend {
/// the monitor's configuration, and the trait has no place for it.
pub fn new(hiding_position: WindowHidingPosition) -> Self {
Self {
windows: Mutex::new(HashMap::new()),
monitors: Mutex::new(Vec::new()),
hiding_position,
batches: Mutex::new(Vec::new()),
@@ -76,12 +71,12 @@ impl MacosBackend {
/// Makes the window known to the backend, by its `CGWindowID`.
pub fn register(&self, window: Window) {
self.windows.lock().insert(window.id, window);
crate::ax_registry::register(&window);
}
/// Forgets a window that is gone.
pub fn forget(&self, id: u32) {
self.windows.lock().remove(&id);
crate::ax_registry::forget(id);
}
/// Reads the monitors from `NSScreen` into the cache that [`Backend::monitors`] answers
@@ -105,9 +100,7 @@ impl MacosBackend {
}
fn lookup(&self, id: WindowId) -> Result<Window> {
id.to_u32()
.and_then(|raw| self.windows.lock().get(&raw).cloned())
.ok_or(Error::WindowGone(id))
crate::ax_registry::window(id)
}
/// Says why a call about a window failed. A failed write is how macOS finds out that a window
@@ -118,7 +111,7 @@ impl MacosBackend {
window: &Window,
error: impl std::error::Error + Send + Sync + 'static,
) -> Error {
if window.is_valid() {
if window.alive() {
platform(error)
} else {
Error::WindowGone(id)
@@ -126,24 +119,16 @@ impl MacosBackend {
}
fn info(window: &Window) -> Option<WindowInfo> {
if !window.is_valid() {
if !window.alive() {
return None;
}
let minimized = AccessibilityApi::copy_attribute_value::<CFBoolean>(
&window.element,
kAXMinimizedAttribute,
)
.is_some_and(|b| b.as_bool());
Some(WindowInfo {
id: WindowId::from_u32(window.id),
pid: window.application.process_id,
// the Accessibility position and size, in points; a failure leaves the default
rect: MacosApi::window_rect(&window.element)
.map(Rect::from)
.unwrap_or_default(),
minimized,
rect: window.window_rect().map(Rect::from).unwrap_or_default(),
minimized: window.minimized(),
alive: true,
title: window.title(),
// macOS has no window class: a bundle identifier, role and subrole instead
@@ -162,7 +147,7 @@ impl Backend for MacosBackend {
// The registry is the only list there is: the CoreGraphics list joined with the
// Accessibility elements by title lives inside `load_workspace_information`, which fills
// a `WindowManager`, and the daemon registers what it finds
let registered: Vec<_> = self.windows.lock().values().cloned().collect();
let registered = crate::ax_registry::windows();
let mut windows: Vec<_> = registered.iter().filter_map(Self::info).collect();
windows.sort_by_key(|w| w.id);
windows
@@ -203,11 +188,11 @@ impl Backend for MacosBackend {
}
fn set_rect(&self, id: WindowId, rect: &Rect) -> Result<()> {
// `Window::set_position` writes directly (two Accessibility writes) or hands the move to
// `Window::set_rect` writes directly (two Accessibility writes) or hands the move to
// the animator, depending on the animation settings; both are requests
let window = self.lookup(id)?;
window
.set_position(rect)
.set_rect(rect)
.map_err(|e| Self::failed(id, &window, e))
}
@@ -252,7 +237,7 @@ impl Backend for MacosBackend {
fn close(&self, id: WindowId) -> Result<()> {
// presses the close button; `Ok` when the window has none
let window = self.lookup(id)?;
AdhocWindow::close(&window.element.0).map_err(|e| Self::failed(id, &window, e))
window.close().map_err(|e| Self::failed(id, &window, e))
}
fn center_cursor_in(&self, rect: &Rect) -> Result<()> {
@@ -9,7 +9,6 @@ use crate::SESSION_FLOATING_APPLICATIONS;
use crate::SUBSCRIPTION_SOCKET_OPTIONS;
use crate::SUBSCRIPTION_SOCKETS;
use crate::WORKSPACE_MATCHING_RULES;
use crate::accessibility::AccessibilityApi;
use crate::animation;
use crate::animation::ANIMATION_DURATION_GLOBAL;
use crate::animation::ANIMATION_DURATION_PER_ANIMATION;
@@ -637,7 +636,7 @@ impl WindowManager {
// Sort by window area
window_idx_pairs.sort_by_key(|(_, w)| {
let rect =
Rect::from(MacosApi::window_rect(&w.element).unwrap_or_default());
Rect::from(w.window_rect().unwrap_or_default());
rect.right * rect.bottom
});
window_idx_pairs.reverse();
@@ -707,7 +706,7 @@ impl WindowManager {
// Sort by window area
window_idx_pairs.sort_by_key(|w| {
let rect = Rect::from(
MacosApi::window_rect(&w.element).unwrap_or_default(),
w.window_rect().unwrap_or_default(),
);
rect.right * rect.bottom
});
@@ -1718,7 +1717,7 @@ impl WindowManager {
let application = Application::new(raw_info.owner_pid)?;
if let Some(elements) = application.window_elements() {
for element in elements {
if let Ok(wid) = AccessibilityApi::window_id(&element)
if let Ok(wid) = crate::ax_registry::identify(&element)
&& i64::from(wid) == window_id
&& let Ok(window) = Window::new(element, application.clone())
{
+15 -11
View File
@@ -6,7 +6,6 @@ use crate::REGEX_IDENTIFIERS;
use crate::TABBED_APPLICATIONS;
use crate::UNMANAGED_WINDOW_IDS;
use crate::WORKSPACE_MATCHING_RULES;
use crate::accessibility::AccessibilityApi;
use crate::accessibility::enhanced_ui;
use crate::accessibility::error::AccessibilityApiError;
use crate::accessibility::error::AccessibilityError;
@@ -182,7 +181,7 @@ impl WindowManager {
{
let application = self.application(event.process_id())?;
if let Some(element) = application.main_window()
&& let Ok(wid) = AccessibilityApi::window_id(&element)
&& let Ok(wid) = crate::ax_registry::identify(&element)
{
window_id = Some(wid);
window_element = Some(AccessibilityUiElement(element.clone()));
@@ -260,7 +259,7 @@ impl WindowManager {
&& window.application.process_id == process_id
{
let tab_rect = MacosApi::window_rect(&element)?;
let main_rect = match MacosApi::window_rect(&window.element) {
let main_rect = match window.window_rect() {
Ok(rect) => rect,
Err(AccessibilityError::Api(
AccessibilityApiError::InvalidUIElement,
@@ -296,7 +295,7 @@ impl WindowManager {
// Additional check: verify the stored element belongs to this container
// This prevents treating separate windows as tabs when they have matching geometry
if let Ok(stored_window_id) =
AccessibilityApi::window_id(&window.element.0)
crate::ax_registry::identify(&window.element.0)
&& stored_window_id == window.id
{
// All checks pass: stored element matches container ID = true tab change
@@ -316,8 +315,10 @@ impl WindowManager {
workspace.containers_mut().get_mut(container_idx)
&& let Some(window) = container.focused_window_mut()
{
let old_id = window.id;
window.id = window_id;
window.element = AccessibilityUiElement(element.clone());
crate::ax_registry::sync(old_id, window);
}
// check monocle_container for tabbed applications
@@ -328,7 +329,7 @@ impl WindowManager {
&& window.application.process_id == process_id
{
let tab_rect = MacosApi::window_rect(&element)?;
let main_rect = match MacosApi::window_rect(&window.element) {
let main_rect = match window.window_rect() {
Ok(rect) => rect,
Err(AccessibilityError::Api(
AccessibilityApiError::InvalidUIElement,
@@ -349,8 +350,10 @@ impl WindowManager {
&& let Some(monocle) = workspace.monocle_container.as_mut()
&& let Some(window) = monocle.focused_window_mut()
{
let old_id = window.id;
window.id = window_id;
window.element = AccessibilityUiElement(element.clone());
crate::ax_registry::sync(old_id, window);
}
if first_tab_destroyed {
@@ -534,7 +537,7 @@ impl WindowManager {
{
let application = self.application(process_id)?;
if let Some(element) = application.main_window()
&& let Ok(wid) = AccessibilityApi::window_id(&element)
&& let Ok(wid) = crate::ax_registry::identify(&element)
{
window_id = Some(wid);
window_element = Some(element.clone());
@@ -548,12 +551,12 @@ impl WindowManager {
let tabbed_applications = TABBED_APPLICATIONS.lock();
if tabbed_applications.contains(&application_name) {
// Get the window_id for the new window
if let Ok(new_window_id) = AccessibilityApi::window_id(element) {
if let Ok(new_window_id) = crate::ax_registry::identify(element) {
for window in workspace.visible_windows().iter().flatten() {
if window.application.name().unwrap_or_default() == application_name
{
let tab_rect = MacosApi::window_rect(element)?;
let main_rect = MacosApi::window_rect(&window.element)?;
let main_rect = window.window_rect()?;
// BOTH conditions must be true:
// 1. Geometry matches (original check - preserves existing behavior)
// 2. Window IDs match (new check - prevents false positives)
@@ -745,6 +748,7 @@ impl WindowManager {
)
) {
enhanced_ui::forget(process_id);
crate::ax_registry::forget_pid(process_id);
}
// some apps like Discord in all of their relentless Electron slop stupidity
@@ -897,7 +901,7 @@ impl WindowManager {
{
// TODO: not sure about this clone
let window = window.clone();
let new_position = Rect::from(MacosApi::window_rect(&window.element)?);
let new_position = Rect::from(window.window_rect()?);
let old_position = *workspace
.latest_layout
.get(focused_container_idx)
@@ -1102,7 +1106,7 @@ impl WindowManager {
&& let Some(window) =
container.windows().iter().find(|w| w.id == window_id)
{
let started_from = Rect::from(MacosApi::window_rect(&window.element)?);
let started_from = Rect::from(window.window_rect()?);
let pending_resize_op = Arc::make_mut(&mut self.pending_resize_op);
*pending_resize_op = Option::from((window_id, Some(started_from)));
@@ -1162,7 +1166,7 @@ impl WindowManager {
&& let Some(window) =
container.windows().iter().find(|w| w.id == window_id)
{
let ended_at = Rect::from(MacosApi::window_rect(&window.element)?);
let ended_at = Rect::from(window.window_rect()?);
tracing::info!("resizing with mouse");
redraw |= self.resize_by_drag(window_id, started_from, ended_at)?;
+4 -1
View File
@@ -64,7 +64,7 @@ fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result<()>
continue 'notification;
}
handle_notification(&mut wm, notification, Window::is_valid)?;
handle_notification(&mut wm, notification, Window::alive)?;
}
Ok(())
@@ -111,6 +111,9 @@ fn invalid_window_ids(
}
fn reap_window(wm: &mut WindowManager, window_id: u32) -> color_eyre::Result<()> {
// whatever else happens to the window, the id no longer names anything
crate::ax_registry::forget(window_id);
let mut should_update = false;
for monitor in wm.monitors_mut() {
for workspace in monitor.workspaces_mut() {
+2 -2
View File
@@ -303,7 +303,7 @@ impl Workspace {
for window in container.windows_mut() {
let result = match placement.visibility {
Visibility::Visible => window
.set_position(&rect)
.set_rect(&rect)
.map(|()| window.note_arrival(&rect, resized)),
Visibility::Clipped => {
// part of it is in view and the rest is past the edge of the screen,
@@ -315,7 +315,7 @@ impl Workspace {
}
window
.set_position(&target)
.set_rect(&target)
.map(|()| window.note_arrival(&target, resized))
}
Visibility::OffLeft => {
+4 -6
View File
@@ -8,10 +8,8 @@
//! and any that are not where they are to be are sent there again, and looked at once more after
//! that.
use crate::accessibility::AccessibilityApi;
use crate::animation::animator;
use crate::core::Rect;
use crate::macos_api::MacosApi;
use crate::window::Snapping;
use crate::window::dragged_window;
use crate::window::is_parked;
@@ -185,9 +183,9 @@ impl WindowManager {
return Ok(0);
}
let _ = AccessibilityApi::set_messaging_timeout(&window.element, LOOK_TIMEOUT);
let rect = MacosApi::window_rect(&window.element).map(Rect::from);
let _ = AccessibilityApi::set_messaging_timeout(&window.element, 0.0);
let _ = window.set_messaging_timeout(LOOK_TIMEOUT);
let rect = window.window_rect().map(Rect::from);
let _ = window.set_messaging_timeout(0.0);
if let Ok(rect) = rect
&& is_off(&rect, &layout)
@@ -208,7 +206,7 @@ impl WindowManager {
let _snapping = Snapping::end();
for (window, layout) in &off {
if let Err(error) = window.set_position(layout) {
if let Err(error) = window.set_rect(layout) {
tracing::debug!(
"live resize: could not send window {} again: {error}",
window.id
@@ -545,16 +545,18 @@ impl ApplyGlobals for StaticConfig {
// transparency_manager::TRANSPARENCY_ALPHA
// .store(self.transparency_alpha.unwrap_or(200), Ordering::SeqCst);
// the order every other place takes them in (floating, ignore, manage, regex; see lib.rs):
// a thread that takes two in another order can wait for one that holds what it needs
let mut floating_applications = FLOATING_APPLICATIONS.lock();
let mut ignore_identifiers = IGNORE_IDENTIFIERS.lock();
let mut regex_identifiers = REGEX_IDENTIFIERS.lock();
let mut manage_identifiers = MANAGE_IDENTIFIERS.lock();
let mut regex_identifiers = REGEX_IDENTIFIERS.lock();
// let mut tray_and_multi_window_identifiers = TRAY_AND_MULTI_WINDOW_IDENTIFIERS.lock();
// let mut object_name_change_identifiers = OBJECT_NAME_CHANGE_ON_LAUNCH.lock();
// let mut object_name_change_title_ignore_list = OBJECT_NAME_CHANGE_TITLE_IGNORE_LIST.lock();
// let mut layered_identifiers = LAYERED_WHITELIST.lock();
// let mut transparency_blacklist = TRANSPARENCY_BLACKLIST.lock();
// let mut slow_application_identifiers = SLOW_APPLICATION_IDENTIFIERS.lock();
let mut floating_applications = FLOATING_APPLICATIONS.lock();
// let mut no_titlebar_applications = NO_TITLEBAR.lock();
let mut titleless_applications = TITLELESS_APPLICATIONS.lock();
let mut tabbed_applications = TABBED_APPLICATIONS.lock();
+75 -22
View File
@@ -465,7 +465,7 @@ impl Drop for Window {
fn drop(&mut self) {
// this gets called when a cloned Window is dropped, so we need to make sure it only
// invalidates the observer if the Window is no longer open
if !self.is_valid() {
if !self.alive() {
tracing::info!(
"invalidating window observer for {}",
self.title()
@@ -476,6 +476,9 @@ impl Drop for Window {
// for now - hopefully it doesn't break anything else
reaper::send_notification(ReaperNotification::InvalidWindow(self.id));
// and the id no longer names anything
crate::ax_registry::forget(self.id);
// make sure the observer gets removed from any run loops
AccessibilityApi::invalidate_observer(&self.observer);
}
@@ -520,7 +523,7 @@ impl Serialize for Window {
state.serialize_field("id", &self.id)?;
state.serialize_field(
"rect",
&Rect::from(MacosApi::window_rect(&self.element).unwrap_or_default()),
&Rect::from(self.window_rect().unwrap_or_default()),
)?;
state.serialize_field("details", &WindowDetails::from(self))?;
state.end()
@@ -560,16 +563,52 @@ impl Window {
Some(window_observer_callback),
)?;
Ok(Self {
let window = Self {
id: AccessibilityApi::window_id(&element)?,
element: AccessibilityUiElement(element),
application,
observer: AccessibilityObserver(Some(observer)),
details: None,
})
};
// so that the window can be found again from its id alone
crate::ax_registry::register(&window);
Ok(window)
}
pub fn is_valid(&self) -> bool {
/// The handles that act on this window, as the registry keeps them
pub(crate) fn entry(&self) -> crate::ax_registry::AxEntry {
crate::ax_registry::AxEntry {
element: self.element.clone(),
observer: self.observer.clone(),
application: self.application.clone(),
}
}
/// A window made from a registry entry
pub(crate) fn from_entry(id: u32, entry: crate::ax_registry::AxEntry) -> Self {
Self {
id,
element: entry.element,
application: entry.application,
observer: entry.observer,
details: None,
}
}
/// The frame query (`Backend::window_rect`): the Accessibility position and size, in points.
pub fn window_rect(&self) -> Result<CGRect, AccessibilityError> {
MacosApi::window_rect(&self.element)
}
/// How long an Accessibility call to this window's app may block, in seconds (0 restores the
/// system default). Not an operation of the shared `Backend`; it bounds the frame query.
pub fn set_messaging_timeout(&self, seconds: f32) -> Result<(), AccessibilityError> {
AccessibilityApi::set_messaging_timeout(&self.element, seconds)
}
pub fn alive(&self) -> bool {
AccessibilityApi::copy_attribute_names(&self.element).is_some()
}
@@ -622,7 +661,7 @@ impl Window {
// it happened to be when it was hidden
let rect = match animator::take_over(self.id) {
Some(target) => CGRect::from(target),
None => MacosApi::window_rect(&self.element)?,
None => self.window_rect()?,
};
let mut window_restore_positions = WINDOW_RESTORE_POSITIONS.lock();
@@ -706,7 +745,7 @@ impl Window {
let rect = match animator::target(self.id) {
Some(target) => CGRect::from(target),
None => MacosApi::window_rect(&self.element)?,
None => self.window_rect()?,
};
let Some(screen) = screen.or_else(|| CoreGraphicsApi::display_bounds_for_window_rect(rect))
@@ -791,6 +830,12 @@ impl Window {
Ok(())
}
/// Whether the window is minimized (the `WindowInfo::minimized` field of the shared `Backend`).
pub fn minimized(&self) -> bool {
AccessibilityApi::copy_attribute_value::<CFBoolean>(&self.element, kAXMinimizedAttribute)
.is_some_and(|b| b.as_bool())
}
pub fn minimize(&mut self) -> Result<(), AccessibilityError> {
let cf_boolean = CFBoolean::new(true);
let value = &**cf_boolean;
@@ -803,6 +848,11 @@ impl Window {
AccessibilityApi::set_attribute_cf_value(&self.element, kAXMinimizedAttribute, value)
}
/// Closes the window by pressing its close button (`Backend::close`); `Ok` when it has none.
pub fn close(&self) -> Result<(), AccessibilityError> {
AdhocWindow::close(&self.element.0)
}
#[tracing::instrument(skip_all)]
pub fn restore(&mut self) -> Result<(), AccessibilityError> {
// waiting for what is on its way to the window is not something to do with a lock held
@@ -912,7 +962,7 @@ impl Window {
))
}
pub fn set_position(&self, rect: &Rect) -> Result<(), AccessibilityError> {
pub fn set_rect(&self, rect: &Rect) -> Result<(), AccessibilityError> {
match snap() {
Snap::Step(dragged) => return self.set_position_for_step(dragged, rect),
Snap::End => {
@@ -954,9 +1004,9 @@ impl Window {
}
// the window manager is waiting for this, and so is every window after this one
let _ = AccessibilityApi::set_messaging_timeout(&self.element, ARRIVAL_TIMEOUT);
let _ = self.set_messaging_timeout(ARRIVAL_TIMEOUT);
match MacosApi::window_rect(&self.element) {
match self.window_rect() {
Ok(actual) => arrival::report(self.id, *target, Rect::from(actual)),
Err(error) => {
tracing::debug!(
@@ -985,7 +1035,7 @@ impl Window {
return;
}
let Ok(current) = MacosApi::window_rect(&self.element) else {
let Ok(current) = self.window_rect() else {
return;
};
@@ -1077,7 +1127,7 @@ impl Window {
motion,
after: None,
},
|| MacosApi::window_rect(&self.element).map(Rect::from),
|| self.window_rect().map(Rect::from),
)
}
@@ -1192,14 +1242,14 @@ impl Window {
let target_width = (target_height * aspect_ratio_width) / aspect_ratio_height;
(target_width, target_height)
} else {
let current_rect = Rect::from(MacosApi::window_rect(&self.element)?);
let current_rect = Rect::from(self.window_rect()?);
(current_rect.right, current_rect.bottom)
};
let x = work_area.left + ((work_area.right - target_width) / 2);
let y = work_area.top + ((work_area.bottom - target_height) / 2);
self.set_position(&Rect {
self.set_rect(&Rect {
left: x,
top: y,
right: target_width,
@@ -1212,7 +1262,7 @@ impl Window {
current_area: &Rect,
target_area: &Rect,
) -> Result<(), AccessibilityError> {
let current_rect = Rect::from(MacosApi::window_rect(&self.element)?);
let current_rect = Rect::from(self.window_rect()?);
let x_diff = target_area.left - current_area.left;
let y_diff = target_area.top - current_area.top;
let x_ratio = f32::abs((target_area.right as f32) / (current_area.right as f32));
@@ -1250,7 +1300,7 @@ impl Window {
// let anim_count = ANIMATION_MANAGER
// .lock()
// .count_in_progress(MovementRenderDispatcher::PREFIX);
// self.set_position(&new_rect, true)?;
// self.set_rect(&new_rect, true)?;
// let hwnd = self.hwnd;
// // Wait for the animation to finish before maximizing the window again, otherwise
// // we would be maximizing the window on the current monitor anyway
@@ -1269,11 +1319,11 @@ impl Window {
// windows_api::WindowsApi::maximize_window(hwnd);
// });
// } else {
// self.set_position(&new_rect, true)?;
// self.set_rect(&new_rect, true)?;
// windows_api::WindowsApi::maximize_window(self.hwnd);
// }
// } else {
self.set_position(&new_rect)?;
self.set_rect(&new_rect)?;
// }
Ok(())
@@ -1285,7 +1335,7 @@ impl Window {
event: Option<WindowManagerEvent>,
debug: &mut RuleDebug,
) -> eyre::Result<bool> {
if !self.is_valid() {
if !self.alive() {
return Ok(false);
}
@@ -1641,9 +1691,14 @@ fn window_is_eligible(
}
}
// Every place that holds two of the rule lists takes them in this order: floating, ignore,
// manage, regex (see the statics in lib.rs). A thread that takes two in another order can wait
// for one that holds what it needs, and both wait for ever.
let floating_identifiers = FLOATING_APPLICATIONS.lock();
let ignore_identifiers = IGNORE_IDENTIFIERS.lock();
let manage_identifiers = MANAGE_IDENTIFIERS.lock();
let regex_identifiers = REGEX_IDENTIFIERS.lock();
let ignore_identifiers = IGNORE_IDENTIFIERS.lock();
let should_ignore = if let Some(rule) = should_act(
title,
exe_name,
@@ -1658,7 +1713,6 @@ fn window_is_eligible(
false
};
let manage_identifiers = MANAGE_IDENTIFIERS.lock();
let managed_override = if let Some(rule) = should_act(
title,
exe_name,
@@ -1673,7 +1727,6 @@ fn window_is_eligible(
false
};
let floating_identifiers = FLOATING_APPLICATIONS.lock();
if let Some(rule) = should_act(
title,
exe_name,
+17 -18
View File
@@ -279,7 +279,7 @@ impl WindowManager {
.enumerate()
.flat_map(|(idx, w)| {
(w.id != focused_window_id).then_some(
MacosApi::window_rect(&w.element)
w.window_rect()
.ok()
.map(|r| (idx, Rect::from(r))),
)
@@ -305,7 +305,7 @@ impl WindowManager {
.enumerate()
.flat_map(|(idx, w)| {
(w.id != focused_window_id).then_some(
MacosApi::window_rect(&w.element)
w.window_rect()
.ok()
.map(|r| (idx, Rect::from(r))),
)
@@ -331,7 +331,7 @@ impl WindowManager {
.enumerate()
.flat_map(|(idx, w)| {
(w.id != focused_window_id).then_some(
MacosApi::window_rect(&w.element)
w.window_rect()
.ok()
.map(|r| (idx, Rect::from(r))),
)
@@ -357,7 +357,7 @@ impl WindowManager {
.enumerate()
.flat_map(|(idx, w)| {
(w.id != focused_window_id).then_some(
MacosApi::window_rect(&w.element)
w.window_rect()
.ok()
.map(|r| (idx, Rect::from(r))),
)
@@ -747,7 +747,7 @@ impl WindowManager {
MacosApi::foreground_window_id().ok_or_eyre("no foreground window")?;
for window in focused_workspace.floating_windows().iter() {
if window.id == focused_window_id {
let mut rect = Rect::from(MacosApi::window_rect(&window.element)?);
let mut rect = Rect::from(window.window_rect()?);
match direction {
OperationDirection::Left => {
if rect.left - delta < focused_monitor_work_area.left {
@@ -787,7 +787,7 @@ impl WindowManager {
}
}
window.set_position(&rect)?;
window.set_rect(&rect)?;
if mouse_follows_focus {
MacosApi::center_cursor_in_rect(&rect)?;
@@ -1180,7 +1180,7 @@ impl WindowManager {
let mut valid_window_ids = vec![];
if let Some(elements) = application.window_elements() {
for element in elements {
if let Ok(window_id) = AccessibilityApi::window_id(&element) {
if let Ok(window_id) = crate::ax_registry::identify(&element) {
valid_window_ids.push(window_id);
}
}
@@ -1475,7 +1475,7 @@ impl WindowManager {
.into_iter()
.flatten()
.filter_map(|window| {
MacosApi::window_rect(&window.element)
window.window_rect()
.ok()
.map(|rect| (window.id, Rect::from(rect)))
})
@@ -1696,7 +1696,7 @@ impl WindowManager {
for window in workspace.floating_windows().iter() {
if window.id == focused_window_id {
let mut rect = Rect::from(MacosApi::window_rect(&window.element)?);
let mut rect = Rect::from(window.window_rect()?);
match (direction, sizing) {
(OperationDirection::Left, Sizing::Increase) => {
if rect.left - delta < focused_monitor_work_area.left {
@@ -1750,7 +1750,7 @@ impl WindowManager {
}
}
window.set_position(&rect)?;
window.set_rect(&rect)?;
if mouse_follows_focus {
MacosApi::center_cursor_in_rect(&rect)?;
@@ -2190,14 +2190,13 @@ impl WindowManager {
return Ok(());
}
let element = self
.focused_workspace()?
.container_for_window(window_id)
.and_then(|container| container.windows().iter().find(|w| w.id == window_id))
.map(|window| window.element.clone())
.ok_or_eyre("the dragged window is not in a container on the focused workspace")?;
let now = Rect::from(MacosApi::window_rect(&element)?);
let now = Rect::from(
self.focused_workspace()?
.container_for_window(window_id)
.and_then(|container| container.windows().iter().find(|w| w.id == window_id))
.ok_or_eyre("the dragged window is not in a container on the focused workspace")?
.window_rect()?,
);
if !self.live_resize_apply(now)? {
return Ok(());
+8 -8
View File
@@ -553,7 +553,7 @@ impl Workspace {
&& focused_window.application.process_id == process_id
{
if !valid_window_ids.contains(&focused_window.id) {
if let Ok(rect) = MacosApi::window_rect(&focused_window.element) {
if let Ok(rect) = focused_window.window_rect() {
tracing::debug!(
"reaping window {} ({}): rect = {:?}",
focused_window.id,
@@ -566,7 +566,7 @@ impl Workspace {
// if accessibility API calls fail on AXError::InvalidUIElement
// let's try and nuke these early
if !focused_window.is_valid() {
if !focused_window.alive() {
invalid_window_ids.push(focused_window.id);
}
}
@@ -578,7 +578,7 @@ impl Workspace {
if focused_window.application.process_id == process_id
&& !valid_window_ids.contains(&focused_window.id)
{
if let Ok(rect) = MacosApi::window_rect(&focused_window.element) {
if let Ok(rect) = focused_window.window_rect() {
tracing::debug!(
"reaping monocle window {} ({}): rect = {:?}",
focused_window.id,
@@ -591,7 +591,7 @@ impl Workspace {
// if accessibility API calls fail on AXError::InvalidUIElement
// let's try and nuke these early
if !focused_window.is_valid() {
if !focused_window.alive() {
invalid_window_ids.push(focused_window.id);
}
}
@@ -599,7 +599,7 @@ impl Workspace {
for window in self.floating_windows() {
if window.application.process_id == process_id {
if !valid_window_ids.contains(&window.id) {
if let Ok(rect) = MacosApi::window_rect(&window.element) {
if let Ok(rect) = window.window_rect() {
tracing::debug!(
"reaping floating window {} ({}): rect = {:?}",
window.id,
@@ -612,7 +612,7 @@ impl Workspace {
// if accessibility API calls fail on AXError::InvalidUIElement
// let's try and nuke these early
if !window.is_valid() {
if !window.alive() {
invalid_floating_window_ids.push(window.id);
}
}
@@ -1476,7 +1476,7 @@ impl Workspace {
adjusted_work_area.add_padding(container_padding);
adjusted_work_area.add_padding(border_offset);
adjusted_work_area.add_padding(border_width);
window.set_position(&adjusted_work_area)?;
window.set_rect(&adjusted_work_area)?;
};
} else if !self.containers().is_empty() {
if self.is_scrolling() {
@@ -1503,7 +1503,7 @@ impl Workspace {
layout.add_padding(border_width);
for window in container.windows_mut() {
if let Err(error) = window.set_position(layout) {
if let Err(error) = window.set_rect(layout) {
tracing::warn!("failed to set window position: {error}")
}
}