diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0914aaf..b82fad3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,3 +17,5 @@ jobs: run: swift build -c release - name: Debug build (includes the development harness) run: swift build + - name: Tests + run: swift test diff --git a/Package.swift b/Package.swift index e637320..65ce80c 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,7 @@ let package = Package( // Sparkle.framework is embedded in Contents/Frameworks by Scripts/bundle.sh. .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"]), ] - ) + ), + .testTarget(name: "MonitorGlueTests", dependencies: ["MonitorGlue"]) ] ) diff --git a/README.md b/README.md index c19777a..654f0b2 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ puts them back — right monitor, right position, right size — the moment you - **Restores automatically** when a monitor connects, when you open the lid, when the Mac wakes, and when you unlock. If you plug in at the lock screen, it waits and places everything the moment you unlock. +- **Remembers windows across Spaces.** Restores windows across all Spaces. - **Respects your changes.** Resize a window on the monitor and that becomes its new place. Drag a window to the laptop screen and it stays there — it won't be yanked back. - **Copes with stubborn apps.** Some apps refuse certain sizes (Slack has a minimum width); @@ -79,14 +80,19 @@ windows — never their contents. Everything else stays on your Mac: under — so a home monitor and an office monitor never share a layout. - **Positions are stored relative to the monitor's own origin.** macOS often brings a display back at a different place in its arrangement; relative coordinates still land correctly. -- **Windows are read and moved with the Accessibility API**, matched back to saved entries by - title, then by position in the app's window list. +- **Windows are read and moved with the Accessibility API**, identified by their window number and owning process's launch time. After an app restarts, only an unambiguous title match is used; ambiguous windows are learned again when visited. - **Restores retry for up to three minutes**, quickly at first. Right after a wake, apps can be unresponsive to Accessibility for a while, and some apps are still relaunching. - **Why it isn't on the Mac App Store:** App Store apps must run in the App Sandbox, and the sandbox hides other apps' windows from the Accessibility API. We tested it: the sandboxed build was told it was trusted and then saw zero windows. +### Multiple Spaces + +Visit each Space while the monitor is connected to let Monitor Glue learn its windows. Captures update only visible windows and preserve the other Spaces. After reconnecting, Monitor Glue attempts to restore all remembered windows, including retained windows on inactive Spaces, without switching your desktop. Windows that are unavailable remain pending and are retried when you visit their Space or activate their app. Window references must be learned again after Monitor Glue restarts. + +Monitor Glue restores positions within existing Spaces. It does not recreate deleted Spaces or reassign windows between Spaces if macOS changes their membership. Minimized and fullscreen windows are not supported. Window identity uses the undocumented, read-only `_AXUIElementGetWindow` function; it does not require disabling SIP. Native multi-Space behavior needs testing with your apps and macOS version. + ## Troubleshooting - **Nothing is restored:** check Accessibility is on for Monitor Glue. If it looks on but the @@ -115,6 +121,8 @@ rebuilds keep the permission. compiled in (`MG_PREVIEW=menu`, `manager`, `diag`, and friends — see `DebugPreview.swift`). Release builds leave all of it out. +Run the regression tests with `swift test` using full Xcode 15 or later (Swift 5.9+ and XCTest). For a manual check, put two windows of the same app with the same title in different Spaces, visit both while docked, disconnect, then reconnect. Verify that windows on all Spaces restore without switching desktops. For windows that remain unavailable, check restoration when visiting their Space more than three minutes later. Resize a restored window and verify it stays resized when visiting another Space. Quit and reopen an app to check that ambiguous windows are not assigned each other's saved frames. Hide an app with Command-H before reconnecting, then unhide it without changing Spaces: its windows should restore, and subsequent resizing should be remembered. + ## Contributing Issues and pull requests are welcome — especially reports of apps or monitor setups that don't diff --git a/Sources/MonitorGlue/LayoutCapturer.swift b/Sources/MonitorGlue/LayoutCapturer.swift index 4e1f5b3..3105481 100644 --- a/Sources/MonitorGlue/LayoutCapturer.swift +++ b/Sources/MonitorGlue/LayoutCapturer.swift @@ -51,48 +51,17 @@ final class LayoutCapturer { currentSetKey = setKey let live = WindowManager.currentWindows() - var layouts: [WindowLayout] = [] - let now = Date() - - // Saved windows the user has moved onto the built-in screen: forget them, rather than - // pulling them back to this monitor. Resolved with the same matcher restore uses, so - // the two always agree about which open window a saved entry refers to. Keying this on - // the window index instead made entries churn, because an app's window indices shift as - // its windows come and go. - var movedToBuiltIn = Set() - if let saved = LayoutStore.shared.record(for: setKey)?.windows { - let matched = LayoutMatcher.match(saved, to: live) - for (i, win) in matched { - if WindowManager.display(for: win, in: displays)?.isBuiltin == true { - movedToBuiltIn.insert(saved[i].id) - } - } - } + let snapshot = Snapshot(live: live, displays: displays, + saved: LayoutStore.shared.record(for: setKey)?.windows ?? [], + protected: LayoutRestorer.pendingWindowIDs(for: setKey)) + let layouts = snapshot.windows + let replaced = snapshot.replaced - for win in live { - // Only remember windows that live on an external display. - guard let disp = WindowManager.display(for: win, in: displays), !disp.isBuiltin else { continue } - // Store the position RELATIVE to the display's origin, so restore works even when - // the display comes back at a different arrangement origin next session. - layouts.append(WindowLayout( - appBundleID: win.appBundleID, - appName: win.appName, - displayUUID: disp.uuid, - windowTitle: win.title, - windowIndex: win.index, - x: win.frame.origin.x - disp.bounds.origin.x, - y: win.frame.origin.y - disp.bounds.origin.y, - width: win.frame.size.width, height: win.frame.size.height, - updatedAt: now - )) - } - - guard !layouts.isEmpty else { return } + guard !layouts.isEmpty || !replaced.isEmpty else { return } // Diff: skip the write if the layout is unchanged (ignore timestamps). - let hash = (layouts.map { "\($0.appBundleID)\($0.windowIndex)\($0.x)\($0.y)\($0.width)\($0.height)" } - + movedToBuiltIn.sorted().map { "builtin:\($0)" }) - .joined().hashValue + let hash = ([setKey] + layouts.map { "\($0.id)|\($0.displayUUID)|\($0.windowTitle)|\($0.x)|\($0.y)|\($0.width)|\($0.height)" }.sorted() + + replaced.sorted()).joined(separator: "\n").hashValue // Skip unchanged snapshots, but still write about once a day: the write refreshes each // window's "last seen" time, which is what keeps a window that simply never moves from // being mistaken for a closed one by the 30-day cleanup. @@ -104,8 +73,53 @@ final class LayoutCapturer { setKey: setKey, displays: DisplayInfo.records(for: displays), windows: layouts, - evictSlots: movedToBuiltIn + evictSlots: replaced ) Log.write("captured \(layouts.count) window(s) for \(externals.map { $0.localizedName }.joined(separator: " + "))") } + + /// A partial view of the desktop must never replace an entire monitor's history. + struct Snapshot { + var windows: [WindowLayout] + var replaced: Set + + init(live: [LiveWindow], displays: [LiveDisplay], saved: [WindowLayout], protected: Set) { + var layouts: [WindowLayout] = [] + let now = Date() + + let matched = LayoutMatcher.match(saved, to: live) + let protectedWindows = Set(matched.compactMap { i, win in + protected.contains(saved[i].id) ? win.identity : nil + }) + // Replace matched entries only when observed on screen and fully restored. Inactive + // Spaces may still have macOS's temporary laptop frames after a disconnect. + let replaced = Set(matched.compactMap { i, win -> String? in + guard win.isOnScreen, !protected.contains(saved[i].id), + WindowManager.display(for: win, in: displays) != nil else { return nil } + return saved[i].id + }) + + for win in live where win.isOnScreen && !protectedWindows.contains(win.identity) { + // Only remember windows that live on an external display. + guard let disp = WindowManager.display(for: win, in: displays), !disp.isBuiltin else { continue } + // Store the position RELATIVE to the display's origin, so restore works even when + // the display comes back at a different arrangement origin next session. + layouts.append(WindowLayout( + appBundleID: win.appBundleID, + appName: win.appName, + displayUUID: disp.uuid, + windowTitle: win.title, + windowIndex: win.index, + x: win.frame.origin.x - disp.bounds.origin.x, + y: win.frame.origin.y - disp.bounds.origin.y, + width: win.frame.size.width, height: win.frame.size.height, + updatedAt: now, + identity: win.identity + )) + } + + windows = layouts + self.replaced = replaced + } + } } diff --git a/Sources/MonitorGlue/LayoutMatcher.swift b/Sources/MonitorGlue/LayoutMatcher.swift index a632b61..ba1098b 100644 --- a/Sources/MonitorGlue/LayoutMatcher.swift +++ b/Sources/MonitorGlue/LayoutMatcher.swift @@ -1,54 +1,34 @@ import AppKit -/// Works out which open window belongs to which saved layout. -/// -/// Both restoring and forgetting need the same answer, and they must agree: if capture decides -/// a saved entry belongs to a window the user parked on the built-in screen, restore has to -/// reach the same conclusion, or one undoes the other. +/// Shared by capture and restore so both identify the same window across Space switches. enum LayoutMatcher { - - /// - Returns: index into `layouts` → the open window it refers to. static func match(_ layouts: [WindowLayout], to live: [LiveWindow]) -> [Int: LiveWindow] { var assignment: [Int: LiveWindow] = [:] - var used = Set() - - func token(_ w: LiveWindow) -> UInt { UInt(bitPattern: ObjectIdentifier(w.element).hashValue) } - func claim(_ i: Int, _ w: LiveWindow) { assignment[i] = w; used.insert(token(w)) } - func available(_ bundleID: String) -> [LiveWindow] { - live.filter { $0.appBundleID == bundleID && !used.contains(token($0)) } - } + var used = Set() + func claim(_ i: Int, _ j: Int) { assignment[i] = live[j]; used.insert(j) } - // Strongest signal first, across all layouts, so an early weak guess cannot steal the - // window a later layout identifies exactly. - for (i, layout) in layouts.enumerated() where assignment[i] == nil { - guard !layout.windowTitle.isEmpty else { continue } - if let w = available(layout.appBundleID).first(where: { $0.title == layout.windowTitle }) { - claim(i, w) + for (i, layout) in layouts.enumerated() { + guard let identity = layout.identity else { continue } + if let j = live.indices.first(where: { !used.contains($0) && live[$0].identity == identity }) { + claim(i, j) } } + // Old files and relaunched apps have no usable window number. Only a unique title + // is safe: window-list indices and duplicate titles can refer to another Space. for (i, layout) in layouts.enumerated() where assignment[i] == nil { - if let w = available(layout.appBundleID).first(where: { $0.index == layout.windowIndex }) { - claim(i, w) + guard layout.identity?.isRunning != true, !layout.windowTitle.isEmpty else { continue } + let saved = layouts.indices.filter { + layouts[$0].appBundleID == layout.appBundleID && layouts[$0].windowTitle == layout.windowTitle } - } - // Last resort: only when there is nothing to get wrong — exactly one unmatched saved - // window for the app and exactly one unmatched open window. Guessing beyond that is how - // a window the user deliberately moved to the laptop got dragged back to the monitor. - for (i, layout) in layouts.enumerated() where assignment[i] == nil { - let candidates = available(layout.appBundleID) - let unmatchedForApp = layouts.enumerated() - .filter { $0.element.appBundleID == layout.appBundleID && assignment[$0.offset] == nil } - if candidates.count == 1, unmatchedForApp.count == 1 { - claim(i, candidates[0]) + let candidates = live.indices.filter { + !used.contains($0) && live[$0].appBundleID == layout.appBundleID && live[$0].title == layout.windowTitle } + if saved.count == 1, candidates.count == 1 { claim(i, candidates[0]) } } return assignment } - /// How a layout was matched, for the log. static func reason(_ layout: WindowLayout, _ window: LiveWindow) -> String { - if !layout.windowTitle.isEmpty, window.title == layout.windowTitle { return "title" } - if window.index == layout.windowIndex { return "index" } - return "only-window" + layout.identity == window.identity ? "window-id" : "title" } } diff --git a/Sources/MonitorGlue/LayoutRestorer.swift b/Sources/MonitorGlue/LayoutRestorer.swift index 6e7bf71..abfd07b 100644 --- a/Sources/MonitorGlue/LayoutRestorer.swift +++ b/Sources/MonitorGlue/LayoutRestorer.swift @@ -1,7 +1,7 @@ import AppKit /// Repositions windows to their saved frames when a known monitor set reconnects. -/// Matching is best-effort: bundle ID is required, then exact title, then window index. +/// Inaccessible Spaces remain pending until their windows become available. enum LayoutRestorer { /// Last logged outcome and how many identical ones followed it. Retries now run several @@ -9,11 +9,13 @@ enum LayoutRestorer { private static var lastSignature = "" private static var suppressedRepeats = 0 - /// Layouts already placed during the current restore cycle. Retries keep running while some - /// other window is still missing (its app may yet reopen), and without this they would keep - /// re-applying the saved frame to windows that are already done - overriding the user every - /// few seconds when they resize one. - private static var placedThisCycle = Set() + /// Unplaced saved IDs stay protected from capture, even after retries stop. + private static var cycleKey = "" + private static var pending = Set() + + static func pendingWindowIDs(for key: String) -> Set { + key == cycleKey ? pending : [] + } /// The frame each layout actually ended up with on the previous attempt. Some apps refuse a /// size outright (Slack will not go narrower than its minimum), so the target can never be @@ -22,8 +24,9 @@ enum LayoutRestorer { private static var lastAchieved = [String: CGRect]() /// Start a new restore cycle for a display change; forget what was placed in the last one. - static func beginCycle() { - placedThisCycle.removeAll() + static func beginCycle(setKey: String) { + cycleKey = setKey + pending = Set(LayoutStore.shared.record(for: setKey)?.windows.map(\.id) ?? []) lastAchieved.removeAll() } @@ -55,6 +58,7 @@ enum LayoutRestorer { return Outcome() } + pending.formIntersection(record.windows.map(\.id)) let displays = DisplayInfo.liveDisplays() let live = WindowManager.currentWindows() let liveByBundle = Dictionary(grouping: live, by: { $0.appBundleID }) @@ -79,17 +83,8 @@ enum LayoutRestorer { details.append(" MISS \(layout.appName) idx=\(layout.windowIndex) '\(layout.windowTitle.prefix(28))' — no open window to place") continue } - // Past the settle phase, a window sitting on the built-in screen is there because - // the user put it there - dragging a window to the laptop must stick even while - // this cycle is still waiting on some other app. During the settle phase we do move - // built-in windows, since that is exactly the pile macOS makes on reconnect. - if !enforce, WindowManager.display(for: win, in: displays)?.isBuiltin == true { - details.append(" SKIP \(layout.appName) idx=\(layout.windowIndex) '\(win.title.prefix(24))' — user moved it to the built-in screen") - continue - } - // Already done earlier in this cycle: leave it alone, so the user can resize it - // while we are still waiting for some other app to reopen. - if !enforce, placedThisCycle.contains(layout.id) { + // Once restored, leave user moves/resizes and freshly captured layouts alone. + if !enforce, !pending.contains(layout.id) { moved += 1 alreadyInPlace += 1 continue @@ -97,7 +92,7 @@ enum LayoutRestorer { if win.frame.matches(target) { moved += 1 alreadyInPlace += 1 - placedThisCycle.insert(layout.id) + pending.remove(layout.id) continue } let ok = WindowManager.setFrame(win.element, target) @@ -105,21 +100,22 @@ enum LayoutRestorer { var note = "" if ok { moved += 1 - placedThisCycle.insert(layout.id) - } else if let previous = lastAchieved[layout.id], previous.matches(actual) { + pending.remove(layout.id) + } else if win.isOnScreen, actual.width > 0, actual.height > 0, + disp.bounds.contains(CGPoint(x: actual.midX, y: actual.midY)), + let previous = lastAchieved[layout.id], previous.matches(actual) { // Second attempt landed in exactly the same place: the app will not accept the // saved frame. Take what it gives, remember that, and stop fighting it. moved += 1 - placedThisCycle.insert(layout.id) + pending.remove(layout.id) let settled = CGRect(x: actual.origin.x - disp.bounds.origin.x, y: actual.origin.y - disp.bounds.origin.y, width: actual.width, height: actual.height) - LayoutStore.shared.replaceFrame(setKey: setKey, bundleID: layout.appBundleID, - windowIndex: layout.windowIndex, with: settled) + LayoutStore.shared.replaceFrame(setKey: setKey, windowID: layout.id, with: settled) note = " — app refused this frame; saving what it allows" } else { lastAchieved[layout.id] = actual - pendingPlacements += 1 + if win.isOnScreen { pendingPlacements += 1 } } details.append(" \(ok ? "OK " : "BAD ") \(layout.appName) idx=\(layout.windowIndex) via=\(LayoutMatcher.reason(layout, win)) '\(win.title.prefix(24))' want=\(str(target)) got=\(str(actual))\(note)") } diff --git a/Sources/MonitorGlue/LayoutStore.swift b/Sources/MonitorGlue/LayoutStore.swift index b5bb869..c160184 100644 --- a/Sources/MonitorGlue/LayoutStore.swift +++ b/Sources/MonitorGlue/LayoutStore.swift @@ -12,19 +12,20 @@ struct DisplayInfoRecord: Codable, Hashable { /// A remembered window's saved frame on a specific external display. struct WindowLayout: Codable, Hashable, Identifiable { - var id: String { "\(appBundleID)#\(displayUUID)#\(windowIndex)#\(windowTitle)" } + var id: String { identity?.key ?? "\(appBundleID)#\(displayUUID)#\(windowIndex)#\(windowTitle)" } var appBundleID: String var appName: String var displayUUID: String var windowTitle: String var windowIndex: Int - // Frame in AX global (top-left origin) coordinates. + // Frame relative to the saved display origin (top-left coordinates). var x: Double var y: Double var width: Double var height: Double var updatedAt: Date + var identity: WindowIdentity? = nil var frame: CGRect { get { CGRect(x: x, y: y, width: width, height: height) } @@ -41,6 +42,13 @@ struct MonitorSetRecord: Codable, Identifiable { var lastSeen: Date var windows: [WindowLayout] + /// Merge only observed windows, preserving windows on unvisited Spaces. + mutating func merge(_ captured: [WindowLayout], evicting ids: Set) { + let replaced = ids.union(captured.map(\.id)) + windows.removeAll { replaced.contains($0.id) } + windows.append(contentsOf: captured) + } + /// Human label like "DELL U2720Q + LG HDR 4K". var label: String { displays.map { $0.localizedName }.sorted().joined(separator: " + ") @@ -115,15 +123,9 @@ final class LayoutStore { /// Upsert a monitor set's metadata and merge in freshly captured windows. /// - /// Merges rather than replaces. A snapshot only sees windows that are on the external - /// display right now, so replacing wholesale would forget an app that is currently closed, - /// or one whose window the user happens to have parked on the built-in screen. Entries not - /// present in this snapshot are kept as they were, which is also what makes sizes "belong" - /// to the monitor: only a change made while the window is on that monitor updates it. - /// - Parameter evictSlots: ids of saved windows whose open window is currently on the - /// built-in display. The user moved them off this monitor deliberately, so stop managing - /// them here. A saved window with no live window at all is left alone - that is an app - /// that simply is not open. + /// Unobserved Spaces retain their saved windows. Matched entries are replaced by the + /// snapshot (including legacy identities); windows moved to the laptop are only evicted. + /// - Parameter evictSlots: saved IDs replaced or deliberately moved off the monitor. func upsert(setKey: String, displays: [DisplayInfoRecord], windows: [WindowLayout], evictSlots: Set = []) { queue.sync { @@ -132,20 +134,7 @@ final class LayoutStore { record.displays = displays record.lastSeen = Date() - // Key by app + window index, which is how restore matches them back up. - func slot(_ w: WindowLayout) -> String { "\(w.appBundleID)#\(w.windowIndex)" } - var merged = record.windows - for captured in windows { - if let existing = merged.firstIndex(where: { slot($0) == slot(captured) }) { - merged[existing] = captured - } else { - merged.append(captured) - } - } - if !evictSlots.isEmpty { - merged.removeAll { evictSlots.contains($0.id) } - } - record.windows = merged + record.merge(windows, evicting: evictSlots) data.monitorSets[setKey] = LayoutStore.pruned(record) } save() @@ -154,11 +143,11 @@ final class LayoutStore { /// Record the frame a window actually ended up with, when the app refused the saved one /// (Slack will not go below its minimum width, for example). Without this the app would /// keep trying to apply a size that can never be reached. - func replaceFrame(setKey: String, bundleID: String, windowIndex: Int, with frame: CGRect) { + func replaceFrame(setKey: String, windowID: String, with frame: CGRect) { queue.sync { guard var record = data.monitorSets[setKey] else { return } guard let i = record.windows.firstIndex(where: { - $0.appBundleID == bundleID && $0.windowIndex == windowIndex + $0.id == windowID }) else { return } record.windows[i].frame = frame record.windows[i].updatedAt = Date() diff --git a/Sources/MonitorGlue/MonitorGlueApp.swift b/Sources/MonitorGlue/MonitorGlueApp.swift index f74dd62..798aa79 100644 --- a/Sources/MonitorGlue/MonitorGlueApp.swift +++ b/Sources/MonitorGlue/MonitorGlueApp.swift @@ -65,6 +65,7 @@ final class AppModel: ObservableObject { private var pollTimer: Timer? /// Set while a restore for this monitor set has not yet placed every saved window. private var restorePendingKey: String? + private var restoreGeneration = 0 private var recheckDebounce: DispatchWorkItem? func start() { @@ -79,6 +80,18 @@ final class AppModel: ObservableObject { } watcher.start() observeWakeEvents() + // Hidden apps can become visible without a Space change. + for name in [NSWorkspace.activeSpaceDidChangeNotification, + NSWorkspace.didUnhideApplicationNotification, + NSWorkspace.didActivateApplicationNotification] { + NSWorkspace.shared.notificationCenter.addObserver( + forName: name, object: nil, queue: .main + ) { [weak self] _ in + // Suppress capture before the transition can expose temporary laptop frames. + LayoutCapturer.shared.suppressCapture(for: 2) + self?.resumePendingRestore() + } + } currentSetKey = watcher.currentKey LayoutCapturer.shared.currentSetKey = currentSetKey @@ -149,6 +162,8 @@ final class AppModel: ObservableObject { } private func handleSetChange(_ key: String) { + restoreGeneration += 1 + restorePendingKey = nil currentSetKey = key LayoutCapturer.shared.currentSetKey = key let known = !key.isEmpty && LayoutStore.shared.record(for: key) != nil @@ -166,11 +181,19 @@ final class AppModel: ObservableObject { /// Capture stays suppressed for as long as a restore is unfinished. Otherwise a failed /// restore gets immortalised: macOS leaves the windows at their built-in-display sizes, and /// the next snapshot overwrites the good saved layout with those sizes. - private func restoreWithRetries(_ key: String) { + private func resumePendingRestore() { + let key = currentSetKey + guard !key.isEmpty, !LayoutRestorer.pendingWindowIDs(for: key).isEmpty, + restorePendingKey != key else { return } + restoreWithRetries(key, newCycle: false) + } + + private func restoreWithRetries(_ key: String, newCycle: Bool = true) { + restoreGeneration += 1 restorePendingKey = key - LayoutRestorer.beginCycle() + if newCycle { LayoutRestorer.beginCycle(setKey: key) } LayoutCapturer.shared.suppressCapture(for: 10) - attemptRestore(key, elapsed: 0) + attemptRestore(key, elapsed: 0, generation: restoreGeneration, enforcePlaced: newCycle) } /// One restore attempt, rescheduling itself until the layout is on screen. The interval is @@ -178,12 +201,13 @@ final class AppModel: ObservableObject { /// answering the Accessibility API slowly after the display change — polling every few /// hundred milliseconds means we place the windows as soon as they become visible to us, /// instead of waiting out a sparse fixed schedule. - private func attemptRestore(_ key: String, elapsed: Double, lockedWait: Bool = false) { + private func attemptRestore(_ key: String, elapsed: Double, generation: Int, enforcePlaced: Bool, lockedWait: Bool = false) { // While locked there is nothing to poll for quickly; check back at a calm pace. let interval: Double = lockedWait ? 2.0 : (elapsed < 10 ? 0.4 : (elapsed < 30 ? 1.0 : 3.0)) let giveUpAfter = 180.0 DispatchQueue.main.asyncAfter(deadline: .now() + interval) { + guard self.restoreGeneration == generation else { return } guard self.currentSetKey == key else { return } // display changed again guard self.restorePendingKey == key else { return } // already satisfied @@ -192,14 +216,14 @@ final class AppModel: ObservableObject { // not let the give-up clock run down, or unlocking ten minutes later would // find capture already recording a layout that was never restored. LayoutCapturer.shared.suppressCapture(for: 45) - self.attemptRestore(key, elapsed: elapsed, lockedWait: true) + self.attemptRestore(key, elapsed: elapsed, generation: generation, enforcePlaced: enforcePlaced, lockedWait: true) return } // For the first few seconds keep asserting the saved frames, since macOS is still // shuffling windows. After that, respect windows already placed in this cycle so // resizing one actually sticks while we wait for some other app to reopen. - let outcome = LayoutRestorer.restore(setKey: key, enforce: elapsed < 5) + let outcome = LayoutRestorer.restore(setKey: key, enforce: enforcePlaced && elapsed < 5) if outcome.isComplete { self.restorePendingKey = nil // Layout is on screen — let capture take over again shortly. @@ -222,16 +246,16 @@ final class AppModel: ObservableObject { // again (e.g. the saved app was closed for good). self.restorePendingKey = nil LayoutCapturer.shared.resumeCapture() - Log.write("restore for key=\(key.prefix(8)) never completed after \(Int(next))s — resuming capture; saved layout may now be overwritten by what is on screen") + Log.write("restore for key=\(key.prefix(8)) never completed after \(Int(next))s — resuming capture; unplaced windows remain protected until their Space becomes available") return } - self.attemptRestore(key, elapsed: next) + self.attemptRestore(key, elapsed: next, generation: generation, enforcePlaced: enforcePlaced) } } func restoreNow() { guard !currentSetKey.isEmpty else { return } - LayoutRestorer.restore(setKey: currentSetKey) + restoreWithRetries(currentSetKey) } func refreshStatus() { diff --git a/Sources/MonitorGlue/WindowManager.swift b/Sources/MonitorGlue/WindowManager.swift index b3382d8..02da025 100644 --- a/Sources/MonitorGlue/WindowManager.swift +++ b/Sources/MonitorGlue/WindowManager.swift @@ -1,26 +1,49 @@ import AppKit import ApplicationServices +import Darwin /// A live window read from the Accessibility API. struct LiveWindow { var element: AXUIElement var appBundleID: String var appName: String - var pid: pid_t var title: String var index: Int // Index within the app's window list. var frame: CGRect // AX global coords (top-left origin). + var identity: WindowIdentity + var isOnScreen: Bool = true +} + +/// Window numbers survive Space switches, but may be reused after the owning app exits. +struct WindowIdentity: Codable, Hashable { + var pid: pid_t + var launchedAt: TimeInterval + var number: CGWindowID + + var key: String { "\(pid):\(launchedAt):\(number)" } + var isRunning: Bool { NSRunningApplication(processIdentifier: pid)?.launchDate?.timeIntervalSince1970 == launchedAt } } /// Reads and repositions other applications' windows via the Accessibility API. /// All coordinates are AX global (top-left origin), matching `CGDisplayBounds`. enum WindowManager { + // AXWindows may omit inactive Spaces. Retain references learned on earlier visits; + // invalidate them when their window closes or the owning process exits. + private static var knownWindows: [WindowIdentity: LiveWindow] = [:] + private typealias WindowNumber = @convention(c) (AXUIElement, UnsafeMutablePointer) -> AXError + private static let windowNumber: WindowNumber? = { + guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "_AXUIElementGetWindow") else { + Log.write("window tracking unavailable — macOS window identity API is missing") + return nil + } + return unsafeBitCast(symbol, to: WindowNumber.self) + }() + // MARK: Enumeration static func currentWindows() -> [LiveWindow] { guard AXIsProcessTrusted() else { return [] } - var result: [LiveWindow] = [] let ownPID = ProcessInfo.processInfo.processIdentifier // Per-bundle counter so indices are dense and unique even when an app has several // processes sharing one bundle ID (Chrome profiles) or windows were filtered out. @@ -30,7 +53,8 @@ enum WindowManager { for app in NSWorkspace.shared.runningApplications { guard app.activationPolicy == .regular, app.processIdentifier != ownPID, - let bundleID = app.bundleIdentifier else { continue } + let bundleID = app.bundleIdentifier, + let launchedAt = app.launchDate?.timeIntervalSince1970 else { continue } let appElement = AXUIElementCreateApplication(app.processIdentifier) // Right after a display change apps are busy re-laying out and answer AX slowly. @@ -52,18 +76,41 @@ enum WindowManager { let title = (copyValue(win, kAXTitleAttribute) as? String) ?? "" let idx = nextIndex[bundleID, default: 0] nextIndex[bundleID] = idx + 1 - result.append(LiveWindow( + var number: CGWindowID = 0 + guard windowNumber?(win, &number) == .success, number != 0 else { continue } + let identity = WindowIdentity(pid: app.processIdentifier, launchedAt: launchedAt, number: number) + knownWindows[identity] = LiveWindow( element: win, appBundleID: bundleID, appName: app.localizedName ?? bundleID, - pid: app.processIdentifier, title: title, index: idx, - frame: frame - )) + frame: frame, + identity: identity + ) + } + } + let visible = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) + as? [[String: Any]] ?? [] + let visibleIDs = Set(visible.compactMap { ($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value }) + var windows: [LiveWindow] = [] + for (identity, var window) in knownWindows { + var role: CFTypeRef? + let error = AXUIElementCopyAttributeValue(window.element, kAXRoleAttribute as CFString, &role) + guard identity.isRunning, error != .invalidUIElement else { + knownWindows.removeValue(forKey: identity) + continue } + guard let frame = frame(of: window.element) else { continue } + // Minimized and fullscreen windows remain cached for when they return to normal. + guard (copyValue(window.element, "AXFullScreen") as? Bool) != true else { continue } + guard (copyValue(window.element, kAXMinimizedAttribute) as? Bool) != true else { continue } + window.frame = frame + window.title = (copyValue(window.element, kAXTitleAttribute) as? String) ?? window.title + window.isOnScreen = visibleIDs.contains(identity.number) + windows.append(window) } - return result + return windows } // MARK: Frame read/write diff --git a/Tests/MonitorGlueTests/SpaceLayoutTests.swift b/Tests/MonitorGlueTests/SpaceLayoutTests.swift new file mode 100644 index 0000000..13e80da --- /dev/null +++ b/Tests/MonitorGlueTests/SpaceLayoutTests.swift @@ -0,0 +1,157 @@ +import XCTest +import AppKit +@testable import MonitorGlue + +final class SpaceLayoutTests: XCTestCase { + private let external = LiveDisplay(displayID: 2, uuid: "external", isBuiltin: false, + localizedName: "Monitor", bounds: CGRect(x: 1000, y: 0, width: 2000, height: 1200), + widthPx: 2000, heightPx: 1200) + private let builtin = LiveDisplay(displayID: 1, uuid: "builtin", isBuiltin: true, + localizedName: "Laptop", bounds: CGRect(x: 0, y: 0, width: 1000, height: 800), + widthPx: 1000, heightPx: 800) + + private func window(_ number: UInt32, title: String = "Untitled", index: Int = 0, + visible: Bool = true, x: CGFloat = 1200) -> LiveWindow { + let pid: pid_t = 999_999_999 + return LiveWindow(element: AXUIElementCreateApplication(pid), + appBundleID: "test.editor", appName: "Editor", + title: title, index: index, frame: CGRect(x: x, y: 100, width: 500, height: 400), + identity: WindowIdentity(pid: pid, + launchedAt: 1_791_363_600.123456, number: number), + isOnScreen: visible) + } + + private func layout(_ window: LiveWindow) -> WindowLayout { + WindowLayout(appBundleID: window.appBundleID, appName: window.appName, displayUUID: external.uuid, + windowTitle: window.title, windowIndex: window.index, x: 200, y: 100, + width: 500, height: 400, updatedAt: Date(), identity: window.identity) + } + + private func record(_ windows: [WindowLayout]) -> MonitorSetRecord { + MonitorSetRecord(key: "external", displays: [], lastSeen: Date(), windows: windows) + } + + func testIdenticalTitlesAndIndicesOnDifferentSpacesMatchByIdentity() { + let first = window(10), second = window(20, visible: false) + let matched = LayoutMatcher.match([layout(first), layout(second)], to: [second, first]) + XCTAssertEqual(matched[0]?.identity, first.identity) + XCTAssertEqual(matched[1]?.identity, second.identity) + } + + func testRenamedWindowKeepsItsSavedLayout() { + let original = window(10) + let renamed = window(10, title: "Project B", index: 5) + let matched = LayoutMatcher.match([layout(original)], to: [renamed]) + XCTAssertEqual(matched[0]?.identity, original.identity) + } + + func testUnvisitedWindowCannotStealAnotherWindowsLayout() { + let saved = [layout(window(10)), layout(window(20))] + let matched = LayoutMatcher.match(saved, to: [window(20)]) + XCTAssertNil(matched[0]) + XCTAssertEqual(matched[1]?.identity, window(20).identity) + } + + func testLegacyLayoutWithUniqueTitleStillRestores() { + let live = window(10, title: "Project A") + var saved = layout(live) + saved.identity = nil + XCTAssertEqual(LayoutMatcher.match([saved], to: [live])[0]?.identity, live.identity) + } + + func testLegacyDuplicateTitlesDoNotGuessByWindowIndex() { + var first = layout(window(10, index: 0)), second = layout(window(20, index: 1)) + first.identity = nil + second.identity = nil + XCTAssertTrue(LayoutMatcher.match([first, second], to: [window(20, index: 0)]).isEmpty) + } + + func testRelaunchedAppCanMatchAnUnambiguousTitle() { + let live = window(10, title: "Project A") + var saved = layout(live) + saved.identity?.launchedAt = 0 + XCTAssertEqual(LayoutMatcher.match([saved], to: [live])[0]?.identity, live.identity) + } + + func testIdentitySurvivesTheStoresISO8601Encoding() throws { + let saved = layout(window(10)) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(WindowLayout.self, from: encoder.encode(saved)) + XCTAssertEqual(decoded.identity, saved.identity) + XCTAssertEqual(decoded.id, saved.id) + } + + func testOldLayoutFilesDecodeWithoutIdentity() throws { + let data = Data(""" + {"monitorSets":{"external":{"key":"external","displays":[],"lastSeen":"2026-10-07T09:00:00Z", + "windows":[{"appBundleID":"test.editor","appName":"Editor","displayUUID":"external", + "windowTitle":"Project A","windowIndex":0,"x":200,"y":100,"width":500,"height":400, + "updatedAt":"2026-10-07T09:00:00Z"}]}}} + """.utf8) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let saved = try XCTUnwrap(try decoder.decode(LayoutStoreData.self, from: data).monitorSets["external"]) + XCTAssertNil(saved.windows[0].identity) + XCTAssertEqual(saved.windows[0].windowTitle, "Project A") + } + + func testPartialCapturePreservesOtherSpacesWithIdenticalWindowIndices() { + let first = window(10), second = window(20, visible: false) + var saved = record([layout(first), layout(second)]) + let snapshot = LayoutCapturer.Snapshot(live: [first, second], displays: [builtin, external], + saved: saved.windows, protected: []) + saved.merge(snapshot.windows, evicting: snapshot.replaced) + XCTAssertEqual(Set(saved.windows.map(\.id)), Set([layout(first).id, layout(second).id])) + XCTAssertEqual(snapshot.windows.count, 1) + } + + func testPendingWindowOnLaptopIsNotForgotten() { + let original = layout(window(10)) + let snapshot = LayoutCapturer.Snapshot(live: [window(10, x: 100)], displays: [builtin, external], + saved: [original], protected: [original.id]) + XCTAssertTrue(snapshot.windows.isEmpty) + XCTAssertTrue(snapshot.replaced.isEmpty) + } + + func testPendingWindowOnExternalMonitorDoesNotOverwriteSavedSize() { + let original = layout(window(10)) + var resized = window(10) + resized.frame.size.width = 200 + let snapshot = LayoutCapturer.Snapshot(live: [resized], displays: [builtin, external], + saved: [original], protected: [original.id]) + var saved = record([original]) + saved.merge(snapshot.windows, evicting: snapshot.replaced) + XCTAssertEqual(saved.windows[0].width, original.width) + } + + func testMovingLastVisibleWindowToLaptopForgetsOnlyThatWindow() { + let first = layout(window(10)), second = layout(window(20, visible: false)) + let snapshot = LayoutCapturer.Snapshot(live: [window(10, x: 100), window(20, visible: false)], + displays: [builtin, external], saved: [first, second], protected: []) + var saved = record([first, second]) + saved.merge(snapshot.windows, evicting: snapshot.replaced) + XCTAssertEqual(saved.windows.map(\.id), [second.id]) + } + + func testCaptureMigratesLegacyEntryWithoutCreatingDuplicate() { + let live = window(10, title: "Project A") + var legacy = layout(live) + legacy.identity = nil + let snapshot = LayoutCapturer.Snapshot(live: [live], displays: [builtin, external], saved: [legacy], protected: []) + var saved = record([legacy]) + saved.merge(snapshot.windows, evicting: snapshot.replaced) + XCTAssertEqual(saved.windows.count, 1) + XCTAssertEqual(saved.windows[0].identity, live.identity) + } + + func testRenameUpdatesExistingEntryInsteadOfAccumulatingOldTitles() { + let first = layout(window(10)) + var saved = record([first]) + saved.merge([layout(window(10, title: "Renamed"))], evicting: []) + XCTAssertEqual(saved.windows.count, 1) + XCTAssertEqual(saved.windows[0].windowTitle, "Renamed") + } +}