Skip to content
Draft
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
89 changes: 89 additions & 0 deletions Mac/OriginRestore.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
73 changes: 26 additions & 47 deletions Mac/VirtualDisplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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))")
Expand Down Expand Up @@ -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))
}
}

Expand Down
128 changes: 128 additions & 0 deletions MacTests/OriginRestoreTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
1 change: 1 addition & 0 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ targets:
sources:
- MacTests
- Mac/DisplayArrangement.swift
- Mac/OriginRestore.swift
- Mac/InputRouting.swift
- Mac/SmartTouchTargetClassifier.swift
- MacReceiver/ReceiverWindowLifecycle.swift
Expand Down
Loading