◀ Back to NotesPublished on April 12, 2023

Fullscreen apps above the MacBook notch

NOTE: This is not a tutorial. If you don't understand this article in detail, please don't attempt to do what's written in it.

On MacBook laptops with a notch, the area above the notch is reserved for the menu bar for most applications. That is good for normal window usage, as the menubar no longer takes precious vertical space that could be used for the content.

Going fullscreen though, only makes that area black (so as to hide the notch) but apps will still render below the notch, leaving about 100k pixels useless.

So here are some instructions on how to fullscreen a window above the notch in the most hackish way possible.

By the way, if you prefer that old MacBook without a notch design, my Lunar app can disable the notch by applying a hidden resolution.

Lunar notch button


# Old-style fullscreen

Every macOS window is actually a NSWindow, and that class has a toggleFullScreen() method.

But what that does is move the window to its own special Mission Control Space and render it fullscreen below the notch. Not what I want.

There's a thing called old-style fullscreen which some apps support natively (Sublime Text, kitty, IINA etc.).

Instead of calling toggleFullScreen(), this method does the following:

  • makes the window borderless
  • sets up the app to auto-hide the menubar and dock
  • sets the window frame equal to the screen frame

Here's how IINA does it for example: iina/MainWindowController.swift · iina/iina · GitHub

The problem is that, while apps can change their own windows as they please, there's no way for an app to change those attributes on windows of other apps.

# Injecting code

Using Frida we can inject and run code inside the context of any running process.

We first need to disable SIP though, as code injection is restricted by default for obvious security reasons.

Below we define our Frida script, which we can save in ~/Documents/OverNotch.js for later usage.

OverNotch.js

var NSApplicationPresentationAutoHideMenuBar = 1 << 2
var NSApplicationPresentationAutoHideDock = 1 << 0
var NSWindowStyleMaskBorderless = 0
var NSApplicationPresentationDefault = 0

global.app = ObjC.classes.NSApplication.sharedApplication()

global.arrayFromNSArray = (nsArray) => {
    var jsArray = []
    var count = nsArray.count()
    for (var i = 0; i < count; i++) {
        jsArray[i] = nsArray.objectAtIndex_(i)
    }
    return jsArray
}

global.mainWindow = () => {
    if (global.app.mainWindow()) { return global.app.mainWindow() }
    if (global.app.keyWindow()) { return global.app.keyWindow() }

    var windows = arrayFromNSArray(global.app.windows())
    var index = Math.min(...windows.map((w) => w.orderedIndex()))
    return windows.find((w) => w.orderedIndex() == index)
}

global.w = mainWindow()

global.toggleFullScreen = (windowNum) => {
    if (!windowNum) { windowNum = global.w.windowNumber() }

    var dict = ObjC.classes.NSThread.mainThread().threadDictionary()
    if (dict.valueForKey_(windowNum.toString())) {
        stopFullScreen(windowNum)
    } else {
        makeFullScreen(windowNum)
    }
}

global.stopFullScreen = (windowNum) => {
    Interceptor.revert(app.setPresentationOptions_.implementation)
    ObjC.schedule(ObjC.mainQueue, () => {
        global.app.setPresentationOptions_(NSApplicationPresentationDefault)
    })

    var window = windowNum ? global.app.windowWithWindowNumber_(windowNum) : global.w
    if (!window) {
        return console.log('Window not found')
    }

    ObjC.schedule(ObjC.mainQueue, () => {
        var key = window.windowNumber().toString()
        var dict = ObjC.classes.NSThread.mainThread().threadDictionary()
        if (dict.valueForKey_(key)) {
            window.setStyleMask_(dict.valueForKey_(key).unsignedLongLongValue())
            dict.removeObjectForKey_(key)
        }

        window.setFrameUsingName_(key)
        window.setIsMovable_(true)

        Interceptor.revert(app.setPresentationOptions_.implementation)
    })
}

