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() }),