Skip to content
All lab notes

Lab note · from Deadbolt

Hide a macOS app the moment it activates, then ask for Touch ID

By Mark Santos · · 5 min read

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 a Task puts 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 sink has no receive(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. AuthManager uses .deviceOwnerAuthenticationWithBiometrics and 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. SessionManager keeps 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.didHideApplicationNotification for 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 isUnlocked is always false, so the activate() after a successful unlock should post another activation and prompt again. We haven't run it, because running it means hiding a real app. Checking isUnlocked before 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.screenIsLocked distributed notification (not declared in any SDK header) call lockAll(). 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.swift sets swift-tools 5.9 and .macOS(.v13), and Info.plist sets LSMinimumSystemVersion 13.0. The app isn't sandboxed: com.apple.security.app-sandbox is false in its entitlements. It builds with Swift 6.4 against the macOS 27.0 SDK.

More lab notes