Skip to content
GitHub

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.

  • 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 UserDefaults keys 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.

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: "`")
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.

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.

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.

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:

@MainActor
final 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.

Every constructed module gets a NookModuleContext - its isolated piece of the host process:

@MainActor
public 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>/
}
  • defaults is a private UserDefaults suite named opennook.module.<id>. Use it instead of UserDefaults.standard for anything module-specific so keys cannot collide between modules. Component stores accept it directly: ShelfStore(defaults: context.defaults), for example.
  • containerURL is a suggested folder under Application Support/<host>/Modules/<id>/. Not created on disk; the module creates it on first use.
  • services is a per-module AppServices bag (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).

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-write entitlement is what makes a picked file readable - ship it (the host’s entitlements template has it). And under swift run the binary is unbundled and unsandboxed with no powerbox, so the panel cannot enter TCC-protected folders (Downloads, Desktop, Documents); run the signed .app or 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.

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:

  1. Calls A.onDeactivate() on the outgoing module (synchronous, cheap cleanup).
  2. Calls B.onActivate() on the incoming module (the new module is now foreground).
  3. Builds B’s NookConfiguration from makeConfiguration() and re-publishes it to the surface, which re-wires its hooks and runs onReady for the new module.
  4. Drops A’s instance and context when A.descriptor.backgroundPolicy is .unloadOnSwitchAway (the default), so the next activation rebuilds it from scratch.
  5. Awaits A.prepareForSwitchAway() in a detached follow-on task, bounded by a 2-second timeout. The surface arbiter already treats A’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:

@MainActor
final 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.

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.

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."
)

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.

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.

  • Examples/MultiNook/main.swift - the working multi-module host this guide mirrors (full NookModule class, closure-registered modules, per-module service injection).
  • Activity queue - the prepareForSwitchAway drain pattern in context.
  • Sources/NookKit/App/Modules/ - registry, context, descriptor source.