An open-source framework for building macOS notch apps.
Website: opennook.dev · Docs: Getting started
OpenNook gives you the hard part for free: a polished window that lives in the
menu-bar notch, expands and collapses on hover, paints a proper frosted
backdrop, and ships with a settings shell and a global hotkey. Register your
home view through NookConfiguration; the top bar, Settings, hotkey, and
compact pill come for free. Optional NookComponents add-ons cover a file
shelf, a live-activity queue, and an ambient volume glyph.
It is a base layer plus a working demo - not a finished product. The demo app is intentionally minimal: it shows the framework off and gives you a known-good starting point to fork.
Collapsed, the nook is a compact pill in the menu-bar notch (customizable
leading/trailing slots). Hover to expand on desktop, or press ⌥⌘; to
toggle. Expanded, you get framework chrome (top bar, lock, settings) around
the view you register. Layout follows the display: notch-fused on notched
panels, floating capsule elsewhere (NookPresentation, overridable in
Settings). The shelf above is from Examples/ShelfNook via optional
NookComponents.
OpenNook is two Swift modules, a thin demo app, and two ways to launch it.
The low-level chrome: the notch-shaped panel itself, its shape geometry, hover behavior, expand/compact lifecycle, the translucent backdrop, and the shimmer feedback overlay. This is a trimmed, renamed fork of DynamicNotchKit and is licensed MIT (see Licensing).
You usually don't edit NookSurface - you drive it through NookKit.
Everything built on top of NookSurface to make it feel like an app:
App/AppCoordinator.swift- lifecycle (show / hide / toggle, keep-open, reset settings); binds the notch backdrop to appearance preferences.App/AppState.swift-viewMode,appearancePreferences, visibility flags. Add your product state alongside these.App/AppServices.swift- an empty dependency container. Add your services (clipboard, networking, persistence...) here so view initializers stay put.App/NookAppearancePreferences.swift- persisted theme / surface style / haptics, with forwards-compatibleCodabledecoding.App/Views/NookExpandedView.swift- the framework chrome shell (top bar + Settings) that hosts the home view you register viaNookConfiguration.App/Views/NookTopBar.swift- home glyph + lock (keep-open) + gear.App/Views/Compact/CompactViews.swift- the left/right slots flanking the physical notch when collapsed.App/Views/Settings/- Appearance, Display, Hotkey, and Data panels.App/Views/Shared/- reusable settings primitives.System/HotkeyController.swift- a Carbon-based global hotkey.
Opt-in components built on the layers above - depend on this product only when
you want one. It is not pulled in by NookApp.
Shelf/- a file shelf: drop files onto the notch, they collect in a persistentShelfStore, and you can drag them back out. Render it withNookShelfViewand wireShelfStore.acceptintoNookConfiguration.onFileDrop. SeeExamples/ShelfNook.Activities/- a priority live-activity queue:NookActivityQueuecollects transient activities, orders them by priority, coalesces duplicates, and presents each by briefly taking over the expanded surface. Bind it viaNookConfiguration.onReadyand render withNookActivityHost. The queue yields the surface whenever the user is engaging it. SeeExamples/ActivityNook.Volume/- an ambient volume glyph:SystemVolumeObservertracks the default output device's volume and mute via public CoreAudio APIs;NookVolumeIndicatorrenders it as a compact-slot glyph. It shows the level - it does not intercept or replace Apple's volume HUD. SeeExamples/VolumeNook.
Sources/NookApp/NookApp.swift- the library entry point shared by both launch surfaces; sets up theNSApplication, the coordinator, and a menu-bar fallback.Sources/NookExecutable/main.swift- a three-line SPM trampoline soswift run Nookworks.App/main.swift+App/Info.plist- the Xcode app-target trampoline and bundle metadata, for producing a real signed.app.
- macOS 15 or later
- Xcode command line tools
- XcodeGen (
brew install xcodegen)- only if you want the Xcode project
The fast path - a headless dev binary via SwiftPM:
swift build # build
swift run Nook # run the demo
swift test # run the test suiteOnce it's running, press ⌥⌘; (or use the menu-bar item) to expand the nook. You can rebind that shortcut in Settings -> Shortcut & nook.
For a real .app bundle (signing, notarization, Cmd-R in Xcode):
./Scripts/regenerate-xcodeproj.sh # generates Nook.xcodeproj from project.yml
open Nook.xcodeprojNook.xcodeproj is a generated artifact and is gitignored - project.yml is
the source of truth. Regenerate it after a fresh clone or after editing
project.yml. Both build paths compile the same SwiftPM modules, so behavior
cannot drift between them.
Single-file examples under Examples/ show how to build on OpenNook through
public API only - no forking:
swift run HelloNook # register one view, go
swift run ClockNook # custom home view + a custom compact slot
swift run ThemedNook # a host-supplied theme + lifecycle hooks
swift run ChromeNook # the deeper chrome seams: launch defaults, labels, motion, brand mark, status
swift run LayoutNook # expanded width + nookContentInsets (avoid double horizontal padding)
swift run ShelfNook # a drop-files-on-the-notch shelf (NookComponents)
swift run ActivityNook # a priority live-activity queue (NookComponents)
swift run VolumeNook # an ambient volume glyph in the compact pill (NookComponents)
swift run MultiNook # multiple interchangeable modules sharing one surfaceYou depend on OpenNook as a package and customize it through public API - you do not fork the framework.
1. Register a view. Hand NookApp.main your expanded home view; the top
bar, Settings, hotkey, and compact pill all come for free:
import NookApp
import SwiftUI
NookApp.main { MyHomeView() }2. Customize via NookConfiguration when you need more than a home view -
the compact slots, the chrome theme, the top bar's leading label/icon, the
chrome flags, lifecycle hooks, and file drops:
var configuration = NookConfiguration()
configuration.setHome { MyHomeView() }
configuration.setCompactTrailing { MyGlyph() }
configuration.theme = { appState in MyPalette.resolve(appState) }
// Top bar - leading cluster identity and chrome flags live on `topBar`.
configuration.topBar.leadingTitle = { _ in "Today" } // default: "Home"
configuration.topBar.leadingIcon = "house" // nil = brand mark; SF Symbol overrides
configuration.topBar.showsTopBar = true // false strips top bar + gear + lock
configuration.topBar.showsSettings = true // false drops the gear (top bar stays)
configuration.setTopBarTrailingItems { MyGlyph() } // host actions left of the lock/gear
// Lifecycle hooks.
configuration.onExpand = { print("nook expanded") }
configuration.onCompact = { /* user dismissed / hover-exit collapsed the nook */ }
configuration.onHide = { /* nook went hidden */ }
configuration.onFileDrop = { urls in /* accept/reject dropped files */ true }
configuration.onReady = { coordinator in /* post-launch handle for components */ }
NookApp.main(configuration)Your views read the resolved palette from the \.nookResolvedTheme
environment value and shared services from \.appServices.
3. Add your state and services. AppState
(Sources/NookKit/App/AppState.swift) holds chrome state - add product state
alongside it; AppServices (Sources/NookKit/App/AppServices.swift) is the
dependency container threaded into views.
4. Drive the chrome through AppCoordinator - showNook(), hideNook(),
toggleNook(), toggleKeepNookOpen() are the lifecycle vocabulary; the global
hotkey and menu-bar fallback already call into them.
Rename the product (Nook -> your app) by editing project.yml,
App/Info.plist, and the Package.swift product name when you're ready to
ship.
NookConfiguration exposes the rest of the chrome through additive, non-breaking
seams - every default reproduces the framework exactly, so you opt in only where
you need to. The Chrome customization guide
covers each seam in full; the headlines are below.
Launch defaults. Ship your own out-of-box appearance, global hotkey, and display target. Seed-only: a value the user has changed in Settings always wins, and the seed is never persisted (so a later build can revise it for untouched users):
configuration.preferenceDefaults = NookPreferenceDefaults(
appearance: NookAppearancePreferences(
chromePalette: .dark, surfaceStyle: .translucent, presentation: .floating
)
)surfaceStyle is .solid, .translucent, or .liquidGlass - the real macOS 26
Liquid Glass material, with a pre-Tahoe approximation so the package still builds
and runs on earlier systems. See
Surface materials.
Chrome behavior. Hover side-effects, the cold-launch shimmer, and the appearance->backdrop mapping:
configuration.chromeBehavior = NookChromeBehavior(
hoverBehavior: .all, // default []: opt into hover haptics / keep-visible
showsLaunchShimmer: false, // default true: launch silently
backdrop: { preferences, scheme, reduceTransparency in
.vibrancy(.init(material: .hudWindow, darkenOpacity: 0.3))
}
)Labels, metrics, motion. Localize chrome strings, tune the few fixed layout values, retune the in-panel springs:
configuration.labels.settingsBreadcrumb = "Préférences"
configuration.metrics.compactSlotSize = 28
configuration.motion.viewModeChange = .snappyStatus banner. Post info / success / warning / error from any AppState
handle; suppress the framework banner if you render your own:
appState.showStatus("Imported 3 files", severity: .success)
configuration.topBar.showsStatusBanner = falseIdentity. Name the product, drop in a custom brand mark (replaces the OpenNook
glyph in the top bar, About card, and menu bar), or turn the menu-bar item off -
all reachable from a single-module NookConfiguration:
configuration.branding = NookHostBranding(
hostName: "ContextNook",
hostTagline: "A focused notch app.",
mark: { size, color in AnyView(MyMark(color: color).frame(width: size, height: size)) }
)
configuration.showsMenuBarExtra = falseA single host can run several interchangeable modules - independent notch
apps sharing one surface, one menu bar, and one set of preferences. Each
module ships its own NookConfiguration, its own services, and an optional
global shortcut for direct-jump or cycle-through. Use this when the notch
should host distinct surfaces (a clock, a counter, a notepad) that the user
flips between rather than nesting inside one home view.
import NookApp
var host = NookHostConfiguration()
// A NookModule type that builds its own configuration and services.
host.register(CounterModule.moduleDescriptor) { context in
CounterModule(context: context)
}
// Or just register a configuration closure for the simpler cases.
host.register(
NookModuleDescriptor(id: "com.example.clock", displayName: "Clock", icon: "clock"),
configuration: { clockConfiguration() }
)
host.defaultModule = CounterModule.moduleDescriptor.id
host.moduleCycleHotkey = NookHotkey(keyCode: 50, carbonModifiers: 4096 | 2048, keySymbol: "`")
// Host-product identity - the framework chrome (About card, show/hide hotkey
// label, menu-bar fallback) reads `hostName` and `hostTagline` from here.
// Defaults reproduce the demo strings exactly.
host.branding = NookHostBranding(
hostName: "ContextNook",
hostTagline: "Three modules, one notch."
)
NookApp.main(host)By default the switcher lives in the menu-bar item (a "Modules" section) plus
the cycle / per-module hotkeys - nothing is planted in a module's expanded
surface. Set host.moduleSwitcherPlacement = .leadingCluster for a compact
in-surface switcher folded into the top bar, or .none for hotkeys only.
The active module's hooks/services drive the surface; switching is one
atomic transaction on the lifecycle chain (no half-applied state, no
arbiter claims leaking across modules). See Examples/MultiNook/main.swift
for the full pattern - a NookModule class with a typed ServiceKey-backed
dependency, three modules with per-module top-bar identity, and the
closure-registration shortcut for the simpler ones.
The swift run paths are for the dev loop. To ship a real signed .app
you need a few extra pieces - most of them tiny, none of them magic.
- Bundle identity. Rename the product in three places:
Package.swift(the.executable(name:)and.library(name:)),project.yml(name:,targets:,PRODUCT_BUNDLE_IDENTIFIER), andApp/Info.plist(CFBundleIdentifier,CFBundleName). The reverse-DNS id you pick is used byNookModuleContext.makeDefaultto name on-disk containers and per-moduleUserDefaultssuites, so pick before you ship. - Persistence suites.
NookKitwrites its own preferences intoUserDefaults.standardunder theopennook.*prefix (opennook.appearance.v1,opennook.display.v1,opennook.hotkey.v1,opennook.module.default);NookComponents.Shelfwritesnook.shelf.items. If you're usingNookHostConfiguration, each module gets its ownUserDefaultssuite viaNookModuleContext. Don't collide host product state with theopennook.*ornook.shelf.*keys. - Entitlements. A ready-to-copy template lives at
App/Nook.entitlementswith the minimum that lets every framework feature work inside the App Sandbox:com.apple.security.app-sandbox,com.apple.security.files.user-selected.read-write(shelf drag-in and the file picker - see below),com.apple.security.files.bookmarks.app-scope(scoped-bookmark persistence). It is intentionally not wired into the build by default, so the demo and the dev loop stay unsandboxed; to actually sandbox the app, addCODE_SIGN_ENTITLEMENTS: App/Nook.entitlementsto theNookHostApptarget inproject.ymland regenerate. The global hotkey is Carbon and needs no entitlement. CoreAudio default-output listening is read-only and needs no entitlement. The shelf detects the sandbox at runtime (ShelfRuntime.isSandboxed) and switches to a stricter acceptance mode. - File pickers. Modules present open/save panels through the host's
NookFilePicker(resolved from\.appServicesviaNookFilePickerKey), which activates the app so the panel is interactive from the non-activating notch panel and holds the surface open while it's up. Two caveats: (1) under the sandbox,files.user-selected.read-writeis what makes a picked file readable, so ship the entitlement above; (2) underswift runthe binary is unbundled and unsandboxed with no powerbox, so the panel can't enter TCC-protected folders (Downloads, Desktop, Documents) - run the signed.app(theNookHostApptarget, e.g. Cmd-R in Xcode) or grant your terminal Full Disk Access. This is a dev-loop artifact, not a shipping limitation. - Menu-bar accessory, no Dock. Set
LSUIElement = trueinInfo.plistif you want the menu-bar-only behavior the demo ships. - Hardened runtime + notarization for distribution outside the Mac App Store. Sign with a Developer ID, enable hardened runtime, notarize. None of OpenNook's APIs require runtime exceptions.
OpenNook is licensed under the Apache License 2.0 - see LICENSE.
The Sources/NookSurface/ subtree is licensed MIT instead, because it is
derived from DynamicNotchKit by
Kai Azim. See LICENSE-MIT-NOOKSURFACE,
ThirdPartyLicenses/DynamicNotchKit.txt,
and NOTICE.md for the full license map.
Both licenses are permissive - you can build and ship a closed-source product on top of OpenNook.

