Skip to content

Commit dd5f019

Browse files
committed
Add on-monitor-changed config callback
When a monitor is connected or disconnected, users often need to reload external tools (e.g. sketchybar) that depend on the display configuration. AeroSpace already detects monitor count changes in gcMonitors() and rearranges workspaces, but provides no hook for users to run commands in response. Add on-monitor-changed callback that fires when the set of connected monitors changes. Detection: register a CGDisplayReconfigurationCallback in GlobalObserver. This fires reliably for monitor connect/disconnect at the CoreGraphics level, unlike NSApplication notifications which may not be delivered to agent apps. The callback triggers a refresh session which runs gcMonitors() and layoutWorkspaces() as usual. Callback timing: checkOnMonitorChangedCallback() runs AFTER layoutWorkspaces() in runRefreshSessionBlocking, so external tools that query AeroSpace (e.g. `aerospace list-workspaces --monitor X`) see the final workspace-to-monitor assignments. This avoids a race where the callback fires mid-rearrangement and tools see stale state. The callback tracks monitor count via a separate _previousMonitorCount variable rather than hooking into gcMonitors() directly, because other refresh sessions (triggered by concurrent AX notifications) often call rearrangeWorkspacesOnMonitors() first, making gcMonitors()'s own count comparison a no-op by the time the display reconfiguration session runs. Config syntax: on-monitor-changed = 'exec-and-forget sketchybar --reload' Also adds monitor-changed to the subscribe event types so that `aerospace subscribe monitor-changed` works for external tooling.
1 parent 8d5dc7b commit dd5f019

File tree

10 files changed

+45
-2
lines changed

10 files changed

+45
-2
lines changed

