Skip to content

Commit ac47c69

Browse files
committed
Add on-monitor-changed config callback
Users often need to reload external tools (e.g. sketchybar) when monitors are connected or disconnected. AeroSpace detects monitor changes internally but doesn't expose a hook for it. Uses CGDisplayReconfigurationCallback for reliable detection (NSApplication notifications aren't delivered to agent apps). The callback fires after layoutWorkspaces() so external tools see the final state. Config syntax: on-monitor-changed = 'exec-and-forget sketchybar --reload' Also adds monitor-changed to `aerospace subscribe` event types.
1 parent 8d5dc7b commit ac47c69

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)