From 0efb0409584826c6b0e00d2c941e77d37c787983 Mon Sep 17 00:00:00 2001 From: Mickael Couzinet Date: Fri, 25 Sep 2026 15:50:25 +0200 Subject: [PATCH] helper: windows that land on the Edge come back The mouse fence keeps the pointer off the Edge, but nothing stopped an application from placing a window there: Safari or the Finder restoring a frame saved before the Edge was plugged in, or a display rearranged under windows that were where they belonged. The kiosk covers that display above the status-bar level, so a window that arrives there is focused, holds the keyboard, and is invisible. Nothing on screen says it exists, and the pointer cannot reach it, which is the same trap AdminPlacement was written for, from the other side. A two-second sweep reads the window list, which costs one call and no round trip to any application, and hands anything of somebody else's that overlaps the Edge to AdminPlacement, the geometry the admin window already uses, then moves it there. The Accessibility API is reached only for an application that actually has a window on the Edge, which is almost never. Three moves per window is the limit: an application that puts its window straight back would otherwise be fought forever, twice a second, with the window flickering between two displays. It rides on the existing fence switch rather than adding one, since it is the same promise for the pointer and for windows, and the menu entry is renamed from "Mouse fence" to "Edge fence" to say so. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 + README.md | 11 +- docs/contributing.md | 4 +- docs/troubleshooting.md | 1 + native/Sources/FremkitCore/L10n.swift | 4 +- native/Sources/FremkitCore/WindowSweep.swift | 57 ++++++ .../Sources/FremkitHelper/AppDelegate.swift | 18 +- .../Sources/FremkitHelper/WindowFence.swift | 164 ++++++++++++++++++ .../FremkitCoreTests/WindowSweepTests.swift | 93 ++++++++++ native/Tests/Shim/main.swift | 11 ++ 10 files changed, 359 insertions(+), 11 deletions(-) create mode 100644 native/Sources/FremkitCore/WindowSweep.swift create mode 100644 native/Sources/FremkitHelper/WindowFence.swift create mode 100644 native/Tests/FremkitCoreTests/WindowSweepTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e746a..76a397f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ Anything that changes what the dashboard, the admin or a widget can do gets a li rather than only the one that declared it. ### Added +- **A window that lands on the Edge comes back.** Another application could place a window on the + Edge display, where the kiosk covers it: the window was focused, it took the keyboard, and it + was invisible, with nothing to click to get it back. Safari or the Finder restoring a frame + saved before the Edge was plugged in is enough to fall into it. The helper now moves any such + window back onto the display you work on, within two seconds, under the same switch that keeps + the mouse out. That switch is called *Edge fence* now, since it fences more than the pointer + (@mcouzinet, #7). - **Claude sessions: go to the session.** Touch a card and its window comes forward — the exact Orca pane, the Terminal.app or iTerm2 tab, the VS Code window on that folder, or at least the application — for every session, hooked or merely found running. The card also says which application the session lives in, which is what tells two panes of the diff --git a/README.md b/README.md index a9e6045..ea05c9a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ The name is the Fremen survival kit from *Dune*. - **Connections** — named credential sets for outside services, with secrets kept in the macOS keychain rather than in the config file. - **A native helper** — a menu bar app that drives the Edge's touch panel with its own HID - driver, fences the mouse out of the display, shows the dashboard in a kiosk window and + driver, keeps the mouse and other applications' windows off the display, shows the dashboard + in a kiosk window and supervises the server. - **A widget marketplace** — *Sietch* in the admin installs widgets from a curated registry, showing what each one will be allowed to do and asking again when an update wants more. @@ -109,7 +110,7 @@ admin window: an older copy ignores it. The dashboard then opens by itself on the Edge. If you would rather not run the helper at all, `pnpm start` serves everything and `pnpm kiosk` opens a full-screen Chrome window on the Edge — -you lose the touch driver, the mouse fence and the Dock badges. +you lose the touch driver, the Edge fence and the Dock badges. ## First run @@ -213,9 +214,9 @@ the widget library and the widgets themselves, live, without a reload. **The helper, or a browser.** The dashboard is a web page: any browser can show it. The native helper adds what a browser cannot — its own HID driver for the Edge's touch panel, a fence that -keeps the mouse cursor on your other displays, a kiosk window with no chrome and no cursor, the +keeps the mouse cursor and other applications' windows on your other displays, a kiosk window with no chrome and no cursor, the Dock's notification badges, and supervision of the server. Its menu bar **F** shows what is -running: *Open the admin*, *Admin in the browser*, *Reload the dashboard*, the *Touch* / *Mouse +running: *Open the admin*, *Admin in the browser*, *Reload the dashboard*, the *Touch* / *Edge fence* / *Notifications* / *Manage the server* / *Launch at login* toggles, *Log…* and *Quit*, above state lines such as "Server: running" and "Touch: active". @@ -350,7 +351,7 @@ pnpm helper:test |---|---| | `server/` | Fastify 5 on 127.0.0.1:4242 — config, widget catalog, providers, connections, secrets, the bridge, the WebSocket | | `ui/` | Vue 3 — the dashboard, the admin, and the code they share | -| `native/` | The Swift helper — kiosk window, HID touch driver, mouse fence, admin window, Dock badges, server supervision | +| `native/` | The Swift helper — kiosk window, HID touch driver, Edge fence, admin window, Dock badges, server supervision | | `widgets/` | One folder per widget | | `scripts/` | Setup, dev, the helper's build and tests, the signing identity, kiosk, the Claude Code hooks | | `data/` | Your configuration and its assets. Git-ignored | diff --git a/docs/contributing.md b/docs/contributing.md index f132d95..49bb2c9 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -16,7 +16,7 @@ string goes through `ui/src/shared/locales/`, and every manifest text is either server/ Fastify 5 server on 127.0.0.1:4242 — config, widget catalog, providers, connections, the secret store, the widget bridge and the WebSocket ui/ Vue 3: the dashboard (src/dashboard), the admin (src/admin), shared code (src/shared) -native/ The Swift helper: kiosk window, HID touch driver, mouse fence, admin window, +native/ The Swift helper: kiosk window, HID touch driver, Edge fence, admin window, Dock badges, server supervision widgets/ One folder per widget themes/ One folder per theme — see themes.md @@ -166,7 +166,7 @@ run: | `port` | `4242` | port watched to decide whether a server is already up | | `display` | `2560 × 720` | the Edge's size, used to find the display | | `touch` | `true` | the native touch driver | -| `fence` | `true` | keep the mouse cursor out of the Edge | +| `fence` | `true` | keep the mouse cursor out of the Edge, and move other applications' windows off it | | `manageServer` | `true` | start and restart the server | | `launchAtLogin` | `false` | login item | | `scrollInvert` | `false` | flip the touch scroll direction | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bd81897..8fa800b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -14,6 +14,7 @@ and "Notifications: active" above the toggles, and *Log…* opens the supervised | "Touch: permission missing" | Input Monitoring not granted to *this build* of the helper | Add "Fremkit Helper" under Privacy & Security → Input Monitoring, then relaunch it | | "Touch: taken by another driver" | Touchscreen Gestures, another driver or a `--probe` run holds the panel | Quit it, and unload its launchd agent so it does not come back | | "Fence: permission missing" | Accessibility not granted | Add "Fremkit Helper" under Privacy & Security → Accessibility, then relaunch it | +| A window vanished behind the dashboard | An application placed it on the Edge, where the kiosk covers it | The Edge fence brings it back within two seconds; if it stays there, the helper is missing Accessibility, or the fence is off in its menu | | Permissions reset after every rebuild | Ad-hoc signature: a new code identity each build | Run `scripts/create-signing-identity.sh`, rebuild, grant once more | | Printer unreachable (`EHOSTUNREACH`) while `ping` and `curl` work | Local Network not granted to the app that runs the server | Allow it under Privacy & Security → Local Network | | "Server: external" | Something already answers port 4242, so the helper steps aside | Expected under `pnpm dev`; otherwise stop the stray server, or turn *Manage the server* off | diff --git a/native/Sources/FremkitCore/L10n.swift b/native/Sources/FremkitCore/L10n.swift index a0ea86d..4de0539 100644 --- a/native/Sources/FremkitCore/L10n.swift +++ b/native/Sources/FremkitCore/L10n.swift @@ -101,7 +101,7 @@ public enum L10n { .openAdminInBrowser: "Admin dans le navigateur", .reloadDashboard: "Recharger le dashboard", .toggleTouch: "Tactile", - .toggleFence: "Barrière souris", + .toggleFence: "Barrière Edge", .toggleDock: "Notifications", .toggleManageServer: "Gérer le serveur", .toggleLaunchAtLogin: "Lancer au login", @@ -152,7 +152,7 @@ public enum L10n { .openAdminInBrowser: "Admin in the browser", .reloadDashboard: "Reload the dashboard", .toggleTouch: "Touch", - .toggleFence: "Mouse fence", + .toggleFence: "Edge fence", .toggleDock: "Notifications", .toggleManageServer: "Manage the server", .toggleLaunchAtLogin: "Launch at login", diff --git a/native/Sources/FremkitCore/WindowSweep.swift b/native/Sources/FremkitCore/WindowSweep.swift new file mode 100644 index 0000000..7d17412 --- /dev/null +++ b/native/Sources/FremkitCore/WindowSweep.swift @@ -0,0 +1,57 @@ +import CoreGraphics +import Foundation + +/// One window of another application sitting where the kiosk is. +public struct StrayWindow: Equatable { + public let id: CGWindowID + public let pid: pid_t + public let frame: CGRect + + public init(id: CGWindowID, pid: pid_t, frame: CGRect) { + self.id = id + self.pid = pid + self.frame = frame + } +} + +/// Which windows have to be moved off the Edge display. +/// +/// The kiosk covers that display above the status-bar level, so a window that opens there is +/// buried: it is on screen, it holds the focus and the keyboard, and the user sees the dashboard. +/// The mouse fence already keeps the pointer out, but nothing stops an application from *placing* +/// a window there, which is what happens when Safari or the Finder restores a frame it saved +/// before the Edge was set up, or when a display is rearranged under windows that were fine. +/// +/// The reading of `CGWindowListCopyWindowInfo` lives here, away from AppKit and the Accessibility +/// API, so the rules can be tested with plain dictionaries. +public enum WindowSweep { + /// The windows that overlap `edge` and belong to somebody else, in the order they are listed. + /// + /// - Parameters: + /// - infos: what `CGWindowListCopyWindowInfo` answered, on-screen windows. + /// - edge: the Edge display's bounds, in the same space as the window bounds. + /// - ownPid: this process, whose own kiosk window covers the whole Edge on purpose. + public static func strays(in infos: [[String: Any]], edge: CGRect, ownPid: pid_t) -> [StrayWindow] { + infos.compactMap { info in + // Layer 0 is an ordinary window. Everything else (the Dock, the menu bar extras, a + // screen saver, the kiosk itself) is furniture that is meant to be above or below. + guard let layer = info[kCGWindowLayer as String] as? Int, layer == 0 else { return nil } + guard let pid = info[kCGWindowOwnerPID as String] as? pid_t, pid != ownPid else { return nil } + guard let id = info[kCGWindowNumber as String] as? CGWindowID else { return nil } + guard let bounds = info[kCGWindowBounds as String] as? NSDictionary, + let frame = CGRect(dictionaryRepresentation: bounds as CFDictionary) else { return nil } + // Touching the Edge at all counts: a window straddling the boundary has the part the + // user reads on the display they cannot see. + guard !frame.isEmpty, frame.intersects(edge) else { return nil } + return StrayWindow(id: id, pid: pid, frame: frame) + } + } + + /// The window numbers of every window in `infos`, whatever it is. + /// + /// The caller counts its attempts per window, and a number that is gone from this set belongs + /// to a window that closed: forgetting it there keeps the count from outliving the window. + public static func present(in infos: [[String: Any]]) -> Set { + Set(infos.compactMap { $0[kCGWindowNumber as String] as? CGWindowID }) + } +} diff --git a/native/Sources/FremkitHelper/AppDelegate.swift b/native/Sources/FremkitHelper/AppDelegate.swift index b0c8f43..567d73f 100644 --- a/native/Sources/FremkitHelper/AppDelegate.swift +++ b/native/Sources/FremkitHelper/AppDelegate.swift @@ -11,6 +11,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let poster = EventPoster() private var driver: TouchDriver? private var fence: MouseFence? + private var windows: WindowFence? private var dockBadges: DockBadges? private var watcher: EdgeDisplay.Watcher? private var kiosk: KioskWindow? @@ -56,6 +57,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } self.fence = fence + self.windows = WindowFence(edge: nil) + let dockBadges = DockBadges(port: config.port) dockBadges.onState = { [weak self] state in self?.menu?.update { $0.dock = state } @@ -80,7 +83,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } if config.touch { driver.start() } - if config.fence { fence.start() } + if config.fence { + fence.start() + windows?.start() + } if config.dock { dockBadges.start() } // An orphan from a crashed helper still holds the port and would be mistaken for an @@ -132,6 +138,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { server?.stopAndWait(timeout: 2) driver?.stop() fence?.stop() + windows?.stop() dockBadges?.stop() kiosk?.close() } @@ -186,6 +193,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.edge = edge driver?.displayBounds = edge fence?.edge = edge + windows?.edge = edge if let edge { let frame = NSRect.fromCGDisplayBounds(edge, primaryHeight: NSRect.primaryScreenHeight) @@ -248,7 +256,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let self else { return } self.config.fence = on self.saveConfig() - if on { self.fence?.start() } else { self.fence?.stop() } + if on { + self.fence?.start() + self.windows?.start() + } else { + self.fence?.stop() + self.windows?.stop() + } self.refreshMenu() } diff --git a/native/Sources/FremkitHelper/WindowFence.swift b/native/Sources/FremkitHelper/WindowFence.swift new file mode 100644 index 0000000..9a2d9a6 --- /dev/null +++ b/native/Sources/FremkitHelper/WindowFence.swift @@ -0,0 +1,164 @@ +import ApplicationServices +import CoreGraphics +import Foundation +import FremkitCore + +/// Keeps other applications' windows off the Edge display, which is a dashboard and not a workspace. +/// +/// The mouse fence stops a window from being *dragged* there; this one deals with the windows that +/// arrive without the pointer, which is how it actually happens: an application restores a frame it +/// saved before the Edge existed, or a display is rearranged under windows that were fine where +/// they were. The kiosk covers that display above the status-bar level, so such a window is lost +/// for good: focused, taking the keyboard, and invisible under the dashboard. +/// +/// A sweep costs one `CGWindowListCopyWindowInfo` call, which needs no permission and no round +/// trip to any application. The Accessibility API is only reached for an application that actually +/// has a window on the Edge, which is almost never. +final class WindowFence { + /// How often the window list is read. Slow enough to be free, quick enough that a window + /// landing there is gone before the user has finished wondering where it went. + private static let interval: TimeInterval = 2 + + /// How many times one window is moved before it is left alone. + /// + /// An application that puts its window straight back would otherwise be fought forever, at + /// two rounds a second between the two of us, with the window flickering across two displays. + private static let maxAttempts = 3 + + /// How long an application is given to answer the Accessibility API, so a hung one cannot + /// stall the helper's main thread. + private static let messagingTimeout: Float = 1 + + /// Bounds of the Edge display; the fence does nothing while this is nil. + var edge: CGRect? + + private var timer: Timer? + private var attempts: [CGWindowID: Int] = [:] + /// Logged once, rather than every two seconds, while Accessibility is missing. + private var warnedAboutPermission = false + + private(set) var isRunning = false + + init(edge: CGRect?) { + self.edge = edge + } + + deinit { stop() } + + // MARK: - Lifecycle + + func start() { + guard !isRunning else { return } + // `.common` so the sweep keeps ticking while a menu is being tracked. + let timer = Timer(timeInterval: Self.interval, repeats: true) { [weak self] _ in self?.sweep() } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + isRunning = true + sweep() + } + + func stop() { + timer?.invalidate() + timer = nil + attempts = [:] + isRunning = false + } + + // MARK: - Sweep + + /// Moves every window of another application that overlaps the Edge back onto a display the + /// user can see. Safe to call at any time; it does nothing when there is nothing to do. + func sweep() { + guard let edge else { return } + guard let infos = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) + as? [[String: Any]] else { return } + + // A window that closed takes its attempt count with it, so the same number handed to a + // new window starts from zero. + let present = WindowSweep.present(in: infos) + attempts = attempts.filter { present.contains($0.key) } + + let strays = WindowSweep.strays(in: infos, edge: edge, ownPid: getpid()) + guard !strays.isEmpty else { return } + + guard AXIsProcessTrusted() else { + if !warnedAboutPermission { + warnedAboutPermission = true + NSLog("fremkit: a window sits on the Edge but moving it needs Accessibility access") + } + return + } + warnedAboutPermission = false + + let screens = Self.screenBounds() + for stray in strays { + let count = attempts[stray.id] ?? 0 + guard count < Self.maxAttempts else { continue } + guard let target = AdminPlacement.rescue(frame: stray.frame, size: stray.frame.size, + screens: screens, kiosk: edge) else { continue } + attempts[stray.id] = count + 1 + move(stray, to: target, edge: edge) + } + } + + /// Every active display, the main one first, in the space window bounds are reported in. + private static func screenBounds() -> [CGRect] { + var count: UInt32 = 0 + guard CGGetActiveDisplayList(0, nil, &count) == .success, count > 0 else { return [] } + var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) + guard CGGetActiveDisplayList(count, &ids, &count) == .success else { return [] } + let main = CGMainDisplayID() + // `AdminPlacement.target` takes the first screen that is not the kiosk's, so the display + // the user works on has to come first. + return (ids.filter { $0 == main } + ids.filter { $0 != main }).map(CGDisplayBounds) + } + + // MARK: - Accessibility + + private func move(_ stray: StrayWindow, to target: CGRect, edge: CGRect) { + let app = AXUIElementCreateApplication(stray.pid) + AXUIElementSetMessagingTimeout(app, Self.messagingTimeout) + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &value) == .success, + let windows = value as? [AXUIElement] else { return } + + // The window list and the Accessibility API name the same window in two ways that have no + // identifier in common, so it is matched on its frame, and, if it moved in between, on + // being the one still overlapping the Edge. + let window = windows.first { frame(of: $0) == stray.frame } + ?? windows.first { frame(of: $0)?.intersects(edge) == true } + guard let window else { return } + + // The size first: a window wider than the display it is moved to is shrunk to fit, and + // setting the position afterwards is what decides where it lands. + if target.size != stray.frame.size { + var size = target.size + if let value = AXValueCreate(.cgSize, &size) { + AXUIElementSetAttributeValue(window, kAXSizeAttribute as CFString, value) + } + } + var origin = target.origin + if let value = AXValueCreate(.cgPoint, &origin) { + AXUIElementSetAttributeValue(window, kAXPositionAttribute as CFString, value) + } + } + + private func frame(of window: AXUIElement) -> CGRect? { + guard let positionValue = axValue(window, kAXPositionAttribute), + let sizeValue = axValue(window, kAXSizeAttribute) else { return nil } + var origin = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(positionValue, .cgPoint, &origin), + AXValueGetValue(sizeValue, .cgSize, &size) else { return nil } + return CGRect(origin: origin, size: size) + } + + /// One `AXValue` attribute, or nil when the application answers something else entirely. + private func axValue(_ element: AXUIElement, _ attribute: String) -> AXValue? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, + let value, CFGetTypeID(value) == AXValueGetTypeID() else { return nil } + // Checked just above: the answer is an AXValue and nothing else. + return (value as! AXValue) + } +} diff --git a/native/Tests/FremkitCoreTests/WindowSweepTests.swift b/native/Tests/FremkitCoreTests/WindowSweepTests.swift new file mode 100644 index 0000000..0e7ee33 --- /dev/null +++ b/native/Tests/FremkitCoreTests/WindowSweepTests.swift @@ -0,0 +1,93 @@ +import CoreGraphics +import Foundation +import XCTest +@testable import FremkitCore + +/// The Edge as `CGWindowListCopyWindowInfo` reports bounds: top-left origin, y downwards. +private let edge = CGRect(x: -2560, y: 362, width: 2560, height: 720) +private let ownPid: pid_t = 501 + +/// One entry of the window list, with everything the sweep reads. +private func info( + id: CGWindowID, + pid: pid_t, + frame: CGRect, + layer: Int = 0 +) -> [String: Any] { + [ + kCGWindowNumber as String: id, + kCGWindowOwnerPID as String: pid, + kCGWindowLayer as String: layer, + kCGWindowBounds as String: frame.dictionaryRepresentation as NSDictionary, + ] +} + +final class WindowSweepTests: XCTestCase { + func testFindsAWindowLostOnTheEdge() { + let lost = CGRect(x: -1740, y: 456, width: 920, height: 436) + let strays = WindowSweep.strays(in: [info(id: 7, pid: 900, frame: lost)], edge: edge, ownPid: ownPid) + XCTAssertEqual(strays, [StrayWindow(id: 7, pid: 900, frame: lost)]) + } + + func testLeavesWindowsOnTheOtherDisplaysAlone() { + let onMain = CGRect(x: 200, y: 200, width: 920, height: 436) + XCTAssertTrue(WindowSweep.strays(in: [info(id: 1, pid: 900, frame: onMain)], edge: edge, ownPid: ownPid).isEmpty) + } + + func testTakesAWindowThatMerelyStraddlesTheBoundary() { + // The part the user reads is the part that is hidden, so an overlap of any size counts. + let straddling = CGRect(x: -358, y: 500, width: 2111, height: 690) + XCTAssertEqual(WindowSweep.strays(in: [info(id: 2, pid: 900, frame: straddling)], edge: edge, ownPid: ownPid).count, 1) + } + + func testIgnoresTheHelpersOwnKioskWindow() { + let kiosk = edge + XCTAssertTrue(WindowSweep.strays(in: [info(id: 3, pid: ownPid, frame: kiosk)], edge: edge, ownPid: ownPid).isEmpty) + } + + func testIgnoresEverythingThatIsNotAnOrdinaryWindow() { + // The Dock, the menu bar and the screen saver live on their own layers and belong there. + let furniture = CGRect(x: -2560, y: 362, width: 2560, height: 40) + for layer in [-1, 3, 20, 1000] { + let strays = WindowSweep.strays(in: [info(id: 4, pid: 900, frame: furniture, layer: layer)], edge: edge, ownPid: ownPid) + XCTAssertTrue(strays.isEmpty, "layer \(layer)") + } + } + + func testSkipsAnEntryMissingWhatItNeeds() { + let frame = CGRect(x: -1000, y: 400, width: 400, height: 300) + var noBounds = info(id: 5, pid: 900, frame: frame) + noBounds.removeValue(forKey: kCGWindowBounds as String) + var noPid = info(id: 6, pid: 900, frame: frame) + noPid.removeValue(forKey: kCGWindowOwnerPID as String) + var noNumber = info(id: 7, pid: 900, frame: frame) + noNumber.removeValue(forKey: kCGWindowNumber as String) + let empty = info(id: 8, pid: 900, frame: CGRect(x: -1000, y: 400, width: 0, height: 0)) + XCTAssertTrue(WindowSweep.strays(in: [noBounds, noPid, noNumber, empty], edge: edge, ownPid: ownPid).isEmpty) + } + + func testKeepsTheOrderOfTheList() { + let a = CGRect(x: -1000, y: 400, width: 400, height: 300) + let b = CGRect(x: -500, y: 500, width: 400, height: 300) + let strays = WindowSweep.strays(in: [info(id: 10, pid: 900, frame: a), info(id: 11, pid: 901, frame: b)], + edge: edge, ownPid: ownPid) + XCTAssertEqual(strays.map(\.id), [10, 11]) + } + + func testPresentListsEveryWindowWhateverItIs() { + let frame = CGRect(x: 0, y: 0, width: 10, height: 10) + let infos = [info(id: 1, pid: 900, frame: frame), + info(id: 2, pid: ownPid, frame: frame, layer: 25), + [kCGWindowOwnerPID as String: pid_t(900)]] + XCTAssertEqual(WindowSweep.present(in: infos), [1, 2]) + } + + func testARescuedWindowKeepsItsSizeAndLandsOnTheMainDisplay() { + // The move itself is `AdminPlacement.rescue`, which the helper hands the stray's own size. + let main = CGRect(x: 0, y: 0, width: 2560, height: 1440) + let lost = CGRect(x: -1740, y: 456, width: 920, height: 436) + let target = AdminPlacement.rescue(frame: lost, size: lost.size, screens: [main, edge], kiosk: edge) + XCTAssertEqual(target?.size, lost.size) + XCTAssertEqual(target.map { main.contains($0) }, true) + } +} diff --git a/native/Tests/Shim/main.swift b/native/Tests/Shim/main.swift index de2362d..501665d 100644 --- a/native/Tests/Shim/main.swift +++ b/native/Tests/Shim/main.swift @@ -71,6 +71,17 @@ let suites: [(String, () -> XCTestCase, [TestMethod])] = [ ("testSignatureChangesWithTheBadgeAndWithTheRunningFlag", { ($0 as! DockBadgeParserTests).testSignatureChangesWithTheBadgeAndWithTheRunningFlag() }), ("testSignatureIgnoresTheDisplayNameOnly", { ($0 as! DockBadgeParserTests).testSignatureIgnoresTheDisplayNameOnly() }), ]), + ("WindowSweepTests", { WindowSweepTests() }, [ + ("testFindsAWindowLostOnTheEdge", { ($0 as! WindowSweepTests).testFindsAWindowLostOnTheEdge() }), + ("testLeavesWindowsOnTheOtherDisplaysAlone", { ($0 as! WindowSweepTests).testLeavesWindowsOnTheOtherDisplaysAlone() }), + ("testTakesAWindowThatMerelyStraddlesTheBoundary", { ($0 as! WindowSweepTests).testTakesAWindowThatMerelyStraddlesTheBoundary() }), + ("testIgnoresTheHelpersOwnKioskWindow", { ($0 as! WindowSweepTests).testIgnoresTheHelpersOwnKioskWindow() }), + ("testIgnoresEverythingThatIsNotAnOrdinaryWindow", { ($0 as! WindowSweepTests).testIgnoresEverythingThatIsNotAnOrdinaryWindow() }), + ("testSkipsAnEntryMissingWhatItNeeds", { ($0 as! WindowSweepTests).testSkipsAnEntryMissingWhatItNeeds() }), + ("testKeepsTheOrderOfTheList", { ($0 as! WindowSweepTests).testKeepsTheOrderOfTheList() }), + ("testPresentListsEveryWindowWhateverItIs", { ($0 as! WindowSweepTests).testPresentListsEveryWindowWhateverItIs() }), + ("testARescuedWindowKeepsItsSizeAndLandsOnTheMainDisplay", { ($0 as! WindowSweepTests).testARescuedWindowKeepsItsSizeAndLandsOnTheMainDisplay() }), + ]), ("AdminPlacementTests", { AdminPlacementTests() }, [ ("testAFrameOnTheLaptopIsLeftAlone", { ($0 as! AdminPlacementTests).testAFrameOnTheLaptopIsLeftAlone() }), ("testAFrameOnTheKioskDisplayIsMovedToTheLaptop", { ($0 as! AdminPlacementTests).testAFrameOnTheKioskDisplayIsMovedToTheLaptop() }),