Lab note · from Deadbolt
Hide a macOS app the moment it activates, then ask for Touch ID
Deadbolt hides a protected app from inside its NSWorkspace.didActivateApplicationNotification handler, in the same main-thread call stack, and only then asks for Touch ID with LAContext. A successful unlock calls unhide(). That puts the hide as close to the activation as a notification observer can get, but it isn't "before the first frame". In a passive test on macOS 27.2, the app was already active and frontmost when the notification arrived in 8 of 8 activations, and the SDK header says hide() returns whether the request was sent.
Symptoms
- Prompt first, hide on failure. The protected window stays readable for as long as the Touch ID sheet is up.
- A hop before the hide.
DispatchQueue.main.async,receive(on:)or aTaskputs the hide at least one run-loop turn later. - A flash of the content. Deadbolt's design goal is a hide inside one frame: 16.7 ms at 60 Hz, 8.3 ms at 120 Hz. Nothing in the repository measures it. The README's "before any content is rendered on screen" has no test behind it. The project's worklog lists "app hide/unhide" among the end-to-end flows that "require a real logged-in GUI session", and records only that the app "boots cleanly".
Why it happens
You hear about activation after it happens
Apple's didActivateApplicationNotification reference describes it as posted "when the Finder is about to activate an app", but the name says did. A throwaway observer (it never hid, activated or drew anything) logged 8 activations on macOS 27.2 while the Mac was in normal use. Every notification arrived on the main thread, and in every one isActive was already true and the app was already frontmostApplication. By the time your handler runs, the system already reports the switch as done. Whether a frame of its windows has reached the display depends on timing you don't control.
hide() is a request
NSRunningApplication.h documents the return value of hide() as "YES if the request to hide or unhide was successfully sent, NO if not (for example, if the application has quit...)". The web reference for hide() says "successfully hidden", but the header is the precise one. The windows disappear when the target app and the window server act on the request, out of your process. The header also warns that time-varying properties "persist until the next turn of the main run loop", so reading isHidden inside the handler can't confirm anything.
Authentication can't be synchronous
For evaluatePolicy(_:localizedReason:reply:), Apple says the reply is "evaluated on a private queue internal to the framework in an unspecified threading context". There is nothing to block activation on, so the hide has to come first and stand on its own.
The fix
The naive version, not from the repository:
NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
) { note in
guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
isProtected(app) else { return }
authenticate { ok in if !ok { app.hide() } } // readable for the whole prompt
}
Deadbolt's AppWatcher.swift, unchanged since the first commit:
cancellable = NSWorkspace.shared.notificationCenter
.publisher(for: NSWorkspace.didActivateApplicationNotification)
.sink { [weak self] notification in self?.handleActivation(notification) }
private func handleActivation(_ notification: Notification) {
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
let bundleID = app.bundleIdentifier else { return }
guard bundleID != Bundle.main.bundleIdentifier else { return } // never lock Deadbolt
guard protectedAppStore.isProtected(bundleID) else { return } // Set<String> lookup
// CRITICAL: Hide synchronously — same call stack, no async
windowHider.hide(app) // app.hide()
delegate?.appWatcher(self, didActivateProtectedApp: app, bundleID: bundleID)
}
The delegate in AppDelegate.swift, trimmed, runs the prompt only after that:
guard !sessionManager.isPendingAuth(bundleID) else { return }
if sessionManager.isUnlocked(bundleID) { windowHider.unhide(app); return }
sessionManager.setPendingAuth(bundleID)
authPromptWindow.show(appName: appName, appBundleID: bundleID, authManager: authManager) { success, method in
if success {
self.sessionManager.recordUnlock(bundleID)
self.windowHider.unhide(app)
app.activate()
} else {
self.sessionManager.lock(bundleID) // stays hidden
}
self.activityLogger.log(appBundleID: bundleID, appName: appName, authMethod: method, success: success)
}
Why it works:
- No hop. The
sinkhas noreceive(on:), so the handler runs wherever the notification is delivered, and the test observed the main thread every time. The hide is issued inside that same callback. - Almost no work before the hide. Running the same checks (reading the bundle ID, comparing it with Deadbolt's own, and a
Set<String>lookup) inside a passive observer took roughly 90 to 250 µs per activation. That is a small fraction of a 16.7 ms frame. The rest of the budget belongs to the window server and the target app. - The prompt can take as long as it needs.
AuthManageruses.deviceOwnerAuthenticationWithBiometricsand moves the reply to the main queue. If biometry is unavailable, fails, or the user picks "Use Password", Deadbolt shows its own panel and checks a master password, stored as a salted SHA-256 hash in the Keychain, with five attempts. - Unlocks live only in memory.
SessionManagerkeeps a[String: UnlockState]dictionary and never persists it. After a relaunch every app starts locked. What does reach disk is the protected-app list (UserDefaults), the password hash (Keychain) and the activity log (SQLite).
How to check you've fixed it
- Log the time from the activation notification to
NSWorkspace.didHideApplicationNotificationfor the same process identifier. That measures the round trip of the request, not pixels. - For pixels, record the display at its refresh rate while switching to a protected app, and step through the frames after the switch.
- In Every Time mode, unlock once and count the prompts. There should be one.
- Force-quit Deadbolt while a protected app is unlocked, relaunch it, and look at what stays on screen.
Caveats
- Hide before check. The watcher hides before the delegate looks at session state. Reading the code, an app that is already unlocked is hidden and unhidden on every activation. The default mode is Every Time, where
isUnlockedis always false, so theactivate()after a successful unlock should post another activation and prompt again. We haven't run it, because running it means hiding a real app. CheckingisUnlockedbefore hiding avoids both. - Other surfaces. Automatic hiding is tied to activation. The header says "a hidden app may unhide itself at any time", and nothing observes
didUnhideApplicationNotification. Hiding does nothing about screen capture of frames drawn before the hide landed. A "stealth mode" that keeps protected windows out of Mission Control appears in Deadbolt's summary but isn't in the repository. - Auto-lock. Screen sleep, system sleep and the
com.apple.screenIsLockeddistributed notification (not declared in any SDK header) calllockAll(). That only changes session state: windows stay visible until the app is next activated. The popover's Lock All does the same. The ⌘⇧L hotkey and the status item's right-click "Lock All Apps" also hide. Lid close has no observer of its own. - Crashes. State resets to locked, but at launch Deadbolt hides only the frontmost protected app, and nothing is protected between the crash and the relaunch.
- The hotkey needs Accessibility. It uses
NSEvent.addGlobalMonitorForEvents, and Apple's reference says "Key-related events may only be monitored if accessibility is enabled or if your application is trusted for accessibility access". - Targets.
Package.swiftsets swift-tools 5.9 and.macOS(.v13), andInfo.plistsetsLSMinimumSystemVersion13.0. The app isn't sandboxed:com.apple.security.app-sandboxis false in its entitlements. It builds with Swift 6.4 against the macOS 27.0 SDK.
More lab notes
- macOS global hotkey: RegisterEventHotKey vs an NSEvent monitorA Carbon hotkey that never fires may just be registered late. An NSEvent monitor needs Accessibility and can't stop the key reaching the frontmost app.
- Swift menu bar app stops updating: Timer run loop mode and App NapA Timer in the default run loop mode doesn't fire while a menu is open, and App Nap throttles it later. Add it in .common and hold a beginActivity token.
- CGWindowListCreateImage unavailable in macOS 15: use SCStreamCGWindowListCreateImage is deprecated in macOS 14 and a compile error from a macOS 15 target. Anchor still uses it; here is the SCStream replacement.
- Check a password against Have I Been Pwned without sending itSHA-1 the password, send only the first 5 hex characters to the Pwned Passwords range API, match the suffix locally, and add Add-Padding: true.