From 4737a50b67bdd7a11cbe0cfff2c789f96ded897c Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:01:47 +0530 Subject: [PATCH] fix(mac): start the arrangement restore window on the first tick After sleep, a mode change that blocked the main actor for ~10 s let the first manageOrigin() tick run after the 6 s restore window armed at creation had already closed. That tick reported macOS's default origin as a user drag, and DisplayArrangement saved it over the device's placement, so every later session put the display back there. The restore/observe policy moves into a pure OriginRestore struct whose window starts on the first tick. A window that closes without one observation starts observing instead of saving wherever macOS left the display; first contact still records the initial spot. Ported onto this line's VirtualDisplay rather than cherry-picked (the original conflicts). The fork-only repositionForHeadlessMain gets retarget(to:), which re-arms the window without marking the new origin observed, so a headless move whose applies all fail never saves the display's current spot. Upstream's nine OriginRestore tests are kept; two more cover the headless retarget. Original change by peetzweg in upstream OpenDisplay (GPL-3.0), PR 392, closing upstream issue 390. (adapted from commit 1522cd22699b21932d69c718b4afc18bee45d546) --- Mac/OriginRestore.swift | 89 +++++++++++++++++++++ Mac/VirtualDisplay.swift | 73 ++++++----------- MacTests/OriginRestoreTests.swift | 128 ++++++++++++++++++++++++++++++ project.yml | 1 + 4 files changed, 244 insertions(+), 47 deletions(-) create mode 100644 Mac/OriginRestore.swift create mode 100644 MacTests/OriginRestoreTests.swift diff --git a/Mac/OriginRestore.swift b/Mac/OriginRestore.swift new file mode 100644 index 00000000..b88ce94a --- /dev/null +++ b/Mac/OriginRestore.swift @@ -0,0 +1,89 @@ +import CoreGraphics +import Foundation + +/// Decides, tick by tick, whether a virtual display's origin is ours to +/// enforce or the user's to persist (#116). +/// +/// For a few seconds after creation (or a resize), macOS may restore ITS +/// saved arrangement for the display identity, which is stale whenever the +/// identity is fresh. During that window we re-assert `target`. Afterwards, +/// an origin change is the user rearranging and gets reported. +/// +/// The window is measured from the first tick, not from when it was armed: +/// a tick stalled behind a wedged WindowServer (seen after sleep, a 10 s +/// mode change) must not burn the window before it ever ran, or macOS's +/// default spot is reported as a drag and overwrites the saved placement +/// (#390). +struct OriginRestore { + enum Action: Equatable { + case none + /// Move the display here, then pass where it settled to `settled(at:)`. + case apply(CGPoint) + /// The user moved the display: persist this origin. + case report(CGPoint) + } + + static let window: TimeInterval = 6 + + private var target: CGPoint? + private var deadline: Date? + private var lastObserved: CGPoint? + + init(target: CGPoint?) { + self.target = target + } + + /// Enforce `origin` for a fresh window, e.g. after a mode change that + /// already placed the display there. `origin` counts as observed. + mutating func rearm(at origin: CGPoint) { + target = origin + deadline = nil + lastObserved = origin + } + + /// Enforce `origin` for a fresh window when the display is not there + /// yet, e.g. moving it to (0,0) to become the main display while going + /// headless. Unlike `rearm(at:)`, `origin` is not observed, so a window + /// whose every apply fails never reports the display's current spot. + mutating func retarget(to origin: CGPoint) { + target = origin + deadline = nil + } + + mutating func tick(origin: CGPoint, now: Date = Date()) -> Action { + if let target { + let deadline = self.deadline ?? now.addingTimeInterval(Self.window) + self.deadline = deadline + if now < deadline { + // Initial arrangement is system state, not a user drag (#203). + if origin == target { + lastObserved = origin + return .none + } + return .apply(target) + } + self.target = nil + // A window that closed without one observation (every apply + // failed) still had a placement to protect: start observing + // here instead of persisting wherever macOS left the display. + if lastObserved == nil { + lastObserved = origin + return .none + } + } + // With no saved placement (first contact), the first origin is + // reported so the device gains one. + guard origin != lastObserved else { return .none } + lastObserved = origin + return .report(origin) + } + + /// WindowServer snaps a requested origin to the nearest valid + /// arrangement. Adopt the snap as the target, or every remaining tick of + /// the window re-applies against it; a snap is system state too, so it + /// is observed, not reported. + mutating func settled(at origin: CGPoint) { + target = origin + lastObserved = origin + } +} diff --git a/Mac/VirtualDisplay.swift b/Mac/VirtualDisplay.swift index 59babfba..a7adc58d 100644 --- a/Mac/VirtualDisplay.swift +++ b/Mac/VirtualDisplay.swift @@ -14,9 +14,7 @@ final class VirtualDisplay { private var refreshRate: Int private var hidpiBackoff = HiDPIEnforcementBackoff() - private var restoreTarget: CGPoint? - private var restoreUntil: Date - private var lastReportedOrigin: CGPoint? + private var originRestore: OriginRestore private let onOriginChange: ((CGPoint, CGSize) -> Void)? var displayID: CGDirectDisplayID { display.displayID } @@ -49,8 +47,7 @@ final class VirtualDisplay { // tablet change orientation by applying a new mode to this *same* // virtual monitor instead of removing it and stranding its windows. maxPointsPerAxis = max(pointsWide, pointsHigh) - self.restoreTarget = restoreOrigin - self.restoreUntil = restoreOrigin == nil ? .distantPast : Date().addingTimeInterval(6) + self.originRestore = OriginRestore(target: restoreOrigin) self.onOriginChange = onOriginChange let descriptor = CGVirtualDisplayDescriptor() @@ -151,9 +148,7 @@ final class VirtualDisplay { // on: a snap is system state, and persisting it as if it were a // user drag is the ratchet #203 is about. let settled = CGDisplayBounds(display.displayID).origin - restoreTarget = settled - restoreUntil = Date().addingTimeInterval(6) - lastReportedOrigin = settled + originRestore.rearm(at: settled) Log.info("virtual display \(display.displayID) resized to \(pointsWide)x\(pointsHigh)pt " + "at (\(Int(origin.x)),\(Int(origin.y))), settled " + "(\(Int(settled.x)),\(Int(settled.y))) (result \(err.rawValue))") @@ -210,57 +205,41 @@ final class VirtualDisplay { } } - /// Arrangement restore + observation (#116). For the first few seconds, - /// assert `restoreTarget`: macOS restores ITS saved arrangement for this - /// display identity asynchronously, seconds after creation, and that - /// record is stale or default whenever the identity is fresh (rotation - /// swaps the serial, transport switches change it) — the caller's - /// device-keyed record must win. Afterwards, origin changes are the user - /// rearranging: report them so the caller can persist the new spot. - /// Re-arms the restore-window mechanism to move this display to - /// `origin` (CoreGraphics defines "main display" as whichever display - /// sits at (0,0)) without persisting it as a user-driven placement — - /// used when going headless mid-session forces this display to become - /// the system's main display so macOS's login/lock UI has somewhere to - /// render. `manageOrigin()`'s existing enforcement tick drives the - /// actual move on its next pass; the per-device saved arrangement in - /// `DisplayArrangement` is left untouched, exactly like the initial - /// creation-time restore window. + /// Re-arms the restore window to move this display to `origin` + /// (CoreGraphics defines "main display" as whichever display sits at + /// (0,0)) without persisting it as a user-driven placement — used when + /// going headless mid-session forces this display to become the system's + /// main display so macOS's login/lock UI has somewhere to render. + /// `manageOrigin()`'s enforcement tick drives the actual move on its + /// next pass; the per-device saved arrangement in `DisplayArrangement` + /// is left untouched, exactly like the initial creation-time restore + /// window. func repositionForHeadlessMain(to origin: CGPoint = .zero) { - restoreTarget = origin - restoreUntil = Date().addingTimeInterval(6) + originRestore.retarget(to: origin) } + /// Arrangement restore + observation (#116): enforce the caller's + /// device-keyed origin while macOS may still restore its own stale record + /// for this identity (rotation swaps the serial, transport switches + /// change it), then report user drags. Policy in `OriginRestore`. private func manageOrigin() { let id = display.displayID let origin = CGDisplayBounds(id).origin - if let target = restoreTarget, Date() < restoreUntil { - // Initial arrangement is system state, not a user drag. Mark it - // observed so it cannot overwrite the saved device placement - // when the restore window expires (#203). - guard origin != target else { - lastReportedOrigin = origin - return - } + switch originRestore.tick(origin: origin) { + case .none: + break + case .apply(let target): var config: CGDisplayConfigRef? guard CGBeginDisplayConfiguration(&config) == .success else { return } CGConfigureDisplayOrigin(config, id, Int32(target.x), Int32(target.y)) let err = CGCompleteDisplayConfiguration(config, .permanently) - // WindowServer snaps the requested origin to the nearest valid - // arrangement — adopt what it settled on, or every remaining - // tick of the window would re-apply against the snap. - restoreTarget = CGDisplayBounds(id).origin - // A snap is also system state. Keep observing from the settled - // point, but only a later origin change may be a user drag. - lastReportedOrigin = restoreTarget + let settled = CGDisplayBounds(id).origin + originRestore.settled(at: settled) Log.info("display \(id) origin (\(Int(origin.x)),\(Int(origin.y))) → restored " + "(\(Int(target.x)),\(Int(target.y))), settled " - + "(\(Int(restoreTarget!.x)),\(Int(restoreTarget!.y))) (result \(err.rawValue))") - return - } - if origin != lastReportedOrigin { - lastReportedOrigin = origin - onOriginChange?(origin, CGSize(width: pointsWide, height: pointsHigh)) + + "(\(Int(settled.x)),\(Int(settled.y))) (result \(err.rawValue))") + case .report(let moved): + onOriginChange?(moved, CGSize(width: pointsWide, height: pointsHigh)) } } diff --git a/MacTests/OriginRestoreTests.swift b/MacTests/OriginRestoreTests.swift new file mode 100644 index 00000000..f572b2f9 --- /dev/null +++ b/MacTests/OriginRestoreTests.swift @@ -0,0 +1,128 @@ +import CoreGraphics +import XCTest + +final class OriginRestoreTests: XCTestCase { + private let saved = CGPoint(x: -424, y: -1_152) + private let macDefault = CGPoint(x: 1_512, y: 0) + private let t0 = Date(timeIntervalSinceReferenceDate: 0) + + func testRestoresSavedOriginOverMacOSDefault() { + var restore = OriginRestore(target: saved) + + XCTAssertEqual(restore.tick(origin: macDefault, now: t0), .apply(saved)) + restore.settled(at: saved) + XCTAssertEqual(restore.tick(origin: saved, now: t0 + 2), .none) + XCTAssertEqual(restore.tick(origin: saved, now: t0 + 10), .none) + } + + /// #390: after sleep, a mode change blocked the tick for 10 s. The first + /// tick then ran past a window measured from creation, and macOS's + /// default spot was saved as if the user had dragged the display there. + func testStalledFirstTickStillRestoresInsteadOfReporting() { + var restore = OriginRestore(target: saved) + + let firstTick = t0 + 10 + XCTAssertEqual(restore.tick(origin: macDefault, now: firstTick), .apply(saved)) + restore.settled(at: saved) + XCTAssertEqual(restore.tick(origin: saved, now: firstTick + 7), .none) + } + + func testWindowThatNeverLandedDoesNotPersistTheDefault() { + var restore = OriginRestore(target: saved) + + // Every apply failed (no settled(at:)), then the window ran out. + XCTAssertEqual(restore.tick(origin: macDefault, now: t0), .apply(saved)) + XCTAssertEqual(restore.tick(origin: macDefault, now: t0 + 7), .none) + // A later move is still the user's. + let dragged = CGPoint(x: -1_512, y: 32) + XCTAssertEqual(restore.tick(origin: dragged, now: t0 + 9), .report(dragged)) + } + + /// #203: a WindowServer snap is system state. Adopting it must neither + /// re-apply every tick nor be saved once the window closes. + func testSnapIsAdoptedAndNeverReported() { + var restore = OriginRestore(target: saved) + let snapped = CGPoint(x: -424, y: -1_100) + + XCTAssertEqual(restore.tick(origin: macDefault, now: t0), .apply(saved)) + restore.settled(at: snapped) + XCTAssertEqual(restore.tick(origin: snapped, now: t0 + 2), .none) + XCTAssertEqual(restore.tick(origin: snapped, now: t0 + 10), .none) + } + + /// A failed configuration leaves the display where macOS put it. That + /// spot is adopted, not saved over the device's placement. + func testFailedApplyIsNotReportedAfterWindow() { + var restore = OriginRestore(target: saved) + + XCTAssertEqual(restore.tick(origin: macDefault, now: t0), .apply(saved)) + restore.settled(at: macDefault) + XCTAssertEqual(restore.tick(origin: macDefault, now: t0 + 10), .none) + } + + func testDragAfterWindowIsReported() { + var restore = OriginRestore(target: saved) + XCTAssertEqual(restore.tick(origin: saved, now: t0), .none) + + let dragged = CGPoint(x: 1_512, y: 0) + XCTAssertEqual(restore.tick(origin: dragged, now: t0 + 7), .report(dragged)) + XCTAssertEqual(restore.tick(origin: dragged, now: t0 + 9), .none) + } + + func testMacOSRestoreInsideWindowIsOverridden() { + var restore = OriginRestore(target: saved) + XCTAssertEqual(restore.tick(origin: saved, now: t0), .none) + // macOS applies its own stale record a few seconds after creation. + XCTAssertEqual(restore.tick(origin: macDefault, now: t0 + 3), .apply(saved)) + } + + func testFirstContactReportsTheInitialSpot() { + var restore = OriginRestore(target: nil) + + XCTAssertEqual(restore.tick(origin: macDefault, now: t0), .report(macDefault)) + XCTAssertEqual(restore.tick(origin: macDefault, now: t0 + 2), .none) + } + + func testRearmAfterResizeRestartsWindowOnNextTick() { + var restore = OriginRestore(target: saved) + XCTAssertEqual(restore.tick(origin: saved, now: t0), .none) + + let resized = CGPoint(x: -198, y: -1_152) + restore.rearm(at: resized) + // Next tick is late, but the window starts there. + XCTAssertEqual(restore.tick(origin: macDefault, now: t0 + 30), .apply(resized)) + restore.settled(at: resized) + XCTAssertEqual(restore.tick(origin: resized, now: t0 + 40), .none) + } + + /// Going headless moves the display to (0,0) to become the main display. + /// That is system state: the device's saved placement must not change. + func testHeadlessRetargetMovesToMainWithoutReporting() { + var restore = OriginRestore(target: saved) + XCTAssertEqual(restore.tick(origin: saved, now: t0), .none) + XCTAssertEqual(restore.tick(origin: saved, now: t0 + 10), .none) + + restore.retarget(to: .zero) + // The window starts on the next tick, however late it runs. + XCTAssertEqual(restore.tick(origin: saved, now: t0 + 30), .apply(.zero)) + restore.settled(at: .zero) + XCTAssertEqual(restore.tick(origin: .zero, now: t0 + 32), .none) + XCTAssertEqual(restore.tick(origin: .zero, now: t0 + 40), .none) + } + + /// A headless move whose every apply fails leaves the display where it + /// already was. That spot was observed before, so nothing is reported; + /// `rearm(at: .zero)` would have saved it as a drag when the window closed. + func testHeadlessRetargetThatNeverLandsReportsNothing() { + var restore = OriginRestore(target: saved) + XCTAssertEqual(restore.tick(origin: saved, now: t0), .none) + XCTAssertEqual(restore.tick(origin: saved, now: t0 + 10), .none) + + restore.retarget(to: .zero) + XCTAssertEqual(restore.tick(origin: saved, now: t0 + 20), .apply(.zero)) + XCTAssertEqual(restore.tick(origin: saved, now: t0 + 27), .none) + // A later move is still the user's. + let dragged = CGPoint(x: -1_512, y: 32) + XCTAssertEqual(restore.tick(origin: dragged, now: t0 + 30), .report(dragged)) + } +} diff --git a/project.yml b/project.yml index 65df8708..8f3a26cc 100644 --- a/project.yml +++ b/project.yml @@ -175,6 +175,7 @@ targets: sources: - MacTests - Mac/DisplayArrangement.swift + - Mac/OriginRestore.swift - Mac/InputRouting.swift - Mac/SmartTouchTargetClassifier.swift - MacReceiver/ReceiverWindowLifecycle.swift