feat(macos): MacosBackend, a compile-only adapter for the shared Backend

komorebi::macos_backend::MacosBackend implements komorebi_backend::Backend over the existing MacosApi, Window and AdhocWindow functions. It is NOT used by the daemon (no call site changed; that is step 11). No capability is supported (workspaces, maximize, opacity and focus-follows-mouse answer Unsupported).

Because nothing on macOS acts from a bare id, the adapter owns the id registry of seam.md consequence (1): the daemon would register(Window) and forget(id), every method looks the Window up by id and an unknown id is WindowGone. monitors() reads a cache that refresh_monitors(MainThreadMarker) fills on the main thread (the TARGET rule: no Backend method waits for main; the live NSScreen query panics off main). begin_batch/end_batch hold a stack of SkyLight BatchedUpdates guards. The parts that cannot be written faithfully (windows() is the registry, hide needs a corner and a display the trait has no place for, raise also focuses, no minimized/alive query that does not round-trip to the app, ...) are commented where they are and listed in seam.md section 9 (next revision).

komorebi-for-mac gains the komorebi-backend path dependency (workspace dependency, komorebi/Cargo.toml, Cargo.lock).

Verified: macOS fake-CC cargo check --locked --target aarch64-apple-darwin --workspace --all-targets: finished, 0 errors, no warning in macos_backend.rs. Not run: unit tests (macOS code cannot run here).
This commit is contained in:
LGUG2Z committed 2026-10-31 01:37:54 -07:00
1 parent 76d25c8843
commit bd3454e433
5 files changed
+280

No files matched your search

