# The platform seam: evidence Status: evidence document (step 7a of the consolidation). No `Backend` trait is proposed here as code, and none may be written before this document has been reviewed. Every statement about the code carries a citation; where I could not confirm something it says "not verified" and the item is collected in section 8. ## 1. Purpose and how to read it A shared `Backend` seam has to sit between code that is to be shared (windows, containers, workspaces, monitors, commands, event handling) and three platform layers that today do the same jobs in three different ways. A trait written from memory of one platform would bake that platform's assumptions into the other two. This document records what each platform actually does, so that the trait can be derived from facts. - **Facts** are the tables and the numbered findings, each with a `file:line` citation read on the tips below. Nothing in a table is a proposal. - **Proposals** are confined to section 7 (consequences). Each consequence is a statement about what the seam must satisfy, with the facts that support it, and is marked CONFIRMED, CORRECTED or TARGET (not true of any current code, but something the seam would have to make true). - "Not verified" means I read the code and could not settle the point by reading (usually: Apple or Microsoft runtime behaviour, or behaviour that needs a Mac). It does not mean "false". Tips read: integration line `wip-main` at `aa52fdc4d9e5` (`build(nix): relock the nested flakes after the step 1 and crates changes`). The three trees live in one checkout: | Tree | Prefix used below | Path | |---|---|---| | Windows (repo root) | `W/` | `komorebi/src/` | | KDE | `K/` | `komorebi-for-kde/komorebi/src/` | | macOS | `M/` | `komorebi-for-mac/komorebi/src/` | Shared crates are cited with their full path (`komorebi-layouts/src/rect.rs`). Only reading and `grep` were used; nothing was compiled or run, and no macOS or Windows runtime behaviour was observed. ## Corrections to earlier claims Things in the plan (`generic-percolating-matsumoto.md`, Step 7 and "Risks") or the progress log that this reading contradicts or refines. 1. **KDE `Backend` method count.** The trait declares **31** methods: 19 required (`K/backend/mod.rs:242-258`, `325-389`, `408-432`) and 12 provided (`:280`, `:311`, `:473`, `:504`, `:529`, `:553`, `:586`, `:597`, `:607`, `:612`, `:617`, `:622`). The plan's "28 (not 31)" matches only if the three derived helpers `is_window`, `window_rect`, `monitor_from_point` (`:612-628`, written in terms of `window` and `monitors`) are left out. Use "28 primitives plus 3 derived". 2. **macOS `monitors()` is not a cache** (already corrected by the kfm A0 report, confirmed). `MacosApi::latest_monitor_information` queries `NSScreen` live and does `MainThreadMarker::new().unwrap()` (`M/macos_api.rs:212-213`), so it panics off the main thread; every caller is already on main (section 3.3). "monitors() reads a cache refreshed by the display callback" is a TARGET (consequence 4). 3. **Consequence 2 is too strong as written.** "`set_rect` is never read back to confirm" is false of macOS: after a direct write the layout reads the frame back to learn whether the app refused the width (`M/window.rs:944-972`, `M/arrival.rs:1-12`), and the animator verifies each journey (`M/animation/animator.rs:902`). KDE returns an optimistic value: `set_rect` writes the requested rect into its own table at once (`K/kwin/state.rs:596-600`, called from `K/kwin/mod.rs:855`). The defensible contract is in section 7. 4. **The KDE `u32` id is not "the compositor's".** The doc comments say so (`K/backend/mod.rs:65`, `K/window.rs:78-81`), but the id is a counter that the daemon assigns to each KWin UUID string (`K/kwin/state.rs:153-154`, `603-611`). KDE already has a registry (uuid <-> id) behind its backend, and `KWinBackend::command` resolves every command through it (`K/kwin/mod.rs:797-806`). 5. **The macOS deadlock has more entry points than the plan lists.** The plan names `destroy_all_borders` from the event thread (`M/process_event.rs:128, 139`). A blocking main-queue dispatch under the window-manager lock also happens from the event thread at `M/process_event.rs:791` (`activate_finder`, `M/macos_api.rs:492`), and from command threads (`M/process_command.rs:294, 303, 1776, 1802, 1806`) and workspace switching (`M/window_manager.rs:1385`, `2286`). Section 3.3. 6. **Windows HWND width**: the code never narrows an HWND, so it assumes nothing about 32-bit significance (section 4). The Microsoft-documented fact that handles are 32-bit significant is not verified here. 7. **Counts** (re-measured; method in section 2.3). KDE trait: 31 (not 28, see 1). Windows `WindowsApi`: 71 distinct functions used outside `windows_api.rs` (matches), 280 call sites in `W/` plus 3 in `komorebi-bar/src/bar.rs` = 283 (earlier survey 279). The generic / Windows-only split depends entirely on the rule used; with the rule stated in 2.3 it is 41 / 30 functions and 219 / 61 sites (earlier survey 42 / 29 and 209 / 70). macOS: 14 `MacosApi` functions, 5 of 8 `CoreGraphicsApi` functions, 13 `AccessibilityApi` functions are used outside their API files; the earlier "52 raw AX sites, 33 in window.rs" does not reproduce for the total: **67** `AccessibilityApi::` sites in 12 files, 33 of them in `M/window.rs` (that part agrees). 8. **The plan's list of macOS main-thread-only work is incomplete in one respect.** The AX observer and event-tap callbacks run on main and do synchronous AX IPC to other applications there (`M/window.rs:288-350`, `M/input_event_listener.rs:96-100`): "main thread" is not just "cheap UI work". 9. **macOS has no focus-follows-mouse implementation**, only config/state fields (`grep` finds `FocusFollowsMouse` in `M/static_config.rs`, `M/state.rs`, `M/core/mod.rs` and nowhere else); KDE and Windows both have one. The plan lists focus-follows-mouse among platform subsystems; for macOS it is absent, not platform-specific. ## 2. The operation table Conventions. "Rect" is the shared `komorebi_layouts::Rect`: `left`, `top` are the origin and **`right`, `bottom` hold the width and height**, not the far edge (`komorebi-layouts/src/rect.rs:19-28`; the conversions `From` at `:32-41`, `From` at `:67-76`). "Queued" means the call returns before the platform has acted; "sync" means the calling thread waits for the platform to answer. Row numbers are for reference in later sections. ### 2.1 Operations present on at least two platforms | # | Operation | KDE `Backend` method | Windows | macOS | Semantic differences | |---|---|---|---|---|---| | 1 | Set frame | `set_rect(id, &Rect)` `K/backend/mod.rs:325`; `KWinBackend::set_rect` `K/kwin/mod.rs:852-857` | `Window::set_position(&Rect, top)` `W/window.rs:374`; `WindowsApi::position_window` `W/windows_api.rs:501-555` (SetWindowPos `:639-652`); `move_window` `:655-666` | `Window::set_position(&Rect)` `M/window.rs:915`; direct path `set_position_direct` = `set_point` then `set_size`, two AX writes `M/window.rs:1050-1058, 1148-1184`; animated path `animator::retarget` `M/window.rs:1061-1082` | **KDE: queued.** A `geo` command is pushed (`command_rect` `K/kwin/mod.rs:786-788`) and carried out as the answer to a held `Poll` (`:268-300`, 1 ms coalescing `:141`); the requested rect is written into the table at once (`K/kwin/state.rs:596-600`). **Windows: sync by default** (`WINDOW_HANDLING_BEHAVIOUR` default `Sync`, `W/lib.rs:266-267`, `W/core/mod.rs:816-822`); with `Async` and `with_async_window_pos` the call adds `ASYNC_WINDOW_POS` (`W/windows_api.rs:521-528`). **macOS: sync AX round trips** (`M/animation/animator.rs:6-7`) when animation is off; queued to the animator lane when on. Rect: Windows adds the DWM shadow offset before the call (`W/windows_api.rs:534-540`, `shadow_rect` `:808-821`); KDE sets `frameGeometry` (`K/kwin/bridge.js:378-381`); macOS writes AX position and size as written. A client may refuse the size (documented for KDE `K/backend/mod.rs:319-320`; macOS reads it back, `M/arrival.rs:1-12`). Errors: KDE only for an unknown or dead id (`K/kwin/mod.rs:797-806`); Windows returns the `SetWindowPos` error (`.process()` `:651`); macOS returns the AX error and a failed write asks the reaper whether the window is gone (`M/window.rs:1155-1162`, `M/reaper.rs:164-173`). | | 2 | Get frame | `window_rect(id)` (provided) `K/backend/mod.rs:617-619` over `window(id)` `:245` | `WindowsApi::window_rect(hwnd)` `W/windows_api.rs:789-802` | `MacosApi::window_rect(&AXUIElement) -> CGRect` `M/macos_api.rs:385-427` | KDE: from the daemon's table, never waits on KWin; holds the optimistic requested rect until a `geometry` event replaces it (`K/kwin/state.rs:594-600`). Windows: **visible bounds** (`DWMWA_EXTENDED_FRAME_BOUNDS`, without the shadow) with fallback to `GetWindowRect` (includes shadow) (`:792-801`). macOS: AX position and size, two sync IPC calls, errors logged and returned (`:386-410`). macOS takes an **element, not an id**. | | 3 | Focus | `focus(id, mouse_follows_focus)` `K/backend/mod.rs:332`; `K/kwin/mod.rs:859-880` | `Window::focus` `W/window.rs:591-622` -> `WindowsApi::raise_and_focus_window` `W/windows_api.rs:722-747` | `Window::focus` `M/window.rs:1084-1139` | KDE queues an `activate`, then (if mouse follows) spawns a task that polls up to 60 x 5 ms for the window to be foreground before moving the pointer (`:864-877`). Windows: a synthetic `SendInput` to pass the foreground lock, `SetWindowPos` (error ignored), then `SetForegroundWindow`, whose failure is the returned error; skips the work when already foreground (`W/window.rs:599-607`). macOS: tries the private SkyLight `set_front_process`, else `NSRunningApplication::activateWithOptions` (`M/window.rs:1089-1102`, `M/skylight.rs:206`), then sets `AXMain` (`:1131`); tabbed apps use `main_window()` (`:1105-1129`). Pointer centring reads the frame back over AX. | | 4 | Raise | `raise(id)` `K/backend/mod.rs:339`; `K/kwin/mod.rs:882-884` | `Window::raise` `W/window.rs:791-798` -> `raise_window` `W/windows_api.rs:563-583` | `Window::raise` `M/window.rs:1141-1146` | Windows raises without activating (`SetWindowPos(HWND_TOP, NOACTIVATE)`) and also raises the window's border. macOS has **no raise-without-focus**: it sets `AXMain` and `AXFocused` (`:1144-1145`). | | 5 | Minimize / unminimize | `minimize(id)`, `unminimize(id)` `K/backend/mod.rs:368, 375`; `K/kwin/mod.rs:972-978` | `Window::minimize` `W/window.rs:556-561` (skips an exe containing `komorebi-bar`) -> `minimize_window` `W/windows_api.rs:686`; unminimize is `restore_window` (`SW_SHOWNOACTIVATE`) `:706-708` | `Window::minimize`/`unminimize` set `AXMinimized` true/false `M/window.rs:794-805` | Windows has no separate unminimize, and `show_window` ignores the result (`W/windows_api.rs:668-684`, "TODO: error handling"; `ShowWindowAsync` when `Async`), so these return `()`. KDE and macOS return `Result`. | | 6 | Hide / restore (back of a stack, other workspace) | `hide(id)`, `restore(id)` `K/backend/mod.rs:353, 361`; `K/kwin/mod.rs:886-925` | `Window::hide_with_border`/`restore_with_border` `W/window.rs:488-554` | `Window::hide`/`hide_on` `M/window.rs:602-676`, `restore` `:807-829` | **Three different mechanisms.** KDE: the window is moved to an extra virtual desktop `komorebi-park` that nobody looks at; it stays alive, minimized-false, on its workspace (`K/kwin/mod.rs:55-61` module doc, `K/backend/mod.rs:32-38`). Windows: by `HidingBehaviour`: `Hide` (`SW_HIDE`), `Minimize`, `Cloak` (the undocumented `SetCloak` through COM, `W/com/mod.rs:68-`); **default `Cloak`** (`W/lib.rs:205-206`); komorebi keeps `HIDDEN_HWNDS` (`W/lib.rs:89`, `W/window.rs:495-500`) so it does not take its own hiding for the user's. macOS: the window is **moved to leave a 1-point sliver in a display corner** (`M/lib.rs:334-348`) after its restore rect is stored in the static `WINDOW_RESTORE_POSITIONS` (`M/lib.rs:121`, `M/window.rs:616-676`); restore rewrites position and size. A hidden macOS window is an ordinary window at another position, so the app and the user see it move (observers report it; `PARKED_WINDOWS` bookkeeping `M/window.rs:1152`). | | 7 | Close | `close(id)` `K/backend/mod.rs:382`; `K/kwin/mod.rs:980-982` | `Window::close` `W/window.rs:563-565` -> `close_window` posts `WM_CLOSE` `W/windows_api.rs:694-700` | `AdhocWindow::close(&AXUIElement)` presses the AX close button `M/window.rs:1444-1453`, called on the foreground element `M/process_command.rs:327-332` | All three are requests, not guarantees. macOS returns `Ok(())` when the window has no close button (`:1451`). macOS closes the **foreground element**, not a stored `Window`. | | 8 | Alive check | `is_window(id)` (provided, `window(id).alive`) `K/backend/mod.rs:612-614`; a destroyed window stays queryable with `alive == false` for a while `:18-22` | `WindowsApi::is_window` (`IsWindow`) `W/windows_api.rs:1090`; the reaper polls it every 20 ms `W/reaper.rs:173-210` | `Window::is_valid` = `copy_attribute_names` returns something `M/window.rs:572-574` | KDE: table lookup, never waits. Windows: kernel call, no app involvement, polled without the WM lock (`W/reaper.rs:173-210`). macOS: an AX round trip to the owning app, only driven on mouse-up/key-up from the event tap (`M/input_event_listener.rs:79-82`, `M/reaper.rs:84-93`) and after failed AX writes (`M/reaper.rs:164-173`): macOS has no pollable "is this window gone". | | 9 | Foreground window | `foreground_window() -> Option` `K/backend/mod.rs:248`; `K/kwin/mod.rs:818-820` | `WindowsApi::foreground_window() -> Result` `W/windows_api.rs:718-720` | `MacosApi::foreground_window_id() -> Option` `M/macos_api.rs:429-444`; `foreground_window() -> Option` `:446-456` | KDE: table. Windows: `GetForegroundWindow`; a null handle is an `Err`. macOS: AX system-wide -> focused application -> focused window, then `_AXUIElementGetWindow` (private, `M/accessibility/private.rs:12-16`); `None` whenever any AX step fails; sync IPC. | | 10 | Window list | `windows() -> Vec` `K/backend/mod.rs:242`; `K/kwin/mod.rs:810-812` | `WindowsApi::load_workspace_information` `W/windows_api.rs:437`, `enum_windows` `:433` (callbacks `W/windows_callbacks.rs`) | `MacosApi::load_workspace_information` `M/macos_api.rs:312-374` over `CoreGraphicsApi::window_list_info` `M/core_graphics/mod.rs:126-133` | KDE returns every candidate window with all fields (`WindowInfo` `K/backend/mod.rs:63-91`). Windows and macOS enumerate at start-up (Windows also has `alt_tab_windows`, `W/windows_api.rs:763-771`) and otherwise learn from events. **macOS joins two worlds by title**: CG gives window ids and names, AX gives elements, and the match is `window_by_title` (`==` or `contains`) (`M/application.rs:195-208`, `M/macos_api.rs:346`). | | 11 | Monitors and work areas | `monitors() -> Result>` `K/backend/mod.rs:255`; `K/kwin/mod.rs:830-836` | `WindowsApi::load_monitor_information` `W/windows_api.rs:292`, `monitor(hmonitor)` `:1126-1166` | `MacosApi::latest_monitor_information` `M/macos_api.rs:212-309`, `load_monitor_information` `:96`, `update_monitor_work_areas` `:182` | KDE: table, logical pixels, `Err` when empty (`:831-834`). Windows: `win32_display_data`, physical pixels (process is PerMonitorV2, `W/main.rs:223`, `W/windows_api.rs:1169-1172`), `id: isize` (`W/monitor.rs:33`). macOS: **live** query of `NSScreen`, CoreGraphics and IOKit (`:213-221`), main-thread only (`:213`), `id` is the `CGDirectDisplayID`; work area is `NSScreen.visibleFrame` flipped into Quartz coordinates (`:275-291`). | | 12 | Monitor from point | `monitor_from_point(x, y)` (provided) `K/backend/mod.rs:622-628` | `WindowsApi::monitor_from_point` `W/windows_api.rs:492-496` | `CoreGraphicsApi::display_with_point` `M/core_graphics/mod.rs:102-123`, `MacosApi::monitor_from_point` `M/macos_api.rs:481-483` | KDE and macOS return `None` outside every monitor; Windows uses `MONITOR_DEFAULTTONEAREST` so it **never fails** (`:493`). KDE reads `size.left + size.right` (origin plus width) (`:624-625`). | | 13 | Cursor position | `cursor_pos() -> Option<(i32,i32)>` `K/backend/mod.rs:258`; `K/kwin/mod.rs:838-850` | `WindowsApi::cursor_pos() -> Result` `W/windows_api.rs:847-852` | `MacosApi::cursor_pos() -> CGPoint` `M/macos_api.rs:475-479` | KDE is the odd one: it asks the KWin script and **blocks the caller up to 250 ms** (`:846`), falling back to the last position seen; Windows and macOS read it directly. | | 14 | Center cursor | `center_cursor_in(&Rect)` `K/backend/mod.rs:389`; `K/kwin/mod.rs:991-1001` | `WindowsApi::center_cursor_in_rect` `W/windows_api.rs:862-864` | `MacosApi::center_cursor_in_rect` `M/macos_api.rs:376-381` | **KDE ignores the rect**: KWin can only centre on the active window, so the call is correct only right after focusing that window (`:992-993`). Windows and macOS centre on the rect (`left + right/2`, width halved). KDE queues; the other two are sync. | | 15 | Set wallpaper | `set_wallpaper(monitor_id, &Path)` `K/backend/mod.rs:586`; `K/kwin/mod.rs:1050-1084` | `WindowsApi::set_wallpaper(path, hmonitor)` `W/windows_api.rs:1488-1510` | `MacosApi::set_wallpaper(path, display_id)` `M/macos_api.rs:56-91` | KDE: validates the monitor and the file, then a task asks Plasma and retries; returns at once. Windows: **sync** COM `IDesktopWallpaper`. macOS: `exec_async` on the main queue and `Ok(())` regardless (`:62, 90`; failures only logged `:83`). | | 16 | Maximize | `maximize(id, bool)` (provided, default no-op) `K/backend/mod.rs:607`; `K/kwin/mod.rs:984-989` | `Window::maximize`/`unmaximize` `W/window.rs:567-589` -> `W/windows_api.rs:710-716` | **none**: `Workspace::maximized_window` exists (`M/workspace.rs:52, 124`) but is never assigned `Some`; `ToggleMaximize` is commented out (`M/core/mod.rs:321`) | The "maximized" state query is also generic: `WindowInfo::maximized` (KDE) and `is_zoomed` (Windows, 9 sites). | | 17 | Opacity | `set_opacity(id, u8)` (provided, default no-op) `K/backend/mod.rs:529`; `K/kwin/mod.rs:1017-1022` | `Window::transparent`/`opaque` `W/window.rs:625-`, `set_transparent` `W/windows_api.rs:1346-1358` | **none**: transparency config is commented out (`M/static_config.rs:535-541`); `kCGWindowAlpha` is read only to filter windows (`M/window.rs:1547`) | Both implementations take a `u8` alpha (KDE divides by 255, `:1020`). | | 18 | Workspaces / desktops | five methods `ensure_workspaces`, `switch_workspace`, `set_window_workspace`, `window_workspace`, `current_workspaces` `K/backend/mod.rs:408-432` | **none in the OS**: workspaces are komorebi's own, made visible by hide/restore (row 6) | **none in the OS**: komorebi's own; a guard ignores events and commands while the macOS Space is not the managed one (`M/process_event.rs:120-140`, `M/lib.rs:184`) | KDE alone delegates workspaces to the compositor (virtual desktops, per output, `K/kwin/mod.rs:47-54`). The shared core cannot call these five on Windows or macOS; it needs a capability query or a split (section 7, consequence 3). | | 19 | Focus follows mouse | `set_focus_follows_mouse(bool)` (provided, default no-op) `K/backend/mod.rs:553`; rewrites `kwinrc` `K/kwin/mod.rs:1024-1048` | `enable_`/`disable_`/`focus_follows_mouse` `W/windows_api.rs:1224-1257` (system setting) plus a komorebi implementation on its own thread `W/process_movement.rs:14-60` | **none** (finding 9 above) | KDE and Windows each ask the OS to do it; Windows also has its own mouse-hook implementation. | | 20 | Window identity fields | `WindowInfo` fields `pid`, `title`, `class`, `exe`, `path` `K/backend/mod.rs:63-75` | `window_thread_process_id` `W/windows_api.rs:866-879`, `window_text_w` `:1004`, `real_window_class_w` `:1052`, `exe`/`exe_path` `:1031-1050` (each a separate call) | `Window::title/exe/path/bundle_identifier/role/subrole` `M/window.rs:833-905`; no window class, a bundle identifier instead | KDE delivers them in one snapshot with the window; Windows asks per field (the hook callback comments on the cost, `W/windows_callbacks.rs:197-203`); macOS asks the app per field over AX. | ### 2.2 Platform subsystems (one platform, or the same name with unrelated implementations) | Subsystem | Windows | KDE | macOS | |---|---|---|---| | Borders | own `WS_EX_TOPMOST` windows each on a thread with a message loop `W/border_manager/border.rs:304-340`, `W/windows_api.rs:1322-1344` | **delegated to the compositor** through `Backend::apply_look`, `set_border_colours` `K/backend/mod.rs:473, 504`; `K/kwin/effects.rs` | one `NSWindow` per border created on main (`exec_async` `M/border_manager/ns_window.rs:53`), AX observers per border `M/border_manager/border.rs:153-170`; thread `M/border_manager/mod.rs:323` | | Stackbars | `W/stackbar_manager/` (windows with their own message-loop thread `W/stackbar_manager/stackbar.rs:117-150`) | none | none | | Animation | ghost windows with DWM thumbnails `W/animation/ghost.rs:1-14`, `W/animation/engine.rs`; dwm_* fns | `Look::animation` is a request to the compositor `K/backend/mod.rs:141-152` | animator thread plus a lane thread per app pid `M/animation/animator.rs:122, 597`; `NSWindow` ghosts `M/animation/ghost.rs:125-142` | | Device / power / session notifications | hidden window with `WTSRegisterSessionNotification`, power and device notifications `W/monitor_reconciliator/hidden.rs:60-110`, `W/windows_api.rs:1410-1426, 1484` | `K/kwin/power.rs:60-72` (logind and screen-lock over D-Bus) | `NSWorkspace` notifications `M/notification_center_listener.rs:183-229`; CG display callback `M/display_reconfiguration_listener.rs:12-30, 76` | | Window-event source | `SetWinEventHook` on a message-pump thread `W/winevent_listener.rs:24-56` | the KWin script reports over D-Bus `K/kwin/mod.rs:228-255` | AX observers and `NSWorkspace` on the main run loop (section 3.3) | | Raw input | mouse hook via `winput` `W/process_movement.rs:14-20` | KWin global shortcuts `K/kwin/hotkeys.rs` | `CGEventTap` `M/input_event_listener.rs:63-160` | | Private/undocumented OS calls | `SetCloak` COM `W/com/mod.rs:68-` | none found | SkyLight: `CGSGetActiveSpace` `M/lib.rs:184`, `SLSDisableUpdate`/`SLSReenableUpdate` batching `M/skylight.rs:18-75`, `_SLPSSetFrontProcessWithOptions` `M/skylight.rs:123, 206`; `_AXUIElementGetWindow` `M/accessibility/private.rs:12-16` | | Pan gestures | `W/pan.rs` | none | `M/pan.rs` (508 lines) | | Compositor-only (KDE) | | rounded-corner effect, kwinrc editing, hotkey daemon (`K/kwin/effects.rs`, `K/kwin/kwinrc.rs`, `K/kwin/hotkeys.rs`) | | | Window style edits, title-bar removal, DWM accents | `update_style`, `remove_title_bar`, `set_window_accent`, `round_corners` `W/window.rs:707-790`, `W/windows_api.rs:985-1002, 1295-1320` | none | none | ### 2.3 Re-measured counts Method (reproducible from the repo root). Windows: every `WindowsApi::` occurrence in `komorebi/src` outside `windows_api.rs`, lines that are `//` comments dropped, trailing `//` comments cut; plus `komorebi-bar/src`. macOS: the same for `MacosApi::`, `CoreGraphicsApi::`, `AccessibilityApi::` in `komorebi-for-mac/komorebi/src` outside `macos_api.rs`, `core_graphics/mod.rs`, `accessibility/mod.rs`. (A naive `grep` over-counts: `M/window_manager.rs:490-492, 2489` are commented-out `MacosApi::` calls.) | API | Distinct fns used outside the API file | Call sites | Defined in the API file | |---|---|---|---| | `WindowsApi` | 71 | 280 in `W/` + 3 in `komorebi-bar/src/bar.rs` = 283 | 92 `pub fn` lines (some duplicated under `cfg`) | | `MacosApi` | 14 | 78 | 14 | | `CoreGraphicsApi` | 5 | 13 | 8 (+1 free fn `display_for_frame` `M/core_graphics/mod.rs:143`) | | `AccessibilityApi` | 13 | 67 (33 in `M/window.rs`, 11 in `M/application.rs`, 4 each in `M/animation/animator.rs`, `M/border_manager/border.rs`, `M/process_event.rs`, rest 1-2 each) | 13 | | KDE `Backend` | 31 methods = 19 required + 12 provided (28 primitives + 3 derived) | 65 `backend()` call sites outside `K/backend/` (non-comment lines) | n/a | Split rule. A function is **generic** when the operation it performs has a counterpart on at least one other platform (a KDE `Backend` method or a macOS function in the table above). It is split in two tiers because the boundary is a judgement: - G1 (strict): the operation is in section 2.1. - G2: a window-state read or z-order helper with no counterpart today but that a shared `WindowInfo` or `Backend` could carry (`lower_window`, `bring_window_to_top`, `is_window_visible`, `is_window_cloaked`, `monitor_from_window`, `get_transparent`, `process_handle`, `close_process`, `window_at_cursor_pos`). - Platform-only (W / P): everything else (class registration, DWM thumbnails, GWL styles, notification registration, raw AX primitives, ...). | Platform | G1 fns / sites | G2 fns / sites | Platform-only fns / sites | |---|---|---|---| | Windows (71 fns, 283 sites) | 32 / 196 | 9 / 23 | 30 / 61 | | macOS (`MacosApi` + `CoreGraphicsApi` + `AccessibilityApi`: 32 fns, 158 sites) | 15 / 83 | 1 / 4 (`left_mouse_button_is_pressed`) | 16 / 71 (13 raw AX primitives, `frontmost_process_id`, `activate_finder`, `contains_rect`) | Windows G1 + G2 = 41 fns / 219 sites (earlier survey 42 / 209); platform-only = 30 / 61 (earlier 29 / 70). The per-function counts are in 2.4 so that the split can be re-derived with another rule. What the macOS count says about the seam: 67 raw AX call sites live outside `accessibility/mod.rs`, 33 of them in `M/window.rs` and 11 in `M/application.rs`; `M/macos_api.rs` itself has only 6. Most of the macOS seam is therefore the `Window` and `Application` types, not the `*Api` structs, which is why step 6 normalises `Window` methods first. ### 2.4 Per-function counts (name:sites) Windows, `WindowsApi::` outside `windows_api.rs` (class in brackets: G1, G2, W): - G1: window_rect:39, foreground_window:26, is_window:21, restore_window:16, position_window:15, is_zoomed:9, disable_focus_follows_mouse:6, center_cursor_in_rect:6, real_window_class_w:5, raise_and_focus_window:5, monitor:4, is_iconic:4, focus_follows_mouse:4, enable_focus_follows_mouse:4, window_thread_process_id:3, maximize_window:3, hide_window:3, close_window:3, unmaximize_window:2, set_transparent:2, raise_window:2, minimize_window:2, load_monitor_information:2, cursor_pos:2, window_text_w:1, set_wallpaper:1, move_window:1, monitor_rects:1, monitor_from_point:1, load_workspace_information:1, exe_path:1, exe:1 - G2: is_window_visible:6, is_window_cloaked:4, window_at_cursor_pos:2, process_handle:2, monitor_from_window:2, lower_window:2, get_transparent:2, close_process:2, bring_window_to_top:1 - W: module_handle_w:8, register_class_w:6, dwm_update_thumbnail_properties:5, dwm_unregister_thumbnail:4, desktop_window:4, register_device_notification:3, dwm_register_thumbnail:3, create_solid_brush:3, update_style:2, update_ex_style:2, set_window_accent:2, wts_register_session_notification:1, set_process_dpi_awareness_context:1, set_border_pos:1, register_power_setting_notification:1, process_id_to_session_id:1, monitors_have_same_dpi:1, monitor_info_w:1, left_click:1, invalidate_rect:1, gwl_style:1, gwl_ex_style:1, foreground_lock_timeout:1, enum_windows:1, enum_display_monitors:1, dpi_for_monitor:1, current_process_id:1, create_hidden_window:1, create_border_window:1, allow_set_foreground_window:1 macOS, outside the API files (G1, G2, P): - G1: MacosApi::window_rect:39, MacosApi::foreground_window_id:14, MacosApi::foreground_window:7, MacosApi::center_cursor_in_rect:3, MacosApi::load_monitor_information:2, MacosApi::cursor_pos:2, MacosApi::update_monitor_work_areas:1, MacosApi::set_wallpaper:1, MacosApi::monitor_from_point:1, MacosApi::load_workspace_information:1, MacosApi::latest_monitor_information:1, CoreGraphicsApi::display_bounds_for_window_rect:5, CoreGraphicsApi::display_bounds:4, CoreGraphicsApi::window_list_info:1, CoreGraphicsApi::connected_display_ids:1 - G2: MacosApi::left_mouse_button_is_pressed:4 - P: MacosApi::frontmost_process_id:1, MacosApi::activate_finder:1, CoreGraphicsApi::contains_rect:2, AccessibilityApi::copy_attribute_value:17, AccessibilityApi::window_id:11, AccessibilityApi::set_attribute_cf_value:9, AccessibilityApi::set_attribute_ax_value:8, AccessibilityApi::set_messaging_timeout:6, AccessibilityApi::invalidate_observer:3, AccessibilityApi::create_observer:3, AccessibilityApi::copy_attribute_names:3, AccessibilityApi::add_observer_to_run_loop:3, AccessibilityApi::remove_notification_from_observer:1, AccessibilityApi::perform_action:1, AccessibilityApi::create_application:1, AccessibilityApi::add_notification_to_observer:1 ## 3. Threads, locks and who calls the platform Common to all three trees: the window manager is `Arc>`; the event consumer thread takes `wm.lock()` and holds it for the whole event; command connections take it with `try_lock_for(1 s)` on UDS (`K/process_command.rs:2125`, `M/process_command.rs:1882`, `W/process_command.rs:2280`; the Windows TCP path uses a blocking `lock()`, `:2333`). The event channel producers use `try_send` on KDE and macOS (`K/window_manager_event_listener.rs:55`, `M/window_manager_event_listener.rs:54`) and a blocking `send` on Windows (`W/windows_callbacks.rs:212, 233`); see the step 5 prep entries for the channel facts, not repeated here. ### 3.1 KDE: who calls `backend()` `backend()` returns `OVERRIDE` (thread-local) else the process-wide `BACKEND` (`RwLock>>`) else, in the library's own unit tests only, a permissive fake installed in the calling thread's thread-local (`K/backend/mod.rs:631-657, 661-673`). 65 call sites outside `K/backend/` (`window.rs` 17, `window_manager.rs` 15, `workspace.rs` 5, `transparency_manager.rs` 5, `process_command.rs` 5, `process_event.rs` 4, `monitor.rs` 4, `desktop.rs` 4, `border_manager/mod.rs` 3, `static_config.rs` 2, `main.rs` 1; non-comment lines, counted by `grep -rn "backend()"`); all of them are reached from window-manager code. | Thread | Started at | Locks it takes | Calls `backend()`? | |---|---|---|---| | main | process entry `K/main.rs:271` | WM lock for `init`, `sync_workspace_desktops`, `retile_all`, colour/transparency setup (`:320-342`); again for the restore at stop (`:382-386`) | **Yes**: indirectly through those WM methods, and directly at `K/main.rs:387` (`backend().shutdown()`, WM lock released). Installs the backend first (`:281`/`:284`). Then blocks on `quit_receiver.recv()` (`:379`), so it is idle while running. | | `kwin-bridge-main` plus two tokio workers `kwin-bridge` | `K/kwin/mod.rs:366-412` | `Shared::table`, `Shared::queue` (own mutexes); **never the WM lock** | **No** (no `backend()` in `K/kwin/*.rs` outside tests, `grep`). The D-Bus handlers `Events::event` (`:230-255`) and `Poll::poll` (`:268-300`) only update the table, `try_send` window and monitor notifications (`Shared::dispatch` `:191-206`), push commands, answer cursor waiters and call `hotkeys::focus`. | | tokio tasks spawned by the backend on that runtime | `K/kwin/mod.rs:865, 995, 1081`; `K/kwin/power.rs:66-70` | `Shared` mutexes | No: they move the pointer, ask Plasma for a wallpaper, watch logind (they send monitor notifications). | | `kwin-hotkeys` (current-thread runtime) | `K/kwin/hotkeys.rs:599-617` | its own | No: a key runs `komorebic` as a child process (`K/kwin/hotkeys.rs:356-360`), which reaches the daemon like any other client, i.e. through a command thread. | | event consumer | `K/process_event.rs:132-149` | **WM lock held for each event** (`:136`) | **Yes**: `backend().window`, `foreground_window`, `drag_origin`, `drag_end` directly (`:460, 521, 796, 864`) and everything in `Window` methods. | | command accept thread, restarted on panic, and one thread per client | `K/process_command.rs:296-330` | WM lock for `try_clone` of the listener (`:303-306`); then `try_lock_for(1 s)` per connection (`:2125`) | **Yes**: every command is processed under the WM lock. | | monitor reconciliator | `K/monitor_reconciliator.rs:155-` | WM lock per notification (`:538, 545, 552, 559, 567`) | **Yes**: `desktop::latest_monitor_information` = `backend().monitors()` (`K/desktop.rs:83-89`) under the lock. | | settle thread (spawned once per burst) | `K/monitor_reconciliator.rs:123-135` | WM lock after 1.5 s (`:127`) | **Yes**: `follow_windows_to_their_monitors`. | | deadlock detector (feature `deadlock_detection`) | `K/main.rs:136-159` | none | No. | | tests | `with_backend` `K/backend/mod.rs:697-707` | n/a | see below | Findings (KDE). 1. **Every `Backend` call from non-test code happens either at start-up/shutdown on the main thread, or from a thread that holds the WM lock.** The KWin bridge threads never call it. So a `Backend` method may block the WM lock but never the bridge. 2. **Two kinds of backend methods block their caller**, which is why finding 1 matters. `cursor_pos` blocks up to 250 ms on a reply that arrives on the bridge runtime (`K/kwin/mod.rs:841-849`). `ensure_workspaces`, `hide` when the park desktop does not exist yet, `apply_look`, `set_border_colours`, `set_focus_follows_mouse` and `shutdown` use `Handle::block_on` (`Shared::block_on` `:178-183`, uses at `:901, 931, 1007, 1027, 1097, 1114`). `block_on` from inside a tokio worker panics (tokio's documented behaviour, not re-verified here), so the code relies on no runtime thread calling `backend()`; finding 1 shows that holds today. 3. **Tests.** `with_backend` sets a **thread-local** override (`:633-635, 697-707`), so a thread spawned inside it does not see the test's fake: it falls through to `BACKEND` (unset in a test), then to a fresh permissive fake of its own in unit tests, or panics with "no backend installed" elsewhere (`:649-657`). In integration tests (`komorebi/tests/*.rs`) the library is not built with `cfg(test)`, so `test_default` returns `None` (`:670-673`) and a test must install a backend. 54 of the 60 files in `komorebi-for-kde/komorebi/tests/` mention `with_backend`, `set_backend` or `FakeBackend`; the six that do not are `power.rs`, `config_parity.rs`, `hot_reload.rs`, `no_kwin.rs`, `version.rs`, `socket.rs`. Production code that spawns a thread which calls `backend()` would work under `set_backend` but silently get a different (permissive) fake in a unit test: nothing today does (the only test-spawned thread, `K/process_command.rs:2229`, only reads a socket). 4. **`FakeBackend`** records 18 command kinds (`K/backend/fake.rs:43-81`), has a `min_sizes` table so a window can refuse a size (`:90`), and has **no failure injection**: its only error is `no such window` (`:509`). It has no frame offset (shadow) model either. ### 3.2 Windows: who calls `WindowsApi` `WindowsApi` is a unit struct of associated functions (`W/windows_api.rs:252-254`), so there is no state and no handle: any thread may call any function. Threads, from `W/main.rs` and the `listen_for_*` functions: | Thread | Started at | WM lock | `WindowsApi` calls (examples) | |---|---|---|---| | main | `W/main.rs` | before the loop: `init`, `retile_all` (`:327`); after ctrl-c: `restore_all_windows` (`:368`) | start-up (`:205-223`: `allow_set_foreground_window`, `set_process_dpi_awareness_context`, `foreground_lock_timeout`), shutdown `disable_focus_follows_mouse` (`:372`). Otherwise blocked in `ctrlc_receiver.recv()` (`:357`): **there is no message pump on the main thread**. | | WinEvent pump | `W/winevent_listener.rs:24-56` | none | **Calls `SetWinEventHook` and then runs `GetMessageW` / `DispatchMessageW` on the same thread** (`:28-50`). The hook callback `win_event_hook` (`W/windows_callbacks.rs:144-236`) runs here: `window.exe()`, `title()`, `class()` (`:224-230`), `SendNotifyMessageW` to a border, and a **blocking** `event_tx().send(..)` (`:212, 233`). | | event consumer | `W/process_event.rs:117-134` | held per event (`:121`) | every `Window` method: `position_window`, `restore_window`, `foreground_window`, ... | | command threads (UDS accept + one per client, and TCP) | `W/process_command.rs:105-130`, `:151` | `try_lock_for(1 s)` (`:2280`); TCP blocking `lock()` (`:2333`) | the same, via `process_command` | | reaper (two threads) | `W/reaper.rs:58, 155` | handler: WM lock (`:79`); poller: **none** | poller calls `Window::is_window`/`is_visible` every 20 ms (`:173-203`) | | border manager | `W/border_manager/mod.rs:390` (+ watcher `:365`, helper threads `:1068-1194`) | WM lock per notification (`:421`) | `window_rect`, `set_border_pos`, border window create/destroy | | one thread per border window | `W/border_manager/border.rs:304-340` | none | **creates the border window (`create_border_window`) and runs its own `GetMessageW` loop**; the `Border` is owned by that thread (`W/border_manager/mod.rs:1054-1056`) | | one thread per stackbar window | `W/stackbar_manager/stackbar.rs:117-150` | none | `CreateWindowExW` and its own message loop; `position_window` is called from another thread under a documented ordering constraint (`:178-193`) | | stackbar manager | `W/stackbar_manager/mod.rs:74-98` | WM lock (`:98`) | stackbar updates | | hidden window thread | `W/monitor_reconciliator/hidden.rs:82-100` | none | `create_hidden_window`, message loop; registers WTS session, power and device notifications | | monitor reconciliator | `W/monitor_reconciliator/mod.rs:226-` | WM lock (`:276`) | `load_monitor_information`, `monitor` | | transparency, focus manager, theme manager | `W/transparency_manager.rs:52`, `W/focus_manager.rs:47`, `W/theme_manager.rs:57` | WM lock (`:87`, `:67`; theme: none) | `set_transparent`; `Window::focus` (`W/focus_manager.rs:68`) | | live resize | `W/live_resize.rs:707-775`, `:442`, `:533`, `:760` | WM lock per step (`:732, 767`) | `position_window` (`:522, 548, 1044`) | | animation engine | `W/animation/engine.rs:100-130, 217-235` (frame thread, a scoped thread per animation, a landing thread per animation) | none; `ANIMATION_MANAGER` lock only | `position_window` through the dispatchers (`W/animation/ghost_dispatcher.rs:376, 513, 599, 673`), DWM thumbnails | | ghost owner | `W/animation/ghost.rs:154-156` | none | creates the host window, owns the thumbnails (`:10-14`) | | `winput` mouse thread | `W/process_movement.rs:14-20` | WM lock per move (`:20, 32`) | `raise_window_at_cursor_pos` | | config watcher and loader | `W/window_manager.rs:348, 465`, `W/main.rs:304` | WM lock inside | everything | Thread-affinity findings (Windows). 1. **Documented or evident thread-affine operations are all window creation and message handling, not window manipulation.** A window must be created by a thread that pumps its messages (`W/animation/ghost.rs:10-14`); the border's Direct2D brush update must run on the window's own thread, which is why it is posted as `WM_UPDATE_BRUSHES` (`W/border_manager/border.rs:75-79, 457-459`); the `Border` is owned by the thread that created its window (`W/border_manager/mod.rs:1054-1056`); the WinEvent hook is installed on the thread that pumps (`W/winevent_listener.rs:26-53`). That `WINEVENT_OUTOFCONTEXT` callbacks are delivered on the installing thread is Microsoft's documented behaviour, not verified here. 2. **No `WindowsApi` function that acts on a managed window is documented thread-affine, and none is called from a single thread.** `position_window` alone is called from the event thread, command threads, animation threads and live-resize threads (see the list above). The code comments explain the one cross-thread cost: `SetWindowPos` waits for the target window's own thread unless `ASYNC_WINDOW_POS` is used (`W/windows_api.rs:518-520`), which is a property of the target app, not of komorebi's threads. `SetCloak` initialises COM per thread through a thread-local (`W/com/mod.rs:40-45, 68-72`), so it is callable from any thread too. 3. **Consequence for a Windows `Backend` adapter**: it can be a stateless wrapper over `WindowsApi`, callable from any thread, **except for the pieces that create windows** (borders, stackbars, ghosts, the hidden window and the WinEvent hook), which stay with their own message-loop threads and are exactly the "platform subsystems" of section 2.2. 4. **Hazard that exists today**: the hook callback blocks in `send` when the 20-slot channel is full (`W/winevent_listener.rs:59`, `W/windows_callbacks.rs:212, 233`), while the consumer needs the WM lock that a command thread may hold while itself sending into the same channel (`W/window_manager.rs:802, 809, 865`). Reading only; not reproduced (this is the plan's step 5 latent bug 2). ### 3.3 macOS: the main thread, the dispatches and the possible deadlock **The main thread** (`M/main.rs:202-367`) is the only thread that may touch AppKit. `main` creates `NSApplication`, calls `finishLaunching` rather than `run` (`:226-230`), registers the `NSWorkspace` observer and the CoreGraphics display callback (`:232-233`), takes `CFRunLoop::current()` and installs the event tap on it (`:235-236, 322-330`), builds the window manager, starts all other threads (`:282-290`), and then **spins** in a loop: `nextEventMatchingMask(.., 0.1 s)` plus `CFRunLoop::run_in_mode(default, 0.1 s)` (`:314-349`; the comment at `:333-334` says the explicit NSApp pumping is what makes display callbacks fire on macOS Tahoe). Because of the 0.1 s bound, anything added to that run loop from another thread is noticed within 0.1 s even without a wake-up call. The main thread takes the WM lock only before the loop (`:274-280`) and after it (`:354`). What runs on main, from callbacks: AX observer callbacks for windows (`M/window.rs:288-350`) and applications (`M/application.rs:54`), the `NSWorkspace` handler (`M/notification_center_listener.rs:183-229`, selector based, no queue argument), the CG display reconfiguration callback (`M/display_reconfiguration_listener.rs:12-30`, registered `:76`), the event tap callback (`M/input_event_listener.rs:63-100`). **None of them takes the WM lock** (no `lock()` call on `wm` in those files). They do synchronous AX IPC to other applications: the window observer reads the window id, title and, for moves, the frame (`M/window.rs:294, 330`), the tap reads the foreground window (`M/input_event_listener.rs:88-100`). The handlers hand off by `try_send` only (`M/window_manager_event_listener.rs:54`, `M/monitor_reconciliator.rs:63-67`, `M/reaper.rs`, `M/border_manager/mod.rs:168-178`). That `NSWorkspace` posts on main, and where exactly the CG callback is delivered, is Apple behaviour and not verified. AppKit objects are created or touched on main only: `NSScreen::screens(MainThreadMarker::new().unwrap())` panics off main (`M/macos_api.rs:213`; the same in `M/splash.rs:166`); closures dispatched to main use `MainThreadMarker::new_unchecked()` (`M/border_manager/ns_window.rs:55`, `M/animation/ghost.rs:142`, `M/macos_api.rs:66`). **Other threads and their locks** | Thread | Started at | WM lock | Platform calls | |---|---|---|---| | event consumer | `M/process_event.rs:70-86` | held for the whole event (`:74`) | every `Window` method (AX), `CoreGraphicsApi`, `activate_finder` (`:791`), `border_manager::destroy_all_borders` (`:128, 139`) | | "mdm" poller | `M/process_event.rs:52-67` | none | spawns `komorebic splash` | | command accept + per-client threads | `M/process_command.rs:79-110` | `try_lock_for(1 s)` (`:1882`) | everything, incl. `destroy_all_borders` (`:294, 303, 1776, 1802, 1806`), `window.observe(&run_loop)` (`:1758`) | | monitor reconciliator | `M/monitor_reconciliator.rs:71-90` | per notification: a short lock for a state snapshot (`:96-99`, released), then see the dispatch table | `exec_sync` to main (below) | | reaper | `M/reaper.rs:41-70` | held per notification (`:61`) | `Window::is_valid` (AX round trip per window) | | settle | `M/settle.rs:78-88` | `wm.lock()` (`:88`) | AX reads with a 0.25 s timeout (`LOOK_TIMEOUT` `:39`) | | workspace reconciliator | `M/workspace_reconciliator.rs:72-98` | held per notification (`:98`) | focus and workspace switches | | border thread | `M/border_manager/mod.rs:323-338, 353` | WM lock for a snapshot, **released** at `:381` (`drop(state)`), then `BORDER_STATE` and `WINDOWS_BORDERS` locked (`:462-463`) | creates borders: `exec_sync` (`M/border_manager/border.rs:153`) and a **blocking `rx.recv()`** waiting for the main queue to build the `NSWindow` (`M/border_manager/ns_window.rs:52-135`, no timeout) | | theme manager | `M/theme_manager.rs:53` | not taken | `destroy_all_borders` (`:227`) | | animator frame thread | `M/animation/animator.rs:122-126` | **never**: "Nothing in here holds the window manager's lock" (`:15-16`) | none itself; it commits to lanes | | one lane thread per pid | `M/animation/animator.rs:597-600` | never | AX writes and verifies (`:819-850, 902, 925`), and `border_manager::follow` (an `exec_async`, below) | | ctrl-c waiter | `M/main.rs:295-310` | none | sets a flag the main loop reads | | deadlock detector (feature) | `M/main.rs:151-174` | none | | **Every dispatch to the main queue** | Site | Kind | Closure | Takes WM lock on main? | Calling threads, and are they holding the WM lock? | |---|---|---|---|---| | `M/monitor_reconciliator.rs:106` | `exec_sync` | `wm.lock()` then `update_monitor_work_areas` (NSScreen) (`:107-110`) | **Yes** | reconciliator thread, not holding it (the snapshot lock is released) | | `M/monitor_reconciliator.rs:117` | `exec_sync` | `latest_monitor_information` (`:118`) | No | reconciliator thread | | `M/monitor_reconciliator.rs:383` | `exec_sync` | `arc.lock()` then `load_monitor_information` (`:384-386`) | **Yes** | reconciliator thread, which first `drop(wm)` "because the main thread needs the lock" (`:379-381`) | | `M/border_manager/mod.rs:157` (`hide_borders_on`) | `exec_sync` | hide `NSWindow`s | No | `M/window_manager.rs:1385`: **under the WM lock** | | `M/border_manager/mod.rs:307` (`destroy_border`) | `exec_sync` | invalidate the border's AX observer | No | `destroy_all_borders` (while holding `BORDER_STATE`, `:240`) from the event thread (`M/process_event.rs:128, 139`), command threads (`M/process_command.rs:294, 303, 1776, 1802, 1806`), `retile_all` (`M/window_manager.rs:2286`): **all under the WM lock**; and the theme manager thread (`M/theme_manager.rs:227`), whose `handle_notifications()` takes no `wm` argument, so it does not hold the WM lock | | `M/border_manager/border.rs:153` | `exec_sync` | add AX observer sources to the run loop | No | border thread, holding `BORDER_STATE`/`WINDOWS_BORDERS`, not the WM lock | | `M/border_manager/border.rs:244` (`Border::destroy`) | `exec_sync` | `NSWindow::close` | No | via `destroy_border` (`:313`), same callers as above | | `M/macos_api.rs:492` (`activate_finder`) | `exec_sync` | activate Finder | No | event thread (`M/process_event.rs:791`): **under the WM lock** | | `M/border_manager/ns_window.rs:53` | `exec_async` + blocking `rx.recv()` (`:135`) | build the border `NSWindow` | No | border thread; blocks without timeout | | `M/animation/ghost.rs:125` (`on_main`) | `exec_async` + `recv_timeout(750 ms)` (`:49, 129`) | build ghosts | No | the animating thread; bounded wait | | `M/animation/ghost.rs:322, 365` | `exec_async` | animate and close ghosts | No | animator | | `M/border_manager/mod.rs:191, 217` | `exec_async` | `apply_follows` (borders follow the animator) | No (it uses `try_lock` on `BORDER_STATE` and `WINDOWS_BORDERS`, `:200-205`, so it never waits for the border thread) | animator lanes (`follow`, `:185-193`) | | `M/macos_api.rs:62` (`set_wallpaper`) | `exec_async` | `setDesktopImageURL` | No | command thread | `exec_sync` is `dispatch_sync_f` (`dispatch2-0.3.1/src/queue.rs:118-130`): the caller blocks until the main queue has run the closure. Calling it from the main thread itself deadlocks in libdispatch (Apple documents this; not verified here). `main` reaches `destroy_all_borders` once, through `retile_all` at `M/main.rs:280`, and that is safe only because the border thread, the sole creator of borders, is started afterwards (`:282`), so `BORDER_STATE` is empty and `destroy_border` is never called. This is true by reading only. **The possible deadlock, stated precisely (by reading, not reproduced).** Three things must coincide: 1. A thread E (the event consumer, or a command thread) holds the WM lock for the whole of a message (`M/process_event.rs:74`; `M/process_command.rs:1882`). 2. While holding it, E calls a blocking main-queue dispatch from the table above: `destroy_all_borders` (space change, `M/process_event.rs:128, 139`; `retile_all`; several commands), `hide_borders_on` (workspace switch with ghosts, `M/window_manager.rs:1385`) or `activate_finder` (`M/process_event.rs:791`). 3. The monitor reconciliator thread R has dispatched `exec_sync(closure)` for a `Resize` or `DisplayConnectionChange` (`M/monitor_reconciliator.rs:106, 383`) and the main thread has started that closure, which calls `wm.lock()` and therefore waits for E. Then E waits for the main queue (which is inside R's closure), the main thread waits for E's lock, and neither can proceed. The existing mitigations protect only their own pair of threads: R drops the lock before its second dispatch (`:379-381`), and `apply_follows` uses `try_lock` (`M/border_manager/mod.rs:196-205`). `deadlock_detection` (`parking_lot::deadlock::check_deadlock`, `M/main.rs:151-174`) would show main blocked on a `parking_lot` mutex but cannot see that E is waiting on a dispatch semaphore, so no cycle appears in its lock graph. The exposure window is small (a display change at the instant of one of those events). The structural fix, outside this work, is that no closure run on the main thread takes the WM lock. **Relevance for the seam**: this is why consequence 4 (no `Backend` method blocks on main while the WM lock is held) is a rule the extraction must not violate; today the rule is already violated at the sites above. **AX observers and the run loop.** `Window::observe` adds an observer source to the main run loop from whichever thread calls it: the event thread (`M/process_event.rs:614`), a command thread (`M/process_command.rs:1758`) and `main` at start-up (`M/macos_api.rs:341, 349`). Border observers are the opposite: added inside an `exec_sync` on main (`M/border_manager/border.rs:153-170`). Whether adding a source to a run loop from a thread that does not own it is safe is a CoreFoundation property that I did not verify (Apple's threading guide lists `CFRunLoopRef` as thread-safe, from memory, not checked). The code asserts it for windows and avoids it for borders. **Thread-safety assertions in the code.** `AccessibilityUiElement` and `AccessibilityObserver` are `unsafe impl Send + Sync` (`M/lib.rs:282-284, 300-302`), `NsWindow` is `Send` (`M/border_manager/ns_window.rs:38`), `Still` (a `CGImage`) is `Send + Sync` (`M/animation/capture.rs:19-20`), and the animator clones `WindowHandle { element, app }` into lane threads (`M/animation/animator.rs:90-93`). So AX elements already travel between threads; that this is allowed by the AX API is not verified. ## 4. Identity | | Window id | Monitor id | What the platform needs to act on a window | |---|---|---|---| | KDE | `u32`, `Copy`: `Window { id }` (`K/window.rs:78-86`) | `u32` (`K/monitor.rs:42, 64`) | the KWin UUID string, found in the backend's own table: `by_uuid` (`K/kwin/state.rs:153-154`), ids assigned by a counter (`id_for` `:603-611`), resolved for every command (`KWinBackend::command` `K/kwin/mod.rs:797-806`) | | Windows | `isize` HWND, `Copy`: `Window { hwnd }` (`W/window.rs:68-72`) | `isize` HMONITOR (`W/monitor.rs:33, 57`) | nothing else: the HWND is the OS handle; `HWND(as_ptr!(hwnd))` is a cast (`W/windows_api.rs:177-181`) | | macOS | `u32` `CGWindowID`, but **`Window` is not a handle**: it owns `element: AccessibilityUiElement`, `application: Application`, `observer: AccessibilityObserver`, `details` and is `Clone`, not `Copy` (`M/window.rs:417-427`) | `u32` `CGDirectDisplayID` (`M/monitor.rs:38, 60`) | the `AXUIElement` (every read and write), the application's `AXUIElement` and pid (focus, tabbed apps, the animator's enhanced-UI guard), the `AXObserver` (observation only) | ### 4.1 Windows: does an HWND fit in 32 bits? The code never narrows an HWND: it is an `isize` end to end, cast to a pointer with `as_ptr!` (`W/windows_api.rs:177-181`); the socket type is `DebugWindow(isize)` (`W/core/mod.rs:273`), later `i64` by decision D15; `grep` finds no `hwnd as u32/i32/u64` except an animation delay jitter `start + Duration::from_millis(hwnd as u64)` (`W/animation/snapshot.rs:920`) that does not depend on the width. So the Windows code **assumes nothing** about 32-bit significance and does not need it. For a shared `WindowId(u64)`: a positive `isize` converts losslessly; a negative one (a sign-extended 32-bit value) would not map to a small `u64`. Observation, not a guarantee: the Windows guest logs and state dumps from the user's earlier e2e runs (`~/e2e-w0`, read only) contain **440 distinct HWND values from 12345 to 7,804,828; none negative, none above 2^32** (`grep -rhoaE 'hwnd[\\": =]+-?[0-9]{4,}'`; 12345 looks like a test constant). Microsoft documents that handles are 32-bit significant for interoperability between 32- and 64-bit processes; I did not check that text here, so treat "HWND < 2^32" as likely but unproven (section 8). ### 4.2 macOS: acting on a window from a bare id Today nothing on macOS acts from an id: callers hold a `Window` (cloned freely) or a bare element. `AdhocWindow` acts on an `AXUIElement` with no `Window` (`M/window.rs:1436-1530`: `minimize`, `close`, `hide`, `restore`, `raise`, used from `M/process_event.rs:594` and `M/process_command.rs:327-332`), and the animator keeps `WindowHandle { element, app }` per id (`M/animation/animator.rs:90-93`). A `Backend` that takes only a `WindowId` therefore needs a registry that owns, per id: - the window `AXUIElement` (set frame, minimize, raise, close, alive check); - the application `AXUIElement` and pid (focus uses the pid and the id for SkyLight, `M/window.rs:1089`, and the application's name and `main_window()` for tabbed apps, `:1105-1129`; the animator uses the app element, `M/window.rs:1065-1067`); - the `AXObserver` (only to keep or release observation; `Window::new` creates one per window, `M/window.rs:554-570`); - a way to learn it is dead: an AX error on any call (`M/reaper.rs:164-173`), a destroyed notification, or `Window::is_valid` (`M/window.rs:572`). Where macOS builds the id-to-element link today: at start-up by matching the CoreGraphics list to AX windows **by title** (`M/macos_api.rs:326-349`, `M/application.rs:195-208`), and afterwards from each AX notification, where the id comes from the private `_AXUIElementGetWindow` (`M/accessibility/mod.rs:31-39`, `M/accessibility/private.rs:12-16`), which can fail (`window_id(..).ok()` at `M/window.rs:294`). The registry therefore has to be fed from both, and an id the registry has never seen is not actionable. KDE is the precedent: its `Backend` also takes only ids and keeps an id-to-UUID table (above). ### 4.3 Where per-window platform data lives today - **KDE**: `kwin/state.rs` `Table` (entries, `by_uuid`, `next_id`, park desktop) for backend data; `HIDDEN_WINDOWS` (`K/window_manager.rs:52, 1401`) in the core for what komorebi has hidden. - **Windows**: nothing in `Window`; globals keyed by HWND: `HIDDEN_HWNDS` (`W/lib.rs:89`), `known_hwnds` (`W/window_manager.rs:103`), border state keyed by container id with `window_border(hwnd)` (`W/border_manager/mod.rs`), the cloak record (`W/cloaked.rs`), the live-resize and animation tables. - **macOS**: in `Window` and `Application` themselves (element, observer, details), plus globals keyed by id: `WINDOW_RESTORE_POSITIONS` (`M/lib.rs:121`), `PARKED_WINDOWS` (`M/window.rs:121`), `ARRIVALS` (`M/arrival.rs:40-41`), the animator's `Tracks` (`M/animation/animator.rs:95-98`), the border map; and `WindowManager::applications: HashMap` keyed by pid (`M/window_manager.rs:122`) and `known_window_ids` (`:146`). ## 5. Coordinates **The shared rectangle.** `komorebi_layouts::Rect { left, top, right, bottom }: i32` where `left`, `top` are the origin and **`right`, `bottom` are the width and height** (doc comments, `komorebi-layouts/src/rect.rs:19-28`; `add_padding` shrinks `right`/`bottom` by twice the padding, `:113-120`). All three trees re-export it (`W/core/mod.rs:43`, `K/core/mod.rs:46`, `M/core/mod.rs:33`). Consequence: the name `right` is a trap, and `From for RECT` (`:43-51`) copies the fields unchanged, so the result is **not a valid Win32 `RECT`** (`right`/`bottom` are a width and height); it exists only to feed `SetWindowPos`. | | Origin and direction | Unit | Frame measured | Where conversions live | |---|---|---|---|---| | Windows | virtual screen, top-left origin, y down (secondary monitors may have negative coordinates: not verified) | physical pixels: the process is `PerMonitorV2` (`W/main.rs:223`, `W/windows_api.rs:1169-1172`) | `window_rect` = **visible bounds** (`DWMWA_EXTENDED_FRAME_BOUNDS`, no shadow), falling back to `GetWindowRect` (`W/windows_api.rs:789-802`); `RECT` to `Rect` subtracts (`komorebi-layouts/src/rect.rs:32-41`) | shadow compensation in `position_window` (`W/windows_api.rs:534-540`) and `move_window` (`:658-664`), offset computed per call by `shadow_rect` (two syscalls, `:808-821`); monitor rects from `win32_display_data` (`:1151-1152`); the DPI scaling that `window_rect` would need is a TODO comment (`:793-796`) | | KDE | KWin's global logical space, top-left, y down | logical (device-independent) pixels (`MonitorInfo` doc `K/backend/mod.rs:93`) | KWin `frameGeometry` both ways (`K/kwin/bridge.js:217, 378-381`); rounded to integers in the script (`:162-164`) | the script (`bridge.js`) and `K/kwin/state.rs`; the output's `devicePixelRatio` is sent (`bridge.js:239`) but **the daemon never reads it** (no `scale` in `K/kwin/state.rs` or `K/kwin/mod.rs`) | | macOS | Quartz global display space: top-left origin of the main display, y down (AX position, `CGDisplayBounds`) | **points**, never pixels (below) | the AX position and size of the window (`M/macos_api.rs:385-427`) | `CGRect` to `Rect` truncates each `f64` with `as i32` (`komorebi-layouts/src/rect.rs:67-76`), so fractional points are lost; `NSScreen.visibleFrame` (AppKit, bottom-left origin) is flipped into Quartz with the main display's height (`M/macos_api.rs:275-291`); the border and ghost `NSWindow`s flip back the same way (`M/border_manager/border.rs:42-55`, `M/animation/ghost.rs:143-150`); `WindowBounds` to `Rect` (`M/core/rect_ext.rs:20-28`) | **macOS Retina: points versus pixels (previously UNVERIFIED, now read).** There is no `backingScaleFactor`, `scale_factor`, `CGDisplayPixels*` or `contentsScale` anywhere in `komorebi-for-mac/komorebi/src` (`grep -rni`). Monitor sizes are `CGDisplayBounds` (`M/core_graphics/mod.rs:56-58`) and `NSScreen.visibleFrame` (points), windows are read and written through AX (points), and the whole layout therefore runs in points. The only place pixels appear is ghost capture: the `CGImage` width and height "in pixels, which is more than the size of the window in points on a retina display" (`M/animation/capture.rs:19-24`). Not verified: how AX reports on scaled (non-native) resolutions or on mixed-DPI arrangements, which needs a Mac. **Two small traps for a shared geometry layer.** 1. `CoreGraphicsApi::contains_rect(smaller, bigger)` is `CGRectIntersectsRect` (`M/core_graphics/mod.rs:33-35`), so macOS `Rect::contains` (`M/core/rect_ext.rs:13-17`) means *intersects*; it is what assigns a window to a monitor at start-up (`M/macos_api.rs:331`). 2. A window's visible frame is not the frame the OS lets you set on Windows; set and get agree only because `window_rect` returns the visible bounds and `position_window` adds the shadow offset back (`W/windows_api.rs:534-540`). The Windows `set_position` compares `window_rect` to the layout before writing (`W/window.rs:394-396`), so the two must stay consistent. KDE and macOS have no such offset in the code; whether macOS AX frames include a shadow is not verified. The shared fake would have to model a frame offset to exercise the Windows case (plan 7b, "frame offsets"); `FakeBackend` has none today (section 3.1, finding 4). ## 6. Events The channel facts (capacities, `try_send` versus blocking `send`, `ResizeStep` coalescing, the missing `Changed` reconcile, the two latent bugs) were established in the step 5 prep entries of the progress log and are not repeated. What matters for the seam is where events originate and on which thread. | | Event sources | Reach the window manager how | Thread of the producer | |---|---|---|---| | Windows | `SetWinEventHook` (`W/winevent_listener.rs:28-36`); WTS session, power and device notifications on the hidden window (`W/monitor_reconciliator/hidden.rs:60-110`); a 20 ms `IsWindow` poll for dead windows (`W/reaper.rs:173-210`); a `winput` mouse hook (`W/process_movement.rs:14-20`) | `win_event_hook` builds a `WindowManagerEvent` and does a **blocking** `send` into `bounded(20)` (`W/winevent_listener.rs:59`, `W/windows_callbacks.rs:212, 233`); other sources have their own channels | the WinEvent message-pump thread; the hidden-window thread; the reaper poller | | KDE | the KWin script, reporting over D-Bus (`K/kwin/mod.rs:228-255`); logind and screen-lock (`K/kwin/power.rs:60-72`); the compositor's presence on the bus | `Table::handle` turns each report into `Output::Event`/`Monitor`/`Command`/`Cursor` (`K/kwin/mod.rs:191-206`); `try_send` into `bounded(20)` with `ResizeStep` coalescing (`K/window_manager_event_listener.rs:22, 36, 51-56`) | the tokio runtime of the bridge | | macOS | AX observers per application and per window (`M/application.rs:54`, `M/window.rs:288-350`); `NSWorkspace` (`M/notification_center_listener.rs:183-229`); CG display callback (`M/display_reconfiguration_listener.rs:12-30`); `CGEventTap` (`M/input_event_listener.rs:63-100`) | `try_send` into `bounded(20)` (`M/window_manager_event_listener.rs:15, 54`) and the reaper, monitor and border channels | **the main thread**, from the run loop (section 3.3) | Every consumer is a single thread that takes the WM lock for the whole event (`K/process_event.rs:132-149`, `M/process_event.rs:70-86`, `W/process_event.rs:117-134`). ### 6.1 Evidence for "a frame request returns once made; landing is observed later" The three platforms disagree about whether a frame write has landed when the call returns, and each tree has grown a way to find out afterwards: - **KDE**: the command is queued and goes out as the reply to a held `Poll` (`K/kwin/mod.rs:185-188, 268-300`). The trait says so: commands "return once the compositor has accepted them, not once the client has acted on them" (`K/backend/mod.rs:21-22`) and a client "may refuse the size, so the frame the window ends up with is `window_rect`, not `rect`" (`:319-320`). `window_rect` is optimistic until the `geometry` event arrives (`K/kwin/state.rs:594-600`). - **macOS**: "An app is free to refuse a size, and many do" (`M/arrival.rs:1-12`): after a direct write the layout reads the frame back (`M/window.rs:944-972`) and, with animation on, the animator verifies each journey (`M/animation/animator.rs:6-14, 902`); both report to `arrival`, which says "Nothing in this module waits for anything or takes the lock of the window manager, so it can be called from any thread. The layout picks up what has been said the next time that it runs" (`:8-10`). `settle` does it again after a drag: "A window can be told where to go and not get there ... A quarter of a second after the drag ... the windows are looked at and any that are not where they are to be are sent there again, and looked at once more after that" (`M/settle.rs:1-9, 31`). The animator's own moves come back as AX notifications and "must not be mistaken for somebody moving a window" (`M/window.rs:300-301`). - **Windows**: `SetWindowPos` is synchronous unless `ASYNC_WINDOW_POS` (`W/windows_api.rs:518-528`), and the same two ideas exist: `set_position` compares the frame to the layout before writing (`W/window.rs:394-396`), and after a drag the windows are looked at again after 250 ms, "a window that is busy with what an earlier move made it do is seen to be left where that put it" (`W/live_resize.rs:713-724`). So the facts support: **a frame request may complete before, after or never at the requested size; the achieved frame is learned from events or from a separate, possibly slow, query.** They do not support "never read back": section 7, consequence 2. ## 7. Consequences for the seam The seven consequences of plan step 7, each against the facts above. Status: CONFIRMED (the facts support it as written), CORRECTED (the plan was wrong or too strong), TARGET (not true of any current code; a rule the seam would have to make true). **(1) The macOS backend owns an id -> {AX element, pid, observer} registry. CONFIRMED, with additions.** `Window` carries element, application and observer and is cloned into other threads (`M/window.rs:417-427`, `M/animation/animator.rs:90-93`), nothing acts from an id today (section 4.2), and KDE already has the id-to-UUID table behind its backend (`K/kwin/state.rs:153-154, 603-611`). Additions: the registry also needs the application element (focus for tabbed apps, the animator's enhanced-UI guard); the per-id bookkeeping that is global today (`WINDOW_RESTORE_POSITIONS`, `PARKED_WINDOWS`, `ARRIVALS`) belongs behind it or beside it; it has to be fed both by start-up enumeration (a title match, `M/macos_api.rs:326-349`) and by AX events (`_AXUIElementGetWindow` can fail, `M/window.rs:294`); an id it does not know must give an error, as KDE's `command` does (`K/kwin/mod.rs:797-806`). The per-window `AXObserver` can stay a platform subsystem (observation only) if the registry does not need to release it. **(2) The contract is "`set_rect` returns once requested, never read back to confirm; landing is observed through events". CORRECTED.** Not true as stated: macOS reads back (`M/window.rs:944-972`, `M/arrival.rs`, `M/animation/animator.rs:902`), Windows compares before writing (`W/window.rs:394-396`), KDE's `window_rect` returns the request until the event replaces it (`K/kwin/state.rs:594-600`). What the facts support (section 6.1): *`set_rect` is a request. It returns when the request has been made (queued on KDE, issued on Windows and macOS), says nothing about the frame the window ends up with, and may fail only for an unknown or dead window. The achieved frame is reported by events and by `window_rect`, which a backend may serve from a cache that holds the request until an event corrects it; a backend may offer an explicit, possibly slow, read-back, which the layout calls only where it needs the truth (a refused width, the end of a drag).* The shared `FakeBackend` should be able to refuse a size (it already has `min_sizes`, `K/backend/fake.rs:90`) and to delay or drop an event, which it cannot today (no failure injection, section 3.1). **(3) Every method is callable from any non-main thread; main-thread work hides inside the macOS implementation. CONFIRMED, qualified.** KDE: every call comes from a thread holding the WM lock or from start-up/shutdown, never from the bridge runtime, whose `block_on` would panic (section 3.1). Windows: stateless, callable from any thread; only window creation and message loops are thread-affine (section 3.2). macOS: the platform calls are already made from the event, command, reaper, settle, reconciler and animator threads (section 3.3). Qualifications: (a) "hides inside the implementation" must not mean `exec_sync`: four of the current `exec_sync` sites (`M/border_manager/mod.rs:157, 307`, `M/border_manager/border.rs:244`, `M/macos_api.rs:492`) are reached under the WM lock and are the ingredients of the possible deadlock; the hidden main-thread work should be `exec_async` with a bounded wait or data the implementation already holds (consequence 4). (b) **Five `Backend` methods exist only because KDE delegates workspaces to the compositor** (`ensure_workspaces`, `switch_workspace`, `set_window_workspace`, `window_workspace`, `current_workspaces`, `K/backend/mod.rs:408-432`; table row 18). Windows and macOS keep workspaces in the core and implement them with hide and restore (row 6). The trait cannot require them of all three: use a capability query, or split them into a sub-trait that only KDE implements and a core path for the rest. (c) `maximize` and `set_opacity` are provided no-ops today and are real on KDE and Windows only; macOS has neither (rows 16, 17), so those need the same capability treatment. **(4) No `Backend` method blocks on the main thread; on macOS `monitors()` reads a cache refreshed by the display callback; rendering uses `exec_async`. TARGET, not current behaviour.** Current macOS: `latest_monitor_information` is a live `NSScreen` query that panics off main (`M/macos_api.rs:212-213`) and its callers run on main through `exec_sync` (`M/monitor_reconciliator.rs:106, 117, 383`). Blocking main dispatches under the WM lock exist at `M/border_manager/mod.rs:157, 307`, `M/border_manager/border.rs:153, 244`, `M/macos_api.rs:492`, plus the unbounded `rx.recv()` for border windows (`M/border_manager/ns_window.rs:135`); the bounded ones are `on_main` (`M/animation/ghost.rs:125-129`, 750 ms). The CG display callback and `NSWorkspace` handlers run on main (section 3.3), so a cache refreshed there is feasible (a `try_send` today, `M/display_reconfiguration_listener.rs:12-30`), but nothing does it yet. Corrections to the rule's wording: KDE's own contract says "Queries never block on the compositor" (`K/backend/mod.rs:179`) and is also violated, by `cursor_pos` (up to 250 ms, `K/kwin/mod.rs:846`) and by the six `block_on` methods (section 3.1); so the seam rule should be "no `Backend` method waits for the main thread of its own platform, and any method that waits for anything is listed and bounded", which Windows (`SetWindowPos` waiting for the target, unless async, `W/windows_api.rs:518-520`) and KDE also need. **(5) Borders, stackbars, ghost/DWM, focus-follows-mouse, AX observers, the event tap, SkyLight batching and pan stay in platform crates, feeding the core through the thin event vocabulary. CONFIRMED for most; three corrections.** - *Focus-follows-mouse* is not platform-only: KDE and Windows both implement it as a setting (`K/kwin/mod.rs:1024-1048`, `W/windows_api.rs:1224-1257`); macOS has none (correction 9). It belongs in the trait as an optional setting. - *Borders on KDE* are drawn by the compositor and reached through `Backend::apply_look` and `set_border_colours` (`K/backend/mod.rs:473, 504`; callers `K/border_manager/mod.rs:199, 289`), so for KDE they are Backend operations, while on Windows and macOS they are the platform's own windows. The split is therefore "a look request that a backend may ignore" (KDE's `Ok(false)`) versus platform crates that draw. - **SkyLight batching is not confined to the platform layer.** `BatchedUpdates::begin()` brackets groups of window operations from `M/monitor.rs:246, 289`, `M/window_manager.rs:1383` and `M/workspace.rs:1470-1519` (`SLSDisableUpdate` ... `SLSReenableUpdate`): "everything that changes on screen is held back until this is dropped" (`M/skylight.rs:36-45`). Any shared code that does a layout then needs a way to say "these writes appear together" (a batch or transaction scope with a default no-op). KDE already batches by construction (the `Poll` reply, `K/kwin/mod.rs:285-290`); Windows does not. **(6) `WindowInfo` has optional metadata fields. CONFIRMED.** KDE's `WindowInfo` has `class`, `exe`, `path`, `fullscreen`, `maximized`, `normal` as plain fields (`K/backend/mod.rs:63-91`). macOS has no class, only a bundle identifier, role and subrole (`M/window.rs:842-905`), and asks the app per field over AX; Windows asks per field too (`W/windows_api.rs:866-1052`). Only `id`, `pid`, `rect` and `alive` are available everywhere cheaply; the rest should be `Option` or fetched on demand. `normal`, `fullscreen`, `maximized` have no macOS source. **(7) macOS setup takes the main run loop from the platform's `main`. CONFIRMED.** macOS `main` owns the `NSApplication`, the run loop, the event tap and the observers and spins the loop itself (`M/main.rs:226-349`), and the shared `WindowManager` carries the run loop as a field (`WindowManager::new(&run_loop, ..)`, `StaticConfig::preload(.., &run_loop)`, `M/main.rs:260-271`). Windows and KDE `main` have no loop to run: they block on a ctrl-c channel (`W/main.rs:350-357`, `K/main.rs:372-379`). So `main` stays per platform. A shared `run` has to accept a "what to do with the calling thread" hook (park for KDE and Windows, spin the loop for macOS), and the run loop must stop being a field of the shared `WindowManager`. **Findings the plan did not list** - **(8) Batch/transaction scope**: see (5). - **(9) Error types differ**: KDE `eyre::Result` (`K/backend/mod.rs:325`), Windows `eyre::Result` for some calls and `()` for `ShowWindow`-based ones (rows 5, 6, `W/windows_api.rs:668-684`), macOS `AccessibilityError` and `LibraryError` (`M/window.rs:794-915, 1084`). A shared error type must carry "the window is gone", because macOS uses a failed write as its signal for that (`M/reaper.rs:164-173`). - **(10) `center_cursor_in(&Rect)` is only honest on Windows and macOS**; KDE ignores the rect (row 14). Either the method becomes "centre on this window" or KDE's contract gets a caveat. - **(11) Hide is observable on macOS and Windows-`Hide`/`Minimize`**, because it changes ordinary window state (a corner position, `SW_HIDE`, minimized), whereas KDE's park desktop and Windows' `Cloak` do not. The core's habit of keeping its own table of what it hid (`HIDDEN_WINDOWS`, `HIDDEN_HWNDS`, `PARKED_WINDOWS`) exists for that reason and the seam must keep the distinction ("hidden by komorebi" versus "minimized by the user") visible to the core. ## 8. Unverified list Everything I could not confirm by reading, and how to confirm it. | # | Item | How to confirm | |---|---|---| | 1 | `NSWorkspace` notifications are posted on the main thread, and the CG display callback runs on main (the code assumes both: `M/notification_center_listener.rs:183-229`, `M/display_reconfiguration_listener.rs:76`, `M/main.rs:333-334`) | On a Mac: log `pthread_main_np()` (or `std::thread::current().name()`) in both handlers during a display change, a space change and an app launch; run with the main loop paused for a second to see whether delivery waits | | 2 | Adding AX observer sources to the main run loop from other threads is safe (`M/process_event.rs:614`, `M/process_command.rs:1758`) | Apple's Thread Safety Summary for `CFRunLoopRef`; on a Mac, a stress test creating windows while a drag is in progress, watching for missed or duplicate notifications | | 3 | `AXUIElement`/`AXObserver` can be used from several threads (the code asserts `Send + Sync`, `M/lib.rs:282-284, 300-302`) | Apple's Accessibility API documentation; a Mac run with a thread sanitizer on the animator lanes | | 4 | The possible deadlock in 3.3 | On a Mac: a test that holds the WM lock in a thread that calls `destroy_all_borders` while a `Resize` notification is posted for the reconciliator (inject through `monitor_reconciliator::send_notification`); watch for a hang. Not reproducible on Linux | | 5 | `dispatch_sync` to the main queue from the main thread deadlocks (relevant to `M/main.rs:280`) | Apple's libdispatch documentation; a Mac test | | 6 | HWND values always fit in 32 bits on 64-bit Windows | Microsoft's "Interprocess Communication Between 32-bit and 64-bit Applications" (handles are 32-bit significant); on a Windows guest, dump `komorebic state` for a long session and check `0 <= hwnd < 2^32` (440 values from the e2e logs passed, section 4.1) | | 7 | `WINEVENT_OUTOFCONTEXT` callbacks arrive on the thread that called `SetWinEventHook`; cross-thread `SetWindowPos` waits for the target's thread | Microsoft documentation for `SetWinEventHook` and `SetWindowPos`; log `GetCurrentThreadId` in `win_event_hook` on a guest | | 8 | Secondary monitors can have negative origins on Windows; AX values on scaled resolutions and mixed-DPI arrangements on macOS | Windows guest with two virtual monitors (the `monitors` scenario); a Mac with a scaled display and one with a different scale factor: compare `CGDisplayBounds`, `NSScreen.frame` and the AX frame of a known window | | 9 | KWin `frameGeometry` includes the server-side decoration and excludes the shadow; whether a macOS AX frame includes a shadow | KWin scripting documentation; compare AX frame with `CGWindowListCopyWindowInfo` bounds for the same window on a Mac | | 10 | Whether a macOS window created by an app is always reachable by `kAXWindowsAttribute` and `_AXUIElementGetWindow` so that a registry can be fed (title matching at start-up, `M/application.rs:195-208`) | A Mac run over a set of apps (Electron, Java, tabbed terminals) comparing `CGWindowListCopyWindowInfo` ids with the AX window ids | | 11 | (closed) Whether `theme_manager.rs:227` calls `destroy_all_borders` while holding the WM lock | Read `M/theme_manager.rs:53-75, 227`: `handle_notifications()` takes no window manager, so it does not | | 12 | Whether the border thread's unbounded `rx.recv()` (`M/border_manager/ns_window.rs:135`) can hang if the main queue never runs the closure (for example while main is blocked) | Read together with item 4; same Mac test | | 13 | The Windows paths of every `WindowsApi` call: section 3.2 lists thread entry points and examples, not every call chain | `cargo` call graph or a `tracing` run that logs the thread name per `WindowsApi` call in a scenario | | 14 | The KDE `block_on` panics inside a runtime thread | Tokio documentation; a unit test calling a `block_on` method from `Handle::spawn` | | 15 | Anything about runtime behaviour: this document is built from reading only; nothing was compiled or run | Run the three gates of the progress log (KDE fast tier, Windows scenarios, macOS by hand) when the trait exists | ## 9. Findings from the 7c adapters Step 7c wrote `WindowsBackend` (`komorebi/src/windows_backend.rs`) and `MacosBackend` (`komorebi-for-mac/komorebi/src/macos_backend.rs`) over the existing static functions, to learn which methods of the shared trait (`crates/komorebi-backend`) cannot be implemented without changing what the daemon does. Both compile (Windows: clippy `-D warnings` clean; macOS: fake-CC `cargo check` 0 errors) and neither is used by its daemon. They have never run: everything below comes from reading the code while writing the adapter, and "not verified" means exactly that. `K/` is `komorebi-for-kde/komorebi/src/`; `W/` and `M/` as in section 1. **Verdict.** All 14 required methods and the 4 capabilities could be written on both platforms, so the trait did not have to change. Eleven of them carry a semantic difference that the doc comments of the trait already allow (a request, a platform-defined read) or that the adapter had to choose; those are listed here so steps 8 and 11 decide them knowingly. Nothing in this section is a reason to change the trait yet; the three candidates are marked PROPOSAL. ### 9.1 Windows (`WindowsBackend`) | Method | What the adapter does | What differs from the daemon's own call | |---|---|---| | `windows()` | `WindowsApi::alt_tab_windows()` (`W/windows_api.rs:763`), each one read with `info` | **Cannot be written faithfully.** The candidate list the daemon uses is `load_workspace_information` (`:437`), which filters inside the `enum_window` callback while filling the containers of a `WindowManager`. The adapter has no manager, so it lists alt-tab windows, a superset (no `should_manage`, no ignore or float rules). | | `window(id)` | `IsWindow`, then one call per field (`process_id`, `title`, `class`, `exe`, `path`, `is_iconic`, `is_zoomed`) | `fullscreen` and `normal` are `None`: komorebi decides both in the core, there is no OS query. An error reading the frame leaves `rect` at the default, indistinguishable from a window at the origin (PROPOSAL: `rect: Option` or an error). Five or six kernel calls per call, where KDE answers from a table: do not call it per frame. | | `monitors()` | `valid_hmonitors()` then `WindowsApi::monitor(hmonitor)` for each | `monitor` re-enumerates every display for each handle (quadratic) and reads the global `DUPLICATE_MONITOR_SERIAL_IDS` (a duplicated serial becomes `None`). Physical pixels, the `Monitor.id` is an `isize` carried bit for bit through `MonitorId::from_hmonitor`. | | `cursor_pos()` | `GetCursorPos`, an error is `None` | The error is dropped. | | `set_rect` | `Window::set_position(rect, false)` | `top` (raise to the front in the same `SetWindowPos`) has no place in the trait: it is `false`, a caller raises separately, so one `SetWindowPos` becomes two. `Window::set_position` also keeps its slide-in animation, the live-resize guard (returns `Ok` while the user holds the window) and the early return when the frame is already right (`W/window.rs:329-370`): the call can return `Ok` without writing, which fits "a request" but not "the window will be there". Synchronous by default (waits for the target's thread; see unverified 7). | | `focus` | `Window::focus(mouse_follows_focus)` | Matches the trait, including the cursor centring. When the window is still sliding in (`transition::is_entering`) the focus is **deferred** and `Ok` is returned. | | `raise` | `Window::raise` (raises without activating, and the border) | Matches. | | `hide`, `restore` | `Window::hide`, `Window::restore` | They return `()`: `ShowWindow` and the cloak result are ignored, so `WindowGone` is told only by an `IsWindow` check before the call (a race). They also hide or show the window's border and write `HIDDEN_HWNDS`, and are skipped while a workspace slide owns the window (`is_exiting`, `is_entering`). The default hiding behaviour `Cloak` is not observable by the user; `Hide` and `Minimize` are (finding 11). | | `minimize` | `Window::minimize` | Silently does nothing for an exe that contains `komorebi-bar`, and returns `Ok`. No error is possible (`()`). | | `unminimize` | `WindowsApi::restore_window` (`SW_SHOWNOACTIVATE`) | Windows has no unminimize: this is the restore the hiding behaviours use, **without** the `HIDDEN_HWNDS` and border bookkeeping of `Window::restore`. It does not activate. | | `close` | `Window::close` (`PostMessage WM_CLOSE`) | Matches; the only call with a real error (a failed post), turned into `WindowGone` when the window is gone. | | `center_cursor_in` | `WindowsApi::center_cursor_in_rect` | Matches (and the one place the rect is honoured, finding 10). | | `monitor_from_point` | `WindowsApi::monitor_from_point` | **Overrides the trait default** because Windows uses `MONITOR_DEFAULTTONEAREST`: never `None`. The trait documents this; shared code must not use `None` to detect "off screen". | | `maximize` | `Window::maximize` / `unmaximize` | `()`-returning; removes the window from `HIDDEN_HWNDS` as the daemon does. | | `set_opacity` | LAYERED extended style, then `WindowsApi::set_transparent(hwnd, alpha)` | **Not what the daemon calls.** `Window::transparent`/`opaque` take the alpha from `transparency_manager::TRANSPARENCY_ALPHA` (a global, not an argument) and may animate (`TransparencyRenderDispatcher`). The adapter writes the plain value both end in, with no animation and no manager bookkeeping. | | `set_focus_follows_mouse` | `enable_`/`disable_focus_follows_mouse` (the system setting) | The komorebi-implemented focus follows mouse (`W/process_movement.rs`) is a different mechanism with its own thread and is not started or stopped by this; the daemon today picks one by configuration. Both are "focus follows mouse"; the trait method means the OS setting. | | Workspaces (5) | `Unsupported` | By design: komorebi's own workspaces. | | Batch | default no-op | Windows does not batch. | | Thread | any thread | The calls are stateless except for the statics named above (`HIDDEN_HWNDS`, `DUPLICATE_MONITOR_SERIAL_IDS`, the transition and live-resize tables), which are behind locks. `set_rect` and `raise` wait for the target window's thread unless `WINDOW_HANDLING_BEHAVIOUR` is `Async`: the bounded-wait rule of section 7(4) is not met by Windows today. | ### 9.2 macOS (`MacosBackend`) | Method | What the adapter does | What differs from the daemon's own call | |---|---|---| | (registry) | `Mutex>` with `register(Window)` and `forget(id)`; every method clones the `Window` out | **The trait needs a feeder the trait does not have.** The daemon must call `register` wherever it creates a `Window` (start-up enumeration by title, AX events) and `forget` on destroy. `Window` carries the element, application and observer, and the adapter keeps the whole clone, so the registry holds the observer too (consequence 1 said the observer could stay outside: it cannot while `Window` is the unit). An id not in the registry is `WindowGone`, which is also what a window the daemon never registered looks like. | | `windows()` | the registry's windows that are still valid | **Cannot be written faithfully.** The CoreGraphics list joined by title with the AX elements (`M/macos_api.rs:312-374`) lives in `load_workspace_information`, which fills a `WindowManager`. The list is only as complete as the registry. | | `window(id)` | AX round trips: `is_valid` (attribute names), frame (two reads), title, exe, path, `AXMinimized` | There is no existing "is minimized" query, so the adapter reads `AXMinimized` itself. A dead window gives `None`, so "never known" and "dead" cannot be told apart (the trait allows both). `class`, `fullscreen`, `maximized`, `normal` are `None`. A frame read error leaves `rect` at the default (same PROPOSAL as Windows). Four or more IPC calls to the app per call: slow and blocking on a hung app. | | `monitors()` | a cache; `refresh_monitors(MainThreadMarker)` fills it from `latest_monitor_information` | **A change in how the daemon works, not just an adapter.** Today every caller runs on main (`exec_sync`). The cache has to be refreshed by the display callback on main (a `try_send` today, `M/display_reconfiguration_listener.rs:12-30`); before the first refresh `monitors()` is an `Err`. The `MainThreadMarker` argument makes a call from another thread a compile error. This is the TARGET rule of consequence 4 made real. | | `foreground_window` | `MacosApi::foreground_window_id` | Matches (`None` when any AX step fails). | | `cursor_pos` | `MacosApi::cursor_pos` | Matches, always `Some`; floats are truncated to `i32`. | | `set_rect` | `Window::set_position` | May write directly or hand the move to the animator, and honours the drag `Snap` state (`M/window.rs:915-940`): a request, as the contract says. A failed write asks `is_valid` to decide between `WindowGone` and a platform error (the reaper's trick, a second round trip inside the error path). | | `focus` | `Window::focus` | Matches (SkyLight `set_front_process`, then `AXMain`; tabbed apps use `main_window`). Error is `LibraryError`. | | `raise` | `Window::raise` | **Also focuses**: macOS has no raise without focus (`AXMain` and `AXFocused`). | | `hide` | `Window::hide(hiding_position)` | **The trait has no place for two inputs `hide` needs.** The corner (`WindowHidingPosition`) is configuration of the monitor; `MacosBackend::new` takes one for all windows. And `hide_on(position, screen)` wants the display the window's monitor owns, "since a window that hangs over the edge is not always on the display the lookup by position finds" (`M/window.rs:610-615`); the adapter passes none and takes the lookup. PROPOSAL (step 11, not now): hide stays as it is and `MacosBackend` learns each window's monitor at `register`. | | `restore` | `Window::restore` | Matches. A window that was not hidden is left alone, as the contract says. | | `minimize`, `unminimize` | `Window::minimize`/`unminimize` (`AXMinimized`) | Match (both need `&mut self`; the adapter clones). | | `close` | `AdhocWindow::close(element)` (presses the close button) | The daemon calls it on the foreground element; here by id. `Ok` when the window has no close button. | | `center_cursor_in` | `MacosApi::center_cursor_in_rect` | Matches. | | `monitor_from_point` | `MacosApi::monitor_from_point` (CoreGraphics) | Matches: `None` outside every display. | | Capabilities | none | By design (rows 16 to 19). | | Batch | a stack of SkyLight `BatchedUpdates` guards | Matches the daemon's own brackets. Not verified: that nested `SLSDisableUpdate` calls count (the daemon nests none today). The guard holds the main connection id, so it is `Send`; `SLSReenableUpdate` from another thread than the one that disabled is not verified. | | Thread | any thread except main for everything but `refresh_monitors` | Not verified: whether `hide_on(.., None)`'s lookup by position (`M/window.rs:616-676`) reaches an `NSScreen` and so needs main; if it does, `hide` breaks the rule until the screen is passed (see `hide`). | ### 9.3 What this means for the trait 1. **No change was needed to write both adapters.** The capability query, the batch scope, `WindowGone` and the optional fields were all used, and each was necessary: macOS answers `Unsupported` for all four capabilities, Windows for one; `WindowGone` is built from a pre-check on Windows (results ignored) and from a re-check on macOS and KDE; `None` fields are used on both. 2. **PROPOSAL A: `WindowInfo::rect` can fail** on both platforms (a frame read is an API call). If the core must tell "at the origin" from "unknown", the field becomes `Option`. Decided not to change it now: KDE cannot fail, and the shared core has no consumer yet. 3. **PROPOSAL B: `hide` has hidden inputs on macOS** (corner, monitor). Left to step 11, where the registry learns the monitor at registration. 4. **PROPOSAL C: `windows()` is a start-up operation on Windows and macOS** (the filtered list needs the core's rules), and the trait documents it as slow. If the core wants one candidate list, the filter has to move into shared code with the window rules as an argument. 5. **The adapters add non-trait API that the daemons must call** (`MacosBackend::register`, `forget`, `refresh_monitors`; `MacosBackend::new(hiding_position)`). That is the cost of keeping `Window` as it is until step 11(ii).