diff --git a/Rectangle/Snapping/SnappingManager.swift b/Rectangle/Snapping/SnappingManager.swift index 8c2fcd01..be1f1032 100644 --- a/Rectangle/Snapping/SnappingManager.swift +++ b/Rectangle/Snapping/SnappingManager.swift @@ -52,6 +52,7 @@ class SnappingManager { } registerWorkspaceChangeNote() + registerSessionChangeNote() Notification.Name.windowSnapping.onPost { notification in if let enabled = notification.object as? Bool { @@ -109,6 +110,14 @@ class SnappingManager { @objc func receiveWorkspaceNote(_ notification: Notification) { checkFullScreen() } + + private func registerSessionChangeNote() { + NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(receiveSessionNote(_:)), name: NSWorkspace.sessionDidBecomeActiveNotification, object: nil) + } + + @objc func receiveSessionNote(_ notification: Notification) { + checkFullScreen() + } public func reloadFromDefaults() { if Defaults.windowSnapping.userDisabled { diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index e5ad949f..c9c64eec 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -213,3 +213,220 @@ class OverlapOffsetGuardsTests: XCTestCase { XCTAssertEqual(candidate.origin.y, 1510 + 11, accuracy: 0.001) } } + +class SnappingManagerSessionTests: XCTestCase { + + private var savedSnappingEnabled: Bool? + + override func setUp() { + super.setUp() + savedSnappingEnabled = Defaults.windowSnapping.enabled + Defaults.windowSnapping.enabled = false + } + + override func tearDown() { + super.tearDown() + Defaults.windowSnapping.enabled = savedSnappingEnabled + } + + func testSessionDidBecomeActiveTriggersCheckFullScreen() { + let sm = SnappingManager() + sm.isFullScreen = true + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + XCTAssertFalse(sm.isFullScreen, + "receiveSessionNote should call checkFullScreen, re-evaluating isFullScreen") + } + + func testSessionDidBecomeActiveEventMonitorPreserved() { + Defaults.windowSnapping.enabled = true + let sm = SnappingManager() + let wasRunning = sm.eventMonitor?.running ?? false + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + let isRunning = sm.eventMonitor?.running ?? false + XCTAssertEqual(isRunning, wasRunning, + "toggleListening should be called but preserve event monitor state") + } + + func testSessionDidBecomeActiveDoesNotEnableSnapping() { + let sm = SnappingManager() + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + XCTAssertNil(sm.eventMonitor, + "snapping should remain disabled after session became active notification") + } + + func testSessionDidBecomeActiveMultiplePostsNoCrash() { + let sm = SnappingManager() + + for _ in 0..<5 { + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + } + + XCTAssertFalse(sm.isFullScreen) + } + + func testSleepWakeMaintainsSnapping() { + Defaults.windowSnapping.enabled = true + let sm = SnappingManager() + let wasRunning = sm.eventMonitor?.running ?? false + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.activeSpaceDidChangeNotification, + object: nil + ) + + let isRunning = sm.eventMonitor?.running ?? false + XCTAssertEqual(isRunning, wasRunning, + "activeSpaceDidChange (simulating wake) should preserve event monitor state") + } + + func testSessionUnlockThenWakeMaintainsSnapping() { + Defaults.windowSnapping.enabled = true + let sm = SnappingManager() + let wasRunning = sm.eventMonitor?.running ?? false + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.activeSpaceDidChangeNotification, + object: nil + ) + + let isRunning = sm.eventMonitor?.running ?? false + XCTAssertEqual(isRunning, wasRunning, + "session unlock followed by wake should restore event monitor state") + } + + func testSessionUnlockWithDisabledSnapping() { + let sm = SnappingManager() + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.activeSpaceDidChangeNotification, + object: nil + ) + + XCTAssertNil(sm.eventMonitor, + "session unlock -> wake should not enable snapping when disabled") + } + + func testFullScreenThenWakeThenLeaveFullScreen() { + Defaults.windowSnapping.enabled = true + let sm = SnappingManager() + let wasRunning = sm.eventMonitor?.running ?? false + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + let isRunning = sm.eventMonitor?.running ?? false + XCTAssertEqual(isRunning, wasRunning, + "session restore after full screen should preserve event monitor state") + } + + func testSessionResignActiveDoesNotCrash() { + let sm = SnappingManager() + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidResignActiveNotification, + object: nil + ) + + XCTAssertFalse(sm.isFullScreen) + } + + func testSessionResignActiveThenBecomeActive() { + Defaults.windowSnapping.enabled = true + let sm = SnappingManager() + let wasRunning = sm.eventMonitor?.running ?? false + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidResignActiveNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + let isRunning = sm.eventMonitor?.running ?? false + XCTAssertEqual(isRunning, wasRunning, + "session resign then become active should preserve event monitor state") + } + + func testScreensDoNotSleepNotificationsBreakSnapping() { + Defaults.windowSnapping.enabled = true + let sm = SnappingManager() + let wasRunning = sm.eventMonitor?.running ?? false + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.screensDidSleepNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.activeSpaceDidChangeNotification, + object: nil + ) + + let isRunning = sm.eventMonitor?.running ?? false + XCTAssertEqual(isRunning, wasRunning, + "screen sleep then wake should preserve event monitor state") + } + + func testScreenSleepSessionResignThenWakeAndSessionActive() { + Defaults.windowSnapping.enabled = true + let sm = SnappingManager() + let wasRunning = sm.eventMonitor?.running ?? false + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.screensDidSleepNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidResignActiveNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.activeSpaceDidChangeNotification, + object: nil + ) + + let isRunning = sm.eventMonitor?.running ?? false + XCTAssertEqual(isRunning, wasRunning, + "screen sleep + session resign -> session active + wake should restore event monitor state") + } +}