From 39fa0a4774556555d9ed4e92765835d58d76ef21 Mon Sep 17 00:00:00 2001 From: Tyler Mandry Date: Tue, 27 Sep 2022 22:33:13 -0700 Subject: [PATCH 1/2] Support space restoration with a coding API --- Package.swift | 2 +- Sources/Space.swift | 131 ++++++++++++++++++++++++++++++++++++-------- Sources/State.swift | 73 +++++++++++++++++++----- 3 files changed, 168 insertions(+), 38 deletions(-) diff --git a/Package.swift b/Package.swift index a47c1f3..b4285e2 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Swindler", platforms: [ - .macOS(.v10_12), + .macOS(.v10_13), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Sources/Space.swift b/Sources/Space.swift index a549014..f8ef309 100644 --- a/Sources/Space.swift +++ b/Sources/Space.swift @@ -1,19 +1,31 @@ import Cocoa -class OSXSpaceObserver: NSObject, NSWindowDelegate { +class OSXSpaceObserver: NSObject, NSWindowDelegate, Encodable { private var trackers: [Int: SpaceTracker] = [:] private weak var ssd: SystemScreenDelegate? private var sst: SystemSpaceTracker private weak var notifier: EventNotifier? + private var nextId: Int = 1 - init(_ notifier: EventNotifier?, _ ssd: SystemScreenDelegate, _ sst: SystemSpaceTracker) { + // Maps from NSWindow id back to space id. + var idMap: [NSNumber: Int] = [:] + + convenience init(_ notifier: EventNotifier?, _ ssd: SystemScreenDelegate, _ sst: SystemSpaceTracker) { + self.init(notifier, ssd, sst) { this in + for screen in ssd.screens { + this.makeWindow(screen) + } + } + } + + private init(_ notifier: EventNotifier?, _ ssd: SystemScreenDelegate, _ sst: SystemSpaceTracker, makeTrackers: (OSXSpaceObserver) throws -> ()) rethrows { self.notifier = notifier self.ssd = ssd self.sst = sst super.init() - for screen in ssd.screens { - makeWindow(screen) - } + // Don't install the event handler until we're done making the initial + // set of trackers. + try makeTrackers(self) sst.onSpaceChanged { [weak self] in self?.emitSpaceWillChangeEvent() } @@ -28,9 +40,13 @@ class OSXSpaceObserver: NSObject, NSWindowDelegate { /// happened. @discardableResult private func makeWindow(_ screen: ScreenDelegate) -> Int { + let id = nextId let win = sst.makeTracker(screen) - trackers[win.id] = win - return win.id + idMap[win.systemId] = id + nextId += 1 + trackers[id] = win + log.info("Made tracker \(id) for \(screen)") + return id } /// Emits a SpaceWillChangeEvent on the notifier this observer was @@ -39,7 +55,7 @@ class OSXSpaceObserver: NSObject, NSWindowDelegate { /// Used during initialization. func emitSpaceWillChangeEvent() { guard let ssd = ssd else { return } - let visible = sst.visibleIds() + let visible = sst.visibleIds().compactMap({ idMap[$0] }) log.debug("spaceChanged: visible=\(visible)") let screens = ssd.screens @@ -71,6 +87,31 @@ class OSXSpaceObserver: NSObject, NSWindowDelegate { } notifier?.notify(SpaceWillChangeEvent(external: true, ids: visiblePerScreen)) } + + enum CodingKeys: CodingKey { + case nextSpaceId + case spaceTrackers + } + + required convenience init(from decoder: Decoder, _ notifier: EventNotifier?, _ ssd: SystemScreenDelegate, _ sst: SystemSpaceTracker) throws { + try self.init(notifier, ssd, sst) { this in + let object = try decoder.container(keyedBy: CodingKeys.self) + this.nextId = try object.decode(Int.self, forKey: .nextSpaceId) + this.trackers = try object.decode( + [Int: OSXSpaceTracker].self, + forKey: .spaceTrackers) + for (id, tracker) in this.trackers { + this.idMap[tracker.systemId] = id + } + } + } + + func encode(to encoder: Encoder) throws { + var object = encoder.container(keyedBy: CodingKeys.self) + let trackers = trackers.compactMapValues({ $0 as? OSXSpaceTracker }) + try object.encode(nextId, forKey: .nextSpaceId) + try object.encode(trackers, forKey: .spaceTrackers) + } } protocol SystemSpaceTracker { @@ -81,7 +122,7 @@ protocol SystemSpaceTracker { func makeTracker(_ screen: ScreenDelegate) -> SpaceTracker /// Returns the list of IDs of SpaceTrackers whose spaces are currently visible. - func visibleIds() -> [Int] + func visibleIds() -> [NSNumber] } class OSXSystemSpaceTracker: SystemSpaceTracker { @@ -96,25 +137,26 @@ class OSXSystemSpaceTracker: SystemSpaceTracker { } func makeTracker(_ screen: ScreenDelegate) -> SpaceTracker { - OSXSpaceTracker(screen) + let tracker = OSXSpaceTracker(screen) + return tracker } - func visibleIds() -> [Int] { - (NSWindow.windowNumbers(options: []) ?? []) as! [Int] + func visibleIds() -> [NSNumber] { + NSWindow.windowNumbers(options: []) ?? [] } } protocol SpaceTracker { - var id: Int { get } + var systemId: NSNumber { get } func screen(_ ssd: SystemScreenDelegate) -> ScreenDelegate? } -class OSXSpaceTracker: NSObject, NSWindowDelegate, SpaceTracker { +class OSXSpaceTracker: NSObject, NSWindowDelegate, Codable, SpaceTracker { let win: NSWindow - var id: Int { win.windowNumber } + var systemId: NSNumber { win.windowNumber as NSNumber } - init(_ screen: ScreenDelegate) { + private init(screen: ScreenDelegate?) { //win = NSWindow(contentViewController: NSViewController(nibName: nil, bundle: nil)) // Size must be non-zero to receive occlusion state events. let rect = /*NSRect.zero */NSRect(x: 0, y: 0, width: 1, height: 1) @@ -123,7 +165,7 @@ class OSXSpaceTracker: NSObject, NSWindowDelegate, SpaceTracker { styleMask: .borderless/*[.titled, .resizable, .miniaturizable]*/, backing: .buffered, defer: true, - screen: screen.native) + screen: screen?.native) win.isReleasedWhenClosed = false win.ignoresMouseEvents = true win.hasShadow = false @@ -142,6 +184,10 @@ class OSXSpaceTracker: NSObject, NSWindowDelegate, SpaceTracker { log.debug("new window windowNumber=\(win.windowNumber)") } + convenience init(_ screen: ScreenDelegate) { + self.init(screen: screen) + } + func screen(_ ssd: SystemScreenDelegate) -> ScreenDelegate? { guard let screen = win.screen else { return nil @@ -165,6 +211,45 @@ class OSXSpaceTracker: NSObject, NSWindowDelegate, SpaceTracker { let visible = (NSWindow.windowNumbers(options: []) ?? []) as! [Int] log.debug("visible=\(visible)") // TODO: Use this event to detect space merges. + //debug() + } + + private class ArchiverDelegate: NSObject, NSKeyedArchiverDelegate { + // Make sure we don't try to encode unencodable objects. AppKit does this by + // defining a custom encoder. + func archiver(_ archiver: NSKeyedArchiver, willEncode object: Any) -> Any? { + if object as? NSWindow != nil || object as? NSView != nil { + return nil + } + return object + } + } + + func debug() { + let delegate = ArchiverDelegate() + let encoder = NSKeyedArchiver(requiringSecureCoding: false) + encoder.delegate = delegate + encoder.outputFormat = .xml + win.encodeRestorableState(with: encoder) + log.info("::: SpaceTracker \(win.windowNumber) restore state :::") + log.info(String(decoding: encoder.encodedData, as: UTF8.self)) + log.info(":::") + } + + func encode(to encoder: Encoder) throws { + let delegate = ArchiverDelegate() + let nsEncoder = NSKeyedArchiver() + nsEncoder.delegate = delegate + win.encodeRestorableState(with: nsEncoder) + var object = encoder.singleValueContainer() + try object.encode(nsEncoder.encodedData) + } + + required convenience init(from decoder: Decoder) throws { + let object = try decoder.singleValueContainer() + let nsDecoder = try NSKeyedUnarchiver(forReadingFrom: object.decode(Data.self)) + self.init(screen: nil) + win.restoreState(with: nsDecoder) } } @@ -178,24 +263,24 @@ class FakeSystemSpaceTracker: SystemSpaceTracker { var trackersMade: [StubSpaceTracker] = [] func makeTracker(_ screen: ScreenDelegate) -> SpaceTracker { - let tracker = StubSpaceTracker(screen, id: nextSpaceId) + let tracker = StubSpaceTracker(nextSpaceId as NSNumber, screen) + visible.append(nextSpaceId) trackersMade.append(tracker) - visible.append(tracker.id) return tracker } var nextSpaceId: Int { trackersMade.count + 1 } var visible: [Int] = [] - func visibleIds() -> [Int] { visible } + func visibleIds() -> [NSNumber] { visible as [NSNumber] } } class StubSpaceTracker: SpaceTracker { var screen: ScreenDelegate? - var id: Int - init(_ screen: ScreenDelegate?, id: Int) { + var systemId: NSNumber + init(_ id: NSNumber, _ screen: ScreenDelegate?) { + systemId = id self.screen = screen - self.id = id } func screen(_ ssd: SystemScreenDelegate) -> ScreenDelegate? { screen } } diff --git a/Sources/State.swift b/Sources/State.swift index 0910e8d..15db80f 100644 --- a/Sources/State.swift +++ b/Sources/State.swift @@ -4,20 +4,65 @@ import PromiseKit /// Initializes a new Swindler state and returns it in a Promise. public func initialize() -> Promise { - let notifier = EventNotifier() - let ssd = OSXSystemScreenDelegate(notifier) - return OSXStateDelegate< - AXSwift.UIElement, - AXSwift.Application, - AXSwift.Observer, - ApplicationObserver - >.initialize( - notifier, - ApplicationObserver(), - ssd, - OSXSpaceObserver(notifier, ssd, OSXSystemSpaceTracker()) - ).map { delegate in - State(delegate: delegate) + return try! Initializer(nil).promise +} + +/// Initializes a new Swindler state and returns it in a Promise. +/// +/// When supplied with a decoder in a correctly configured app environment (TODO), +/// Swindler remembers space IDs across restarts of the application and +/// operating system. +public func initialize(restoringFrom data: Data) throws -> Promise { + return try Initializer.restore(data).promise +} + +typealias RealStateDelegate = OSXStateDelegate< + AXSwift.UIElement, + AXSwift.Application, + AXSwift.Observer, + ApplicationObserver +> + +private class Initializer: Decodable { + let promise: Promise + + static func restore(_ recoveryData: Data) throws -> Initializer { + let decoder = JSONDecoder() + let initializer = try decoder.decode(Initializer.self, from: recoveryData) + return initializer + } + + required convenience init(from decoder: Decoder) throws { + try self.init(decoder) + } + + init(_ decoder: Decoder? = nil) throws { + let notifier = EventNotifier() + let ssd = OSXSystemScreenDelegate(notifier) + var spaces: OSXSpaceObserver + if let decoder = decoder { + spaces = try OSXSpaceObserver(from: decoder, notifier, ssd, OSXSystemSpaceTracker()) + } else { + spaces = OSXSpaceObserver(notifier, ssd, OSXSystemSpaceTracker()) + } + promise = RealStateDelegate.initialize(notifier, ApplicationObserver(), ssd, spaces).map { delegate in + State(delegate: delegate) + } + } +} + +// TODO: Support AppKit automatic restoration +// - Don't initialize spaces until after application finishes launching by scheduling that on the dispatch queue (run loop won't start until restoration is complete, I think).. or finding a suitable event +// - During automatic restoration, save each tracker into a global list +// - Check the list before initializing space on startup +// +// This simplifies everything. + +extension State { + public func recoveryData() throws -> Data? { + guard let delegate = delegate as? RealStateDelegate else { return nil } + let encoder = JSONEncoder() + return try encoder.encode(delegate.spaceObserver) } } From b3f86f69f6c9b8016ff72eb7bd079497af15eca1 Mon Sep 17 00:00:00 2001 From: Tyler Mandry Date: Sun, 31 Dec 2023 14:55:35 -0800 Subject: [PATCH 2/2] wip --- Sources/Space.swift | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/Sources/Space.swift b/Sources/Space.swift index f8ef309..9211031 100644 --- a/Sources/Space.swift +++ b/Sources/Space.swift @@ -137,7 +137,7 @@ class OSXSystemSpaceTracker: SystemSpaceTracker { } func makeTracker(_ screen: ScreenDelegate) -> SpaceTracker { - let tracker = OSXSpaceTracker(screen) + let tracker = OSXSpaceTracker(screen, id: 0) return tracker } @@ -151,12 +151,14 @@ protocol SpaceTracker { func screen(_ ssd: SystemScreenDelegate) -> ScreenDelegate? } -class OSXSpaceTracker: NSObject, NSWindowDelegate, Codable, SpaceTracker { +class OSXSpaceTracker: NSObject, NSWindowDelegate, Codable, SpaceTracker, NSWindowRestoration { let win: NSWindow var systemId: NSNumber { win.windowNumber as NSNumber } + let id: Int - private init(screen: ScreenDelegate?) { + private init(screen: ScreenDelegate?, id: Int) { + self.id = id //win = NSWindow(contentViewController: NSViewController(nibName: nil, bundle: nil)) // Size must be non-zero to receive occlusion state events. let rect = /*NSRect.zero */NSRect(x: 0, y: 0, width: 1, height: 1) @@ -180,12 +182,16 @@ class OSXSpaceTracker: NSObject, NSWindowDelegate, Codable, SpaceTracker { super.init() win.delegate = self + win.isRestorable = true + win.restorationClass = OSXSpaceTracker.self + win.identifier = NSUserInterfaceItemIdentifier("spaceTracker") + win.makeKeyAndOrderFront(nil) log.debug("new window windowNumber=\(win.windowNumber)") } - convenience init(_ screen: ScreenDelegate) { - self.init(screen: screen) + convenience init(_ screen: ScreenDelegate, id: Int) { + self.init(screen: screen, id: id) } func screen(_ ssd: SystemScreenDelegate) -> ScreenDelegate? { @@ -248,9 +254,22 @@ class OSXSpaceTracker: NSObject, NSWindowDelegate, Codable, SpaceTracker { required convenience init(from decoder: Decoder) throws { let object = try decoder.singleValueContainer() let nsDecoder = try NSKeyedUnarchiver(forReadingFrom: object.decode(Data.self)) - self.init(screen: nil) + self.init(screen: nil, id: 0) win.restoreState(with: nsDecoder) } + + @MainActor + static func restoreWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier, state: NSCoder) async throws -> NSWindow { + let id = state.decodeInteger(forKey: "SpaceId") + log.info("restoreWindow \(id)") + let tracker = OSXSpaceTracker(screen: nil, id: id) + return tracker.win + } + + func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) { + log.info("window willencode \(id)") + state.encode(id, forKey: "SpaceId") + } } class FakeSystemSpaceTracker: SystemSpaceTracker {