_ _ _ _
| |__ ___ _ _ __| | (_) _ __ (_)
| '_ \ / _ \ | | | | / _` | | | | '_ \ | |
| | | | | (_) | | |_| | | (_| | | | | | | | | |
|_| |_| \___/ \__,_| \__,_| |_| |_| |_| |_|
A macOS background daemon that hides the menu bar when the frontmost fullscreen app is the same one playing in the system Now Playing widget — fullscreen YouTube, Netflix, Apple TV+, Spotify, etc. Pause, switch apps, or exit fullscreen and the bar comes back.
No UI. One fallback hotkey (⌃⌥⌘M) for the rare cases where the bar gets stuck.
People who keep the menu bar visible by default but want it out of the way during fullscreen media playback.
macOS's native "Automatically hide and show the menu bar in full screen" pref is all-or-nothing — flip it on and the bar disappears from every fullscreen window, including Terminal, your editor, and Figma. houdini scopes that behavior to "the frontmost fullscreen app is the one driving Now Playing":
- Hides in fullscreen YouTube / Netflix / Apple TV+ / Music / QuickTime while playing.
- Stays put in fullscreen Terminal, editors, Figma — anything not driving Now Playing.
Pause, switch apps, or exit fullscreen, and the bar comes back.
Set with houdini mode <smart|fixed>; print the current value with houdini mode. Default is smart.
Smart — automatic, signal-driven (default)
Hides the menu bar only when all of these are true:
- An app is in native fullscreen.
- That app is the frontmost (focused) app.
- That same app is actively playing media via Now Playing.
When any becomes false, the menu bar comes back. The hotkey overrules the daemon's decision until you swipe back to a Desktop space — see Hotkey. For signal sources and the gate-by-gate diagram, see Architecture.
Fixed — manual hotkey toggle
No automatic logic. The menu bar is visible by default; pressing the hotkey hides it, pressing again shows it.
Useful when:
- You want full manual control over fullscreen menu-bar visibility.
- Now Playing isn't reliable for the apps you use.
No Dock log, no Now Playing subprocess — just the hotkey. Toggle state isn't persisted across daemon restart; the bar starts visible.
houdini is best on notched MacBooks (14"/16" MacBook Pro 2021+, 13"/15" MacBook Air 2022+).
- Notched — the menu-bar slot is permanently reserved for the notch, so toggling fullscreen menu-bar visibility doesn't change the window's content area. Show/hide is purely visual.
- Non-notched — the fullscreen window resizes by the menu-bar height each time, which reflows in-window content (e.g. a Chrome page shifts up or down).
Functionally identical; only visually different.
# one-time setup
brew tap mgxv/houdini
brew install houdini
# or as a single command
brew install mgxv/houdini/houdiniThen start the service:
brew services start houdinibrew services start houdini # start and enable at login
brew services stop houdini # stop and disable
brew services restart houdini # stop + start
brew services info houdini # state, PID, plist path
houdini mode # print the current mode
houdini mode smart|fixed # set mode (applied immediately via SIGHUP)
houdini deny # list bundles that won't trigger auto-hide
houdini deny add <bundle> # add a bundle to the deny list
houdini deny remove <bundle> # remove a bundle from the deny listRunning the binary directly (./houdini) is useful for debugging; brew services is the normal path.
Some apps drive Now Playing but you'd rather keep the menu bar visible while they're fullscreen — Spotify, Apple Music, etc. Add their bundle IDs to the deny list and they'll be excluded from auto-hide even when all gates would otherwise pass.
# Find a bundle ID
osascript -e 'id of app "Spotify"' # com.spotify.client
# Manage the list
houdini deny add com.spotify.client
houdini deny remove com.spotify.client
houdini deny # list current entries⌃⌥⌘M (Ctrl+Option+Cmd+M). Behavior depends on the active mode.
In smart mode — flips the bar against the daemon's current decision and pins that choice until you swipe back to a Desktop space, at which point the daemon resumes automatic control. The pin survives front-app switches, FS↔FS hops, and play/pause. Press the hotkey again on the same FS Space to flip the pin back. Pressing on a Desktop space is a no-op (the underlying pref is fullscreen-only); a → hotkey ignored (not in fullscreen) line is logged.
In fixed mode — plain toggle: press hides, press again shows. If the hotkey doesn't toggle, check houdini status — the hotkey: field should read registered.
How smart mode works under the hood, how to inspect it, and how to debug it. Fixed mode bypasses all of this — houdini status is mode-aware, but the gate semantics, signal pipeline, and log breadcrumbs below are smart-only.
Click to expand
Run houdini logs and exercise the trigger you expect to hide the bar (fullscreen the app, start playback). Each evaluation prints a snapshot:
→ hide trig=adapter overrule=auto appMatch=process front_tx=com.apple.Safari[pid=501,name="Safari",bundle=com.apple.Safari,resp=null,fs=true,fsPid=501]
→ np_tx=com.apple.WebKit.GPU[pid=506,bundle=com.apple.WebKit.GPU,parent=com.apple.Safari,resp=501,play=true,title="BLACKPINK - 'GO' M/V"]
Field reference:
→ hide/→ show(reason)— first guard that tripped:not_fullscreen,not_playing,no_front_pid,no_now_playing_pid,front_not_fs_owner,app_mismatch.trig=— input that fired this evaluation:start,front_app,dock_fs,dock_stay,adapter,hotkey.overrule=—auto(daemon-driven),force_hide, orforce_show(hotkey-pinned; clears only on Desktop arrival).appMatch=—process,bundle,both,none, orn/a(when a PID is missing) — which gate-6 path matched.resp=— kernel's responsibility-resolved root PID;nullfor top-level apps, a PID for helpers (WebKit.GPU resolving to Safari). What the same-app process check actually compares.title=on the np line is the Now Playing track title (diagnostic only — not a decision input).
Each input also leaves a debug breadcrumb at the boundary — → np_rx, → front_rx, → dock_rx, → eval_skipped — so a wrong decision can be traced back to the data that drove it.
fs=false(show(not_fullscreen)) — Dock has not reported a fullscreen Space transition. Native fullscreen (⌃⌘F, the green-stoplight button, or in-page fullscreen buttons) creates a dedicated Space; merely-maximized windows that just fill the screen don't qualify.play=false(show(not_playing)) — the Now Playing source is paused; play/pause state comes directly from the media app.- front
pid=null(show(no_front_pid)) — defensive; AppKit reported no frontmost application. Rare in practice (some Lock-Screen / login-window states). np_tx=[pid=null,…](show(no_now_playing_pid)) — nothing is using Now Playing. Some players (browser tabs without media-session metadata) never register with the system widget.fs=truebutpid ≠ fsPid(show(front_not_fs_owner)) — a fullscreen Space exists, but the frontmost app isn't its owner. Typically you've Cmd-Tabbed to a different app.- front bundle ≠ np parent and
respdoesn't match the front pid (show(app_mismatch)) — e.g. Spotify is playing in the background while Safari is the focused fullscreen app.
houdini status prints version, mode, daemon, adapter, dock-log, and hotkey state in one go and exits non-zero if a load-bearing component for the active mode is missing. If a subprocess dies unexpectedly, the daemon emits an error to the unified log (see houdini logs) and exits; launchd then relaunches it via brew services.
Clear orphan subprocesses or a foreground ./houdini you forgot about:
brew services stop houdini
pkill -x houdini
pkill -f mediaremote-adapter
brew services start houdiniClick to expand
houdini status # version, mode, daemon state, subprocess health
# (smart mode), hotkey registration
houdini logs # stream every houdini unified-log entry at debug level
houdini version # print version
houdini help # full usagehoudini status is the fastest way to confirm the install. It checks:
- Which
houdiniis in your$PATH(version). - The active mode (
smartorfixed). - Whether a daemon currently holds the instance lock.
- Whether the two subprocesses (
mediaremote-adapter, the Dock-loglog stream) are alive — smart mode only; fixed mode printsn/a (fixed mode). - Whether the hotkey registered (
registered/failed/unknown).
Exit code is non-zero if a load-bearing component is missing for the active mode: in smart mode, the daemon and both subprocesses must be running; in fixed mode, the daemon must be running with the hotkey registered.
For the live decision (frontmost app, Now Playing, hide/show), watch houdini logs.
Subsystem com.github.mgxv.houdini, three categories:
controller— hide/show snapshots (info), per-input breadcrumbs (debug):→ dock_rx fs=… pid=… name=…— parsedSpace Forces Hidden:lines.→ dock_rx stay_space_change— the FS↔FS hop pulse.→ dock_rx desktop_arrival—Will Force Update Rect, fired only on FS → Desktop arrival; clears any active hotkey pin.→ front_rx pid=… bundle=… name=…— AppKitdidActivateApplicationNotification.→ eval_skipped trig=…— snapshot equal to the previous one.→ hotkey ignored (not in fullscreen)— hotkey press refused on Desktop.
adapter—→ np_rx type=data play=… pid=… bundle=… parent=… title=…per Now Playing event from mediaremote-adapter, plus subprocess stderr (debug).general— startup/shutdown notices, warnings, errors (info / error).
In fixed mode only controller (hide/show via hotkey) and general (startup/shutdown) emit; the adapter category is silent because the subprocess isn't started.
houdini logs streams all three categories at debug level — no flags, one stream, ready to paste into a bug report:
houdini logs # live
log show --predicate 'subsystem == "com.github.mgxv.houdini"' --last 1h # historyOr open Console.app, filter on subsystem com.github.mgxv.houdini, and toggle Action → Include Debug Messages / Include Info Messages.
Click to expand
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ AppKit │ │ Dock Log │ │ MediaRemote │ │ Hotkey │
│ (in-process) │ │ (subprocess) │ │ (Perl shim) │ │ (Carbon) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
│ frontmost │ FS state │ playback │ ⌃⌥⌘M
│ changed │ + owner PID │ state + PID │ pressed
│ │ + FS↔FS hop │ + bundle │
│ │ (refresh) │ │
▼ ▼ ▼ ▼
front_app dock_fs / dock_stay adapter hotkey
│ │ │ │
└────────────────┴────────────────┴────────────────┘
│
▼
┌──────────────────────────────────────┐
│ SmartController │
│ + initial launch trigger (start) │
└──────────────────┬───────────────────┘
▼
menuBarDecision (sequential gates)
─────────────────────────────────
(1) Fullscreen Space active?
└─ no → show(not_fullscreen)
(2) Media playing?
└─ no → show(not_playing)
(3) Frontmost PID present?
└─ no → show(no_front_pid)
(4) Now Playing PID present?
└─ no → show(no_now_playing_pid)
(5) Frontmost owns FS Space?
(multi-display gate)
└─ no → show(front_not_fs_owner)
(6) Frontmost == Now Playing source?
(process or bundle match)
└─ no → show(app_mismatch)
│
▼
effectiveShouldHide
(overrule: hotkey pins
force_hide / force_show
until next Desktop arrival;
auto otherwise)
│
▼
AppleMenuBarVisibleInFullscreen (system pref)
+ DistributedNotification → WindowServer
Internally — where each signal comes from
Fullscreen state and FS-owner PID — Dock's dock-visibility log channel. Spawned via /usr/bin/log stream with a predicate that filters to:
Space Forces Hidden:— emitted on FS entry/exit; carries the active Space's fullscreen flag and owner PID.Skipping no-op state update— emitted on FS↔FS Space switches; payload-less wake-up that lets us refresh the cached owner fromNSWorkspace.frontmostApplication.Will Force Update Rect— payload-less; emitted only on FS → Desktop swipes (not on Desktop ↔ Desktop hops).
Frontmost app and responsibility-PID — AppKit + private SPI.
NSWorkspace.didActivateApplicationNotificationandNSWorkspace.frontmostApplication.responsibility_get_pid_responsible_for_pid(declared via@_silgen_nameinSources/PID.swift) resolves helper processes to their parent app — e.g.WebKit.GPU→ Safari — so the same-app check works without adapter cooperation.
Now Playing — vendored mediaremote-adapter subprocess.
- perl is on Apple's MediaRemote allowlist; an unentitled Swift binary isn't. The adapter is a perl shim that loads
MediaRemoteAdapter.frameworkviadl_load_file. - Run in
streammode with--no-diff --debounce=200 --no-artwork; each newline-delimited JSON event decodes into aNowPlayingSnapshot(playing flag, owning PID, parent bundle, track title).
No entitlements required. One private SPI (responsibility_get_pid_responsible_for_pid); everything else is public API.
houdini is built on top of mediaremote-adapter by Jonas van den Berg (@ungive). Without it, there would be no practical way for an unentitled binary to observe Now Playing state on modern macOS. Huge thanks to Jonas and the project's contributors.
The vendored sources under vendor/mediaremote-adapter/ are distributed under the BSD 3-Clause License — see vendor/mediaremote-adapter/LICENSE (upstream: https://github.com/ungive/mediaremote-adapter/blob/master/LICENSE).
houdini is released under the MIT License. See LICENSE for the full text.
The vendored mediaremote-adapter retains its own BSD 3-Clause License — see vendor/mediaremote-adapter/LICENSE (upstream: https://github.com/ungive/mediaremote-adapter/blob/master/LICENSE).