global.makeFullScreen = (windowNum) => {
    var window = windowNum ? global.app.windowWithWindowNumber_(windowNum) : global.w
    if (!window) {
        return console.log('Window not found')
    }

    ObjC.schedule(ObjC.mainQueue, () => {
        var key = window.windowNumber().toString()
        var dict = ObjC.classes.NSThread.mainThread().threadDictionary()
        var value = ObjC.classes.NSNumber.numberWithUnsignedLongLong_(window.styleMask())
        dict.setValue_forKey_(value, key)
        window.saveFrameUsingName_(key)

        global.app.setPresentationOptions_(NSApplicationPresentationAutoHideDock | NSApplicationPresentationAutoHideMenuBar)
        window.setStyleMask_(NSWindowStyleMaskBorderless)
        window.setFrame_display_(window.screen().frame(), true)
        window.setIsMovable_(false)

        Interceptor.replace(app.setPresentationOptions_.implementation, new NativeCallback((mask) => {}))
    })
}

Then as a test, have a Safari window open, and run the following to make it fullscreen:

sudo frida -q -l ~/Documents/OverNotch.js -e "toggleFullScreen()" Safari

Note that this doesn't work with all windows, and some apps will crash when their window styleMask is changed.

# Hotkey

I prefer to bind fn - a to fullscreen the current window using skhd.

I would usually bind rcmd - a as it's a bit easier to press, but since I surrendered the ⌘ Right Command key to my rcmd app switcher, I'm using fn for these system-wide hotkeys.

Here's what I added in ~/.skhdrc to do that:

fn - a  : sudo frida -q -l ~/Documents/OverNotch.js -e "toggleFullScreen()" $(osascript -e 'tell application "System Events" to get unix id of first application process whose frontmost is true')

# Why would you need such a thing?

I personally wanted it for having Windows 11 look as natively as possible through Parallels Desktop. I still need it from time to time for hardware that only provides a Windows configuration program.

Windows 11 full screen

You can also use it to get an immersive view of specific webpages. Here's NightDrive for example:

Safari NightDrive full screen

Or maybe have a more focused zen-mode on note taking apps. Here's NotePlan in my case:

NotePlan zen mode

# SIP disabled? How about no?

Frida and other debuggers like lldb and gdb use the task_for_pid API for attaching to a running process.

That doesn't necessarily need SIP being disabled. For task_for_pid to succeed on a process, that process needs to meet one of the following requirements:

  • be unsigned
  • be signed without Hardened Runtime
  • include the com.apple.security.get-task-allow entitlement

Fortunately, we can alter the signature of any non-system app without disabling SIP.

Unfortunately that's not very straightforward because we need to ensure that we sign all the additional bundles inside the app, and we need to take care of not removing existing entitlements.

Because I'm using fish shell, here's how I approach this task:

function resign-bundle -a bundle
    # Dump existing entitlements as a PLIST
    codesign -d --entitlements - --xml "$bundle" >/tmp/entitlements.xml
    if not test -s /tmp/entitlements.xml
        # Sign without Hardened Runtime
        codesign -fs $CODESIGN_CERT "$bundle"
        return
    end

    # Add `get-task-allow` to the entitlements to allow debuggers
    /usr/libexec/PlistBuddy -c "Add :com.apple.security.get-task-allow bool true" /tmp/entitlements.xml
    # Sign with Hardened Runtime and `get-task-allow`
    codesign -fs $CODESIGN_CERT -o runtime --timestamp --entitlements /tmp/entitlements.xml "$bundle"
end

function resign-app -a app
    # Backup existing app
    mkdir -p ~/.cache/resign-app-backups/
    if not test -d "~/.cache/resign-app-backups/$(basename "$app")"
        rsync -avz "$app" ~/.cache/resign-app-backups/
    end

    # Resign bundles inside the app
    fd -uu '\.(app|framework|dylib|xpc|appex)$' "$app" -j 1 -x fish -c 'resign-bundle {}'

    # Resign the app itself
    resign-bundle "$app"
end

Testing this on RealVNC Viewer:

> resign-app "/Applications/VNC Viewer.app"

Executable=/Applications/VNC Viewer.app/Contents/MacOS/vncviewer
/Applications/VNC Viewer.app: replacing existing signature

showing how native fullscreen differs from legacy fullscreen in VNC Viewer

I also tried testing this on VirtualBuddy but because it has a required entitlement (com.apple.vm.networking) and an embedded provisioning profile tied to the developer certificate, this got too complicated to warrant the effort.

System apps also can't work with this approach because some of them live in immutable volumes called Cryptexes. For example here's where Safari can be found on macOS Ventura:

# Safari lives at /System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app

> codesign --remove-signature '/System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app'
/System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app: internal error in Code Signing subsystem