Skip to content
Open
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: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
150 changes: 127 additions & 23 deletions Sources/Space.swift
Original file line number Diff line number Diff line change
@@ -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()
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -96,25 +137,28 @@ class OSXSystemSpaceTracker: SystemSpaceTracker {
}

func makeTracker(_ screen: ScreenDelegate) -> SpaceTracker {
OSXSpaceTracker(screen)
let tracker = OSXSpaceTracker(screen, id: 0)
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, NSWindowRestoration {
let win: NSWindow

var id: Int { win.windowNumber }
var systemId: NSNumber { win.windowNumber as NSNumber }
let id: Int

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)
Expand All @@ -123,7 +167,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
Expand All @@ -138,10 +182,18 @@ class OSXSpaceTracker: NSObject, NSWindowDelegate, 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, id: Int) {
self.init(screen: screen, id: id)
}

func screen(_ ssd: SystemScreenDelegate) -> ScreenDelegate? {
guard let screen = win.screen else {
return nil
Expand All @@ -165,6 +217,58 @@ 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, 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")
}
}

Expand All @@ -178,24 +282,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 }
}
73 changes: 59 additions & 14 deletions Sources/State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,65 @@ import PromiseKit

/// Initializes a new Swindler state and returns it in a Promise.
public func initialize() -> Promise<State> {
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<State> {
return try Initializer.restore(data).promise
}

typealias RealStateDelegate = OSXStateDelegate<
AXSwift.UIElement,
AXSwift.Application,
AXSwift.Observer,
ApplicationObserver
>

private class Initializer: Decodable {
let promise: Promise<State>

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)
}
}

Expand Down