+8
View File
@@ -3165,6 +3165,7 @@ dependencies = [
"dispatch2",
"ed25519-dalek",
"hotwatch",
"komorebi-backend",
"komorebi-collections",
"komorebi-config",
"komorebi-layouts",
@@ -3200,6 +3201,13 @@ dependencies = [
"version",
]
[[package]]
name = "komorebi-backend"
version = "0.1.42"
dependencies = [
"komorebi-layouts",
]
[[package]]
name = "komorebi-bar"
version = "0.1.1"
+1
View File
@@ -14,6 +14,7 @@ version = "0.1.0"
[workspace.dependencies]
komorebi-themes = { path = "../komorebi-themes" }
komorebi-backend = { path = "../crates/komorebi-backend" }
komorebi-collections = { path = "../crates/komorebi-collections" }
komorebi-config = { path = "../crates/komorebi-config", default-features = false, features = ["schemars"] }
komorebi-protocol = { path = "../crates/komorebi-protocol", default-features = false, features = ["schemars"] }
+1
View File
@@ -6,6 +6,7 @@ edition = "2024"
[dependencies]
komorebi-themes = { workspace = true }
komorebi-backend = { workspace = true }
komorebi-collections = { workspace = true }
komorebi-layouts = { workspace = true }
komorebi-config = { workspace = true }
+1
View File
@@ -63,6 +63,7 @@ pub mod input_event_listener;
pub mod ioreg;
pub use komorebi_collections::lockable_sequence;
pub mod macos_api;
pub mod macos_backend;
pub mod monitor;
pub mod monitor_reconciliator;
pub mod notification_center_listener;
@@ -0,0 +1,269 @@
//! `MacosBackend`: the shared [`komorebi_backend::Backend`] over the existing [`MacosApi`],
//! [`Window`] and [`AdhocWindow`] functions.
//!
//! **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
//! implementable here before the core depends on it (step 7c of the consolidation). The parts
//! that could not be written without changing what the daemon does are listed in
//! `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
//! 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.
//!
//! Monitors are the other thing that cannot be a plain call: `MacosApi::latest_monitor_information`
//! is an `NSScreen` query that is only legal on the main thread, and the rule is that no `Backend`
//! method waits for main. [`Backend::monitors`] therefore reads a cache, which the display
//! 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;
use komorebi_backend::MonitorId;
use komorebi_backend::MonitorInfo;
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.
///
/// Capabilities: none. macOS has no workspaces of komorebi's to delegate, no maximize, no
/// 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>>,
}
impl Default for MacosBackend {
fn default() -> Self {
Self::new(WindowHidingPosition::default())
}
}
fn platform(error: impl std::error::Error + Send + Sync + 'static) -> Error {
Error::from_platform(error)
}
impl MacosBackend {
/// Returns a backend with an empty registry and no monitors. `hiding_position` is the corner
/// [`Backend::hide`] parks windows in: on macOS it is an argument of every hide, taken from
/// 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()),
}
}
/// Makes the window known to the backend, by its `CGWindowID`.
pub fn register(&self, window: Window) {
self.windows.lock().insert(window.id, window);
}
/// Forgets a window that is gone.
pub fn forget(&self, id: u32) {
self.windows.lock().remove(&id);
}
/// Reads the monitors from `NSScreen` into the cache that [`Backend::monitors`] answers
/// from. The `MainThreadMarker` is the proof that this is called on the main thread, from
/// the display reconfiguration callback.
pub fn refresh_monitors(&self, _main: MainThreadMarker) -> Result<()> {
let monitors = MacosApi::latest_monitor_information()
.map_err(platform)?
.into_iter()
.map(|m| MonitorInfo {
id: MonitorId::from_u32(m.id),
size: m.size,
work_area: m.work_area_size,
name: Some(m.device),
serial_number_id: Some(m.serial_number_id),
})
.collect();
*self.monitors.lock() = monitors;
Ok(())
}
fn lookup(&self, id: WindowId) -> Result<Window> {
id.to_u32()
.and_then(|raw| self.windows.lock().get(&raw).cloned())
.ok_or(Error::WindowGone(id))
}
/// Says why a call about a window failed. A failed write is how macOS finds out that a window
/// has died, so the window is asked whether it is still there (an Accessibility round trip,
/// as the reaper does after a failed write).
fn failed(
id: WindowId,
window: &Window,
error: impl std::error::Error + Send + Sync + 'static,
) -> Error {
if window.is_valid() {
platform(error)
} else {
Error::WindowGone(id)
}
}
fn info(window: &Window) -> Option<WindowInfo> {
if !window.is_valid() {
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,
alive: true,
title: window.title(),
// macOS has no window class: a bundle identifier, role and subrole instead
class: None,
exe: window.exe(),
path: window.path().map(|p| p.to_string_lossy().into_owned()),
fullscreen: None,
maximized: None,
normal: None,
})
}
}
impl Backend for MacosBackend {
fn windows(&self) -> Vec<WindowInfo> {
// 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 mut windows: Vec<_> = registered.iter().filter_map(Self::info).collect();
windows.sort_by_key(|w| w.id);
windows
}
fn window(&self, id: WindowId) -> Option<WindowInfo> {
Self::info(&self.lookup(id).ok()?)
}
fn foreground_window(&self) -> Option<WindowId> {
MacosApi::foreground_window_id().map(WindowId::from_u32)
}
fn monitors(&self) -> Result<Vec<MonitorInfo>> {
let monitors = self.monitors.lock().clone();
if monitors.is_empty() {
return Err(Error::platform(
"no monitors known yet: the display callback has not refreshed the cache",
));
}
Ok(monitors)
}
fn cursor_pos(&self) -> Option<(i32, i32)> {
let point = MacosApi::cursor_pos();
#[allow(clippy::cast_possible_truncation)]
Some((point.x as i32, point.y as i32))
}
fn set_rect(&self, id: WindowId, rect: &Rect) -> Result<()> {
// `Window::set_position` 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)
.map_err(|e| Self::failed(id, &window, e))
}
fn focus(&self, id: WindowId, mouse_follows_focus: bool) -> Result<()> {
let window = self.lookup(id)?;
window
.focus(mouse_follows_focus)
.map_err(|e| Self::failed(id, &window, e))
}
fn raise(&self, id: WindowId) -> Result<()> {
// macOS has no raise without focus: this sets AXMain and AXFocused, so it focuses
let window = self.lookup(id)?;
window.raise().map_err(|e| Self::failed(id, &window, e))
}
fn hide(&self, id: WindowId) -> Result<()> {
// a sliver in a corner of the display, observable by the app and the user
let mut window = self.lookup(id)?;
window
.hide(self.hiding_position)
.map_err(|e| Self::failed(id, &window, e))
}
fn restore(&self, id: WindowId) -> Result<()> {
let mut window = self.lookup(id)?;
window.restore().map_err(|e| Self::failed(id, &window, e))
}
fn minimize(&self, id: WindowId) -> Result<()> {
let mut window = self.lookup(id)?;
window.minimize().map_err(|e| Self::failed(id, &window, e))
}
fn unminimize(&self, id: WindowId) -> Result<()> {
let mut window = self.lookup(id)?;
window
.unminimize()
.map_err(|e| Self::failed(id, &window, e))
}
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))
}
fn center_cursor_in(&self, rect: &Rect) -> Result<()> {
MacosApi::center_cursor_in_rect(rect).map_err(|e: LibraryError| platform(e))
}
fn monitor_from_point(&self, x: i32, y: i32) -> Option<MonitorId> {
MacosApi::monitor_from_point(objc2_core_foundation::CGPoint::new(
f64::from(x),
f64::from(y),
))
.map(MonitorId::from_u32)
}
fn begin_batch(&self) {
// SkyLight holds back everything that changes on screen until the guard is dropped
self.batches.lock().push(BatchedUpdates::begin());
}
fn end_batch(&self) {
drop(self.batches.lock().pop());
}
}