@@ -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
4856final 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