Multiple modules
A 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.
When to use modules
Section titled “When to use modules”- Multiple distinct surfaces. A clock view, a clipboard view, a notepad - each with its own content and lifecycle - not screens nested under one home view.
- You want isolated persistence and services. Two modules in the same host
should never collide on
UserDefaultskeys or service instances. The framework gives each module its own context (see below). - You want to switch at runtime. Registering modules gives you switching
for free: a “Modules” section in the menu-bar item, an optional cycle hotkey,
and per-module direct-jump hotkeys. By default nothing is planted in the
expanded surface - it stays entirely the module’s own. Opt into a compact
in-surface switcher with
moduleSwitcherPlacement(see below).
If you only have one notch app, stick with NookConfiguration and
NookApp.main(_:). NookHostConfiguration traps if you build it empty - it
is the multi-module entry point and is meaningless with zero registrations.
Minimal setup
Section titled “Minimal setup”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.idhost.moduleCycleHotkey = NookHotkey(keyCode: 50, carbonModifiers: 4096 | 2048, keySymbol: "`")
NookApp.main(host)defaultModule is the module shown at launch. When unset, the first
registered module wins. moduleCycleHotkey adds a global shortcut that
advances to the next module in registration order.
Where the switcher appears
Section titled “Where the switcher appears”The framework never plants switcher chrome in a module’s expanded surface
uninvited. host.moduleSwitcherPlacement decides where the on-screen switch
affordance lives; switching is always reachable through the cycle and
per-module hotkeys regardless.
host.moduleSwitcherPlacement = .menuBar // default.menuBar(default) - a “Modules” section in the menu-bar item lists every module and switches on selection, with a check on the active one. Nothing is added to the expanded surface; it stays entirely the module’s own..leadingCluster- a compact switcher folded into the top bar’s leading cluster: the active module’s name and icon become a popup that lists the others. It replaces the leading title rather than adding a band, so it costs no extra height and never duplicates the active module’s identity. Also lists modules in the menu bar..none- no on-screen switcher anywhere. Switching is reachable only through the hotkeys.
Module drill-in breadcrumb
Section titled “Module drill-in breadcrumb”The switcher names the active module; AppState.moduleBreadcrumb names where
the user is inside it. When a module drills into a sub-screen - a selected
deck, an open document, a chosen profile - set moduleBreadcrumb to a short
label and the top bar renders it after the leading title as
[icon] Module > Breadcrumb, so the chrome reflects what the user is actually
looking at instead of the module’s static name. Set it back to nil to clear.
struct DeckList: View { @EnvironmentObject private var appState: AppState
var body: some View { ForEach(decks) { deck in Button(deck.name) { openDeck(deck) appState.moduleBreadcrumb = deck.name // drilled in } } }}Pushing a breadcrumb changes one thing in the chrome beyond the label: the
leading glyph stops being a static module mark and becomes a back control. On
home with no breadcrumb the title sits next to its icon; once a breadcrumb is
set the persistent title collapses and the glyph - now a chevron.left when the
module has no leading icon - clears moduleBreadcrumb when tapped. The
framework only clears the property; it does not touch the module’s own
navigation. The module observes the clear and pops its own sub-state:
.onChange(of: appState.moduleBreadcrumb) { _, breadcrumb in if breadcrumb == nil { popToRoot() // the chrome's back tap cleared it; mirror that here }}This is a soft state hint, not a view-mode change. It does not affect
AppState.viewMode or the home/Settings routing - a breadcrumb can be set while
the home view is showing, and entering Settings takes over the same leading slot
with its own breadcrumb until the user backs out. The label is constrained to
the pre-notch region: it is capped at metrics.breadcrumbMaxWidth (see
Theming for the chrome metrics knobs) and fades out at its
trailing edge rather than overrunning the notch, so a long sub-context label
never collides with the camera housing. Keep the labels short for that reason.
Descriptors and modules
Section titled “Descriptors and modules”A module has two parts: a cheap descriptor (the identity the switcher and hotkey registration need before construction), and the live module (the configuration, state, and lifecycle).
nonisolated static let moduleDescriptor = NookModuleDescriptor( id: "com.opennook.example.counter", displayName: "Counter", icon: "number", accent: .orange)id is the stable, unique identifier. It keys the switcher entry, the
per-module UserDefaults suite, the on-disk container folder, the direct-jump
hotkey, and the surface arbiter’s per-module claim invalidation - so do not
change it across releases.
A full module conforms to NookModule:
@MainActorfinal class CounterModule: NookModule { nonisolated static let moduleDescriptor = NookModuleDescriptor( id: "com.opennook.example.counter", displayName: "Counter", icon: "number", accent: .orange )
let descriptor = CounterModule.moduleDescriptor private let context: NookModuleContext
init(context: NookModuleContext) { self.context = context let tracker = LaunchTracker.bumping(context.defaults) context.services.register(LaunchTrackerKey.self, tracker) }
func makeConfiguration() -> NookConfiguration { var configuration = NookConfiguration() configuration.setHome { CounterHome() } configuration.topBar.leadingTitle = { _ in "Counter" } configuration.topBar.leadingIcon = "number" return configuration }}For modules with no extra product state, register a plain NookConfiguration
closure instead - the host wraps it in a ClosureModule for you:
host.register(descriptor, configuration: { clockConfiguration() })Registration is cheap; module factories run lazily, only when a module is first activated. Registering ten modules pays the construction cost only for the ones the user actually opens.
Per-module persistence: NookModuleContext
Section titled “Per-module persistence: NookModuleContext”Every constructed module gets a NookModuleContext - its isolated piece of
the host process:
@MainActorpublic final class NookModuleContext { public let descriptor: NookModuleDescriptor public let defaults: UserDefaults // suite "opennook.module.<id>" public let services: AppServices // per-module DI bag public let containerURL: URL // Application Support/<host>/Modules/<id>/}defaultsis a privateUserDefaultssuite namedopennook.module.<id>. Use it instead ofUserDefaults.standardfor anything module-specific so keys cannot collide between modules. Component stores accept it directly:ShelfStore(defaults: context.defaults), for example.containerURLis a suggested folder underApplication Support/<host>/Modules/<id>/. Not created on disk; the module creates it on first use.servicesis a per-moduleAppServicesbag (see below).
NookHostConfiguration.register traps on a duplicate module id (see the
pitfall below) precisely so two modules can’t silently alias on any of these.
Service isolation: ServiceKey and AppServices
Section titled “Service isolation: ServiceKey and AppServices”AppServices is a SwiftUI-environment-style DI container: a module declares
a key, registers an instance for it on construction, and resolves it back from
its views. Resolution is total - it never returns nil, falling back to the
key’s defaultValue instead.
final class LaunchTracker: Sendable { let launchCount: Int init(launchCount: Int) { self.launchCount = launchCount } static let unregistered = LaunchTracker(launchCount: 0)}
struct LaunchTrackerKey: ServiceKey { static let defaultValue: LaunchTracker = .unregistered}
// In the module's init, register against the context's services bag.context.services.register(LaunchTrackerKey.self, tracker)
// In any view of that module, resolve through the environment.struct CounterHome: View { @Environment(\.appServices) private var services var body: some View { let count = services.resolve(LaunchTrackerKey.self).launchCount Text("Opened \(count) times") }}Each AppServices instance is private to the module that owns it. A view in
module A and a view in module B that look up the same key get different
results: the value module A registered, and the defaultValue for module B.
This is the seam that keeps modules from accidentally sharing state through a
process-global container.
Register a key once per construction. The double-register guard traps in
debug; in release the last writer wins. If you genuinely need to replace a
service later, use the subscript form (services[Key.self] = newValue).
File pickers from a module
Section titled “File pickers from a module”A module that imports or exports files should not roll its own NSOpenPanel.
The host is an agent app (LSUIElement) whose only window is a non-activating
panel, so at the moment a module asks for a file the app is inactive: a panel
presented by an inactive agent comes up non-key, can appear behind the frontmost
app, and its sidebar stops responding to clicks. The host owns the fix once and
hands every module a ready picker through services.
NookFilePicker is registered into every module’s AppServices automatically
when its context is built - alongside the presentation-pinning broker - so you
never wire it yourself. Resolve it from the \.appServices environment via
NookFilePickerKey and call open or save:
struct ImportButton: View { @Environment(\.appServices) private var services
var body: some View { Button("Import") { let picker = services.resolve(NookFilePickerKey.self) Task { guard let selection = await picker.open( .init(allowedContentTypes: [.pdf], allowsMultipleSelection: true) ) else { return } selection.withAccess { urls in store.accept(urls) } } } }}Both open(_:) and save(_:) are async and return a NookFileSelection? -
nil when the user cancels or a panel is already up (only one panel is
presented at a time). open takes NookOpenOptions; save takes
NookSaveOptions. The picker does two things a hand-rolled panel cannot:
- It activates the app before presenting, so the panel comes up key and interactive even though the click on the notch never activated the agent.
- It holds the surface open for the panel’s whole lifetime. A picker is a separate AppKit panel outside the notch window, so while it is up the pointer has left the notch; without a pin the surface would auto-compact and a competing module’s arbiter claim could be granted underneath. The picker pins the surface for the panel’s lifetime and releases the pin once it closes - the same pin a module’s own transient presenter would take.
A panel-returned URL comes back already security-scoped. The NookFileSelection
owns that live access and stops it when the selection is released, so read the
file’s contents - or capture a .withSecurityScope bookmark to persist access
across launches - inside withAccess(_:) while the selection is still alive.
After it is released the URLs are path-level only and reads fail under the
sandbox.
Sandbox caveat. Under the App Sandbox, the
com.apple.security.files.user-selected.read-writeentitlement is what makes a picked file readable - ship it (the host’s entitlements template has it). And underswift runthe binary is unbundled and unsandboxed with no powerbox, so the panel cannot enter TCC-protected folders (Downloads, Desktop, Documents); run the signed.appor grant your terminal Full Disk Access. That is a dev-loop artifact, not a shipping limitation. See Shipping for the full entitlement list.
For module tests, register your own NookFilePresenting fake against
NookFilePickerKey - NSOpenPanel cannot run headless, and the key’s default
value is a deliberately inert picker that traps in debug if it is ever resolved
without the host registration.
The module-switch lifecycle
Section titled “The module-switch lifecycle”A module switch flips identity inside a single serial transaction, so the user sees the incoming module immediately - no half-applied state, no flash of the outgoing chrome with the incoming content.
For one switch from A to B, the framework:
- Calls
A.onDeactivate()on the outgoing module (synchronous, cheap cleanup). - Calls
B.onActivate()on the incoming module (the new module is now foreground). - Builds
B’sNookConfigurationfrommakeConfiguration()and re-publishes it to the surface, which re-wires its hooks and runsonReadyfor the new module. - Drops
A’s instance and context whenA.descriptor.backgroundPolicyis.unloadOnSwitchAway(the default), so the next activation rebuilds it from scratch. - Awaits
A.prepareForSwitchAway()in a detached follow-on task, bounded by a 2-second timeout. The surface arbiter already treatsA’s in-flight claims as stale, so this drain runs under the covers without blocking the user-visible switch.
The async seam matters when a module owns a transient surface presenter -
typically a NookActivityQueue (see the Activity queue
guide). Implement prepareForSwitchAway to drain
in-flight work and release the surface cleanly:
@MainActorfinal class ActivityModule: NookModule { private let queue = NookActivityQueue()
func prepareForSwitchAway() async { await queue.quiesce() }}onActivate and onDeactivate stay synchronous - use them for cheap setup
and teardown (start/stop timers, attach/detach observers). Anything that has
to join in-flight work belongs in prepareForSwitchAway.
Background policy
Section titled “Background policy”NookModuleDescriptor.backgroundPolicy controls what happens to a module on
switch-away:
.unloadOnSwitchAway(the default). Tear the module instance down; rebuild it from a fresh context on next activation. Cheapest. Use this for any module that does no background work..stayResident. Keep the instance alive in the background so its services and any owned queues keep running. A backgrounded module can still post activities, but the surface arbiter only grants its claims when their priority is.urgent- background modules cannot quietly take over the surface from the foreground one.
Host branding
Section titled “Host branding”The framework chrome - About card, show/hide hotkey label, menu-bar fallback -
reads identity from NookHostConfiguration.branding. A single-module host
gets the demo defaults ("Nook"); a multi-module host usually sets its own:
host.branding = NookHostBranding( hostName: "Constellation", hostTagline: "Your workspace, on the notch.")Pitfalls
Section titled “Pitfalls”Duplicate module ids trap at registration
Section titled “Duplicate module ids trap at registration”NookHostConfiguration.register traps on a duplicate id. Two registrations
under the same id would silently alias on persistence suites, switcher
entries, hotkey registration, and arbiter claim invalidation - every one of
those an unrecoverable corruption. Failing fast at main.swift setup is the
intended behavior; don’t catch it, fix the id.
nonisolated descriptor, @MainActor module
Section titled “nonisolated descriptor, @MainActor module”A module class is @MainActor, but its descriptor static is referenced from
the nonisolated top level of main.swift (where you assemble the
NookHostConfiguration). Spell the descriptor nonisolated static let so
that reference compiles - the descriptor is an immutable Sendable value, so
this is safe.
Don’t register services against the process-global container
Section titled “Don’t register services against the process-global container”A module’s services go in context.services, not UserDefaults.standard or
a process-wide singleton. The former is isolated per module; the latter two
silently alias across modules and undo the isolation guarantee.
Owning a NookActivityQueue? Implement prepareForSwitchAway
Section titled “Owning a NookActivityQueue? Implement prepareForSwitchAway”A module that holds a transient surface presenter must drain it before the switch completes, or its dangling surface claim can outlive the switch. See the Activity queue guide for the full pattern.
See also
Section titled “See also”Examples/MultiNook/main.swift- the working multi-module host this guide mirrors (fullNookModuleclass, closure-registered modules, per-module service injection).- Activity queue - the
prepareForSwitchAwaydrain pattern in context. Sources/NookKit/App/Modules/- registry, context, descriptor source.