Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
]
)
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
94 changes: 54 additions & 40 deletions Sources/MonitorGlue/LayoutCapturer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
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.
Expand All @@ -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<String>

init(live: [LiveWindow], displays: [LiveDisplay], saved: [WindowLayout], protected: Set<String>) {
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
}
}
}
52 changes: 16 additions & 36 deletions Sources/MonitorGlue/LayoutMatcher.swift
Original file line number Diff line number Diff line change
@@ -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<UInt>()

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<Int>()
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"
}
}
48 changes: 22 additions & 26 deletions Sources/MonitorGlue/LayoutRestorer.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
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
/// times a second, and an unchanged failure repeated 25 times is noise, not information.
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<String>()
/// Unplaced saved IDs stay protected from capture, even after retries stop.
private static var cycleKey = ""
private static var pending = Set<String>()

static func pendingWindowIDs(for key: String) -> Set<String> {
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
Expand All @@ -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()
}

Expand Down Expand Up @@ -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 })
Expand All @@ -79,47 +83,39 @@ 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
}
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)
let actual = WindowManager.frame(of: win.element) ?? .zero
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)")
}
Expand Down
Loading