Sources/AppBundle/GlobalObserver.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ enum GlobalObserver {
5353
nc.addObserver(forName: NSWorkspace.activeSpaceDidChangeNotification, object: nil, queue: .main, using: onNotif)
5454
nc.addObserver(forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main, using: onNotif)
5555

56+
// Detect monitor connect/disconnect via CoreGraphics callback.
57+
CGDisplayRegisterReconfigurationCallback({ _, flags, _ in
58+
// Only act on completion of a reconfiguration, not the begin phase
59+
guard flags.contains(.beginConfigurationFlag) == false else { return }
60+
Task { @MainActor in
61+
if !TrayMenuModel.shared.isEnabled { return }
62+
scheduleRefreshSession(.globalObserver("displayReconfiguration"))
63+
}
64+
}, nil)
65+
5666
NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { _ in
5767
// todo reduce number of refreshSession in the callback
5868
// resetManipulatedWithMouseIfPossible might call its own refreshSession

Sources/AppBundle/config/Config.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ struct Config: ConvenienceCopyable {
5454
var onFocusChanged: [any Command] = []
5555
// var onFocusedWorkspaceChanged: [any Command] = []
5656
var onFocusedMonitorChanged: [any Command] = []
57+
var onMonitorChanged: [any Command] = []
5758

5859
var gaps: Gaps = .zero
5960
var workspaceToMonitorForceAssignment: [String: [MonitorDescription]] = [:]

Sources/AppBundle/config/parseConfig.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ private let configParser: [String: any ParserProtocol<Config>] = [
102102
"on-focus-changed": Parser(\.onFocusChanged) { parseCommandOrCommands($0).toParsedConfig($1) },
103103
"on-mode-changed": Parser(\.onModeChanged) { parseCommandOrCommands($0).toParsedConfig($1) },
104104
"on-focused-monitor-changed": Parser(\.onFocusedMonitorChanged) { parseCommandOrCommands($0).toParsedConfig($1) },
105+
"on-monitor-changed": Parser(\.onMonitorChanged) { parseCommandOrCommands($0).toParsedConfig($1) },
105106
// "on-focused-workspace-changed": Parser(\.onFocusedWorkspaceChanged, { parseCommandOrCommands($0).toParsedConfig($1) }),
106107

107108
"enable-normalization-flatten-containers": Parser(\.enableNormalizationFlattenContainers, parseBool),

Sources/AppBundle/layout/refresh.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ func runRefreshSessionBlocking(
4141
SecureInputPanel.shared.refresh()
4242
try await normalizeLayoutReason()
4343
if shouldLayoutWorkspaces { try await layoutWorkspaces() }
44+
checkOnMonitorChangedCallback()
4445
}
4546
}
4647
}

Sources/AppBundle/model/ServerEvent.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,8 @@ public struct ServerEvent: Codable, Sendable {
4545
public static func bindingTriggered(mode: String, binding: String) -> ServerEvent {
4646
ServerEvent(_event: .bindingTriggered, mode: mode, binding: binding)
4747
}
48+
49+
public static func monitorChanged(monitorCount: Int) -> ServerEvent {
50+
ServerEvent(_event: .monitorChanged, monitorId: monitorCount)
51+
}
4852
}

Sources/AppBundle/subscriptions.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func handleSubscribeAndWaitTillError(_ connection: NWConnection, _ args: Subscri
3030
workspace: f.workspace.name,
3131
monitorId_oneBased: f.workspace.workspaceMonitor.monitorId_oneBased ?? 0,
3232
)
33-
case .windowDetected, .bindingTriggered: continue
33+
case .windowDetected, .bindingTriggered, .monitorChanged: continue
3434
}
3535
if await connection.writeAtomic(event, jsonEncoder).error != nil {
3636
return

Sources/AppBundle/tree/Workspace.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,34 @@ extension Monitor {
132132
}
133133
}
134134

135+
@MainActor
136+
var _previousMonitorCount: Int = NSScreen.screens.count
137+
135138
@MainActor
136139
func gcMonitors() {
137140
if screenPointToVisibleWorkspace.count != monitors.count {
138141
rearrangeWorkspacesOnMonitors()
139142
}
140143
}
141144

145+
/// Check if the monitor count changed since last check and fire the callback.
146+
/// Must be called AFTER layoutWorkspaces() so that external tools see the final state.
147+
@MainActor
148+
func checkOnMonitorChangedCallback() {
149+
let current = monitors.count
150+
if current != _previousMonitorCount {
151+
_previousMonitorCount = current
152+
broadcastEvent(.monitorChanged(monitorCount: current))
153+
if config.onMonitorChanged.isEmpty { return }
154+
guard let token: RunSessionGuard = .isServerEnabled else { return }
155+
Task {
156+
try await runLightSession(.onMonitorChanged, token) {
157+
_ = try await config.onMonitorChanged.runCmdSeq(.defaultEnv, .emptyStdin)
158+
}
159+
}
160+
}
161+
}
162+
142163
extension CGPoint {
143164
@MainActor
144165
fileprivate func setActiveWorkspace(_ workspace: Workspace) -> Bool {

Sources/AppBundleTests/command/SubscribeCmdArgsTest.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ final class SubscribeCmdArgsTest: XCTestCase {
3131
case .failure(let err):
3232
assertEquals(err, """
3333
ERROR: Can't parse 'unknown-event'.
34-
Possible values: (focus-changed|focused-monitor-changed|focused-workspace-changed|mode-changed|window-detected|binding-triggered)
34+
Possible values: (focus-changed|focused-monitor-changed|focused-workspace-changed|mode-changed|window-detected|binding-triggered|monitor-changed)
3535
""")
3636
}
3737
}

Sources/Common/cmdArgs/impl/SubscribeCmdArgs.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,5 @@ public enum ServerEventType: String, Codable, CaseIterable, Sendable {
6161
case modeChanged = "mode-changed"
6262
case windowDetected = "window-detected"
6363
case bindingTriggered = "binding-triggered"
64+
case monitorChanged = "monitor-changed"
6465
}

Sources/Common/util/commonUtil.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ public enum RefreshSessionEvent: Sendable, CustomStringConvertible {
8080
case onFocusedMonitorChanged
8181
case onFocusChanged
8282
case onModeChanged
83+
case focusFollowsMouse
84+
case onMonitorChanged
8385

8486
public var isStartup: Bool {
8587
if case .startup = self { return true } else { return false }
@@ -99,6 +101,8 @@ public enum RefreshSessionEvent: Sendable, CustomStringConvertible {
99101
case .onFocusedMonitorChanged: "onFocusedMonitorChanged"
100102
case .onFocusChanged: "onFocusChanged"
101103
case .onModeChanged: "onModeChanged"
104+
case .focusFollowsMouse: "focusFollowsMouse"
105+
case .onMonitorChanged: "onMonitorChanged"
102106
}
103107
}
104108
}

0 commit comments

Comments
 (0)