Skip to content

Commit ff42788

Browse files
committed
Hide menu bar on silent FS↔FS Space switches
Dock skips emitting `Space Forces Hidden:` on FS↔FS Space switches because dock visibility doesn't need to flip, leaving `dockFs.pid` stale — `shouldHideMenuBar`'s multi-display gate then rejects the new Space's snapshot with the previous app's pid and SHOWs when it should HIDE. Parse the `Skipping no-op state update` line Dock emits in that case as a wake-up trigger, and refresh `dockFs.pid` from `frontmostApplication`.
1 parent 3f63294 commit ff42788

2 files changed

Lines changed: 75 additions & 32 deletions

File tree

Sources/Controller.swift

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ final class Controller: NSObject {
9494
private var nowPlayingParentBundle: String?
9595
private var lastSnapshot: Snapshot?
9696

97-
private lazy var dockSpaceWatcher = DockSpaceWatcher { [weak self] state in
98-
self?.updateDockFullScreen(state)
97+
private lazy var dockSpaceWatcher = DockSpaceWatcher { [weak self] event in
98+
self?.handleDockEvent(event)
9999
}
100100

101101
init(menuBar: MenuBarToggler) {
@@ -139,13 +139,35 @@ final class Controller: NSObject {
139139
evaluate()
140140
}
141141

142-
/// Called by `DockSpaceWatcher` for every parsed `Space Forces
143-
/// Hidden:` line.
142+
private func handleDockEvent(_ event: DockSpaceEvent) {
143+
switch event {
144+
case let .fullScreenState(state):
145+
updateDockFullScreen(state)
146+
case .staySpaceChange:
147+
onStaySpaceChange()
148+
}
149+
}
150+
144151
private func updateDockFullScreen(_ state: DockFullScreenState) {
145152
dockFs = state
146153
evaluate()
147154
}
148155

156+
/// Refreshes `dockFs.pid` from `frontmostApplication` so
157+
/// `shouldHideMenuBar`'s multi-display gate doesn't reject FS↔FS
158+
/// hops with a stale pid. Guarded on cached `isFullScreen` because
159+
/// the no-op fires for non-FS hops too; the line's `state` field
160+
/// is unreliable across transition phases, so we trust the cache.
161+
/// `frontmostApplication` is fresh here — the log subprocess
162+
/// pipeline serializes after AppKit propagates the new frontmost.
163+
private func onStaySpaceChange() {
164+
guard dockFs.isFullScreen,
165+
let pid = NSWorkspace.shared.frontmostApplication?.processIdentifier
166+
else { return }
167+
dockFs = DockFullScreenState(isFullScreen: true, pid: pid)
168+
evaluate()
169+
}
170+
149171
private func evaluate() {
150172
let snap = takeSnapshot()
151173
guard snap != lastSnapshot else { return }

Sources/DockSpaceWatcher.swift

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,35 @@ struct DockFullScreenState: Equatable {
4444
static let initial = DockFullScreenState(isFullScreen: false, pid: nil)
4545
}
4646

47+
/// Events from the dock-visibility log channel. `staySpaceChange`
48+
/// is the `Skipping no-op state update` pulse Dock emits on silent
49+
/// FS↔FS Space switches — no payload, just "active Space changed."
50+
enum DockSpaceEvent {
51+
case fullScreenState(DockFullScreenState)
52+
case staySpaceChange
53+
}
54+
4755
@MainActor
4856
final class DockSpaceWatcher {
49-
/// Constrains the log stream to Dock's `dock-visibility` category
50-
/// and to the one message line that carries the FS state. Together
51-
/// they reduce subprocess output to one line per Space transition.
52-
private static let logPredicate = #"subsystem == "com.apple.dock" AND category == "dock-visibility" AND eventMessage CONTAINS "Space Forces Hidden:""# // swiftformat:disable all
53-
54-
private let onUpdate: @MainActor (DockFullScreenState) -> Void
57+
/// Filters `dock-visibility` to `Space Forces Hidden:` (engage/exit
58+
/// transitions; carries pid + fullscreen flag) and `Skipping no-op
59+
/// state update` (Dock's wake-up on silent FS↔FS Space switches).
60+
private static let logPredicate = """
61+
subsystem == "com.apple.dock" \
62+
AND category == "dock-visibility" \
63+
AND (eventMessage CONTAINS "Space Forces Hidden:" \
64+
OR eventMessage CONTAINS "Skipping no-op state update")
65+
"""
66+
67+
private let onUpdate: @MainActor (DockSpaceEvent) -> Void
5568
private var subprocess: Process?
5669
private var lineBuffer = LineBuffer()
5770
/// Set by `stop()` so the termination handler can distinguish
5871
/// graceful shutdown (we asked the subprocess to terminate) from
5972
/// an unexpected exit (which is fatal — see termination handler).
6073
private var stopping = false
6174

62-
init(onUpdate: @escaping @MainActor (DockFullScreenState) -> Void) {
75+
init(onUpdate: @escaping @MainActor (DockSpaceEvent) -> Void) {
6376
self.onUpdate = onUpdate
6477
}
6578

@@ -78,6 +91,7 @@ final class DockSpaceWatcher {
7891
process.executableURL = URL(fileURLWithPath: "/usr/bin/log")
7992
process.arguments = [
8093
"stream",
94+
"--level", "debug",
8195
"--style", "compact",
8296
"--predicate", Self.logPredicate,
8397
]
@@ -145,34 +159,41 @@ final class DockSpaceWatcher {
145159
}
146160

147161
/// Drain complete lines from the buffer, parse each, and forward
148-
/// state changes to the controller. Non-matching lines (the filter
149-
/// header, possible private-redacted variants) are silently
162+
/// recognised events to the controller. Non-matching lines (the
163+
/// filter header, possible private-redacted variants) are silently
150164
/// dropped.
151165
private func ingest(_ chunk: Data) {
152166
lineBuffer.ingest(chunk) { line in
153167
guard let text = String(data: line, encoding: .utf8) else { return }
154-
guard let state = Self.parse(text) else { return }
155-
let pidField = state.pid.map { "\($0)" } ?? "null"
156-
Log.controller.debug(
157-
"dock_visibility fs=\(state.isFullScreen, privacy: .public) pid=\(pidField, privacy: .public)",
158-
)
159-
onUpdate(state)
168+
guard let event = Self.parse(text) else { return }
169+
switch event {
170+
case let .fullScreenState(state):
171+
let pidField = state.pid.map { "\($0)" } ?? "null"
172+
Log.controller.debug(
173+
"dock_visibility fs=\(state.isFullScreen, privacy: .public) pid=\(pidField, privacy: .public)",
174+
)
175+
case .staySpaceChange:
176+
Log.controller.debug("dock_visibility stay_space_change")
177+
}
178+
onUpdate(event)
160179
}
161180
}
162181

163-
/// Extracts `(isFullScreen, pid?)` from a `Space Forces Hidden:`
164-
/// log line. Returns nil for lines that don't carry an unambiguous
165-
/// `fullscreen=true|false` token — that includes the `log stream`
166-
/// header, any reworded variant Apple may ship in a future macOS,
167-
/// and unexpectedly redacted output. The caller treats nil as
168-
/// "ignore this line" rather than crashing or guessing a state.
182+
/// Returns nil for lines that don't match either expected shape
183+
/// (`log stream` header, redacted output) — caller drops them.
169184
///
170-
/// `pid=NNNNN` only appears on engage messages (the FS app's tile
171-
/// names it). Exit messages omit it. A `fullscreen=true` with no
172-
/// pid would be unexpected; we still surface the state with
173-
/// `pid=nil`, which the controller's `shouldHideMenuBar` rejects
174-
/// (the guard requires a non-nil `dockFs.pid`) → SHOW.
175-
private static func parse(_ line: String) -> DockFullScreenState? {
185+
/// `Skipping no-op state update` is used only as a wake-up
186+
/// trigger; its `state` field tracks Dock's transition phases
187+
/// unreliably and isn't a source of FS-ness truth.
188+
///
189+
/// `Space Forces Hidden:` exit messages omit the pid and surface
190+
/// as `pid=nil`, which `shouldHideMenuBar`'s non-nil pid guard
191+
/// then rejects.
192+
private static func parse(_ line: String) -> DockSpaceEvent? {
193+
if line.contains("Skipping no-op state update") {
194+
return .staySpaceChange
195+
}
196+
176197
let isFullScreen: Bool
177198
if line.contains("fullscreen=true") {
178199
isFullScreen = true
@@ -193,6 +214,6 @@ final class DockSpaceWatcher {
193214
pid = pid_t(digits)
194215
}
195216

196-
return DockFullScreenState(isFullScreen: isFullScreen, pid: pid)
217+
return .fullScreenState(DockFullScreenState(isFullScreen: isFullScreen, pid: pid))
197218
}
198219
}

0 commit comments

Comments
 (0)