A few weeks ago I posted about breaking the most popular Mac app locker before building my own. The gist: almost every "app locker" on macOS draws a translucent overlay on top of the app's window and asks Accessibility/Automation permissions to do it. Overlays can be dismissed. Permissions are friction and a privacy smell for a privacy product.
This is the promised follow-up: how Shoo! actually works under the hood, and the bugs that taught me why the obvious approaches don't.
The core idea: don't hide the app, don't let it run
There's no macOS API to "put a password on someone else's app." You can't inject auth into a foreign process. So instead of hiding a running app behind a fake window, Shoo! does this:
-
NSWorkspace.didLaunchApplicationNotification/didActivateApplicationNotificationfires for a locked app. - Shoo! immediately calls
NSRunningApplication.forceTerminate()on it. - Shoo! brings itself to the front and triggers Touch ID (
LAContext.evaluatePolicy). - On success, it relaunches the app via
NSWorkspace.shared.openApplication(at:configuration:)and marks the bundle ID as unlocked for the session. - On cancel, the app just stays closed. Nothing to hide, because there's nothing running.
The nice side effect: no full-screen overlay, no Accessibility, no Automation permission. If there's no window to protect, there's no window to click through — which turns out to matter a lot (see the first dead end below).
Two dead ends that ate a lot of time
Overlay windows that intercept clicks. Any mouse click that reaches the locker's own window while a Touch ID prompt is pending silently kills the biometric session — no error, no callback, the fingerprint scanner just stops responding until the app restarts. Reproducible with every combination of canBecomeKey/ignoresMouseEvents I tried.
Physically hiding the target app instead of killing it. Three attempts, three failures:
-
NSRunningApplication.hide()silently returnsfalsefor some apps (Telegram, notably). - AppleScript
set visible of process ... to false"succeeds" and changes nothing. -
AXUIElementSetAttributeValue(window, kAXMinimizedAttribute, true)works maybe 60% of the time on the same app.
Once I stopped trying to hide a running process and started terminating it instead, an entire category of bugs disappeared.
State bookkeeping: the "we already handled this" trap
AppMonitorService tracks two sets:
var unlockedForSession: Set<String> = [] // bundle IDs unlocked this session
var lockingInProgress: Set<String> = [] // bundle IDs WE just force-terminated
lockingInProgress exists because didTerminateApplicationNotification fires for any termination — including the one Shoo! itself just caused. Without ignoring our own termination events, the "unlocked" flag would get reset the instant we set it. The general lesson: a branch that says "we're already handling this, so do nothing" is a good place to hide a bypass. The actual fix has to be "don't re-trigger the check, but still make sure the process is dead" — not "don't touch it at all."
The focus-switch cancellation race
If the user doesn't authenticate and switches to a different app instead, the Touch ID prompt has to disappear — not float over someone else's window. That's AuthManager.cancel() → LAContext.invalidate(), which maps to LAError.appCancel.
The tricky part: closing the system Touch ID dialog itself briefly hands focus to another process, which looks externally identical to "the user switched apps." Naively watching didActivateApplicationNotification to cancel the prompt meant: click "Use backup password" → the password sheet appears and instantly disappears.
Fix: a 1-second desensitization window (ignoreDismissUntil) after showing the password sheet, plus arming the focus-switch observer only after Shoo! itself becomes the active app (switchAwayArmed) — otherwise it fires immediately, because closing the locked app hands focus to someone by definition. Activations from the system's own auth agents (com.apple.SecurityAgent, CoreAuthUI, coreauthd) are ignored outright, since they're the ones stealing focus during the dialog.
The backup password: PBKDF2, not SHA256
Early version stored the backup password as a bare SHA256 hash. That's close to nothing: an unsalted hash of a short password gets cracked against a rainbow table in seconds if anyone pulls the Keychain entry.
Current implementation: PBKDF2-SHA256, salted, 210,000 iterations, constant-time comparison, stored as JSON with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (never syncs to iCloud, inaccessible on a locked device). Rate limiting is 5 attempts, then exponential backoff — 5, 10, 20, 40 minutes, capping at an hour. The attempt counter lives in the same Keychain entry, not UserDefaults — otherwise one defaults delete resets it to zero.
Five layers to stop someone removing the locker itself
An app locker that can just be deleted doesn't lock anything. This turned out to be the layer where every competitor I tested (see the first post) failed outright.
The threat model is deliberately narrow: someone who got physical access to an unlocked Mac and doesn't know the admin password. Not "resist a determined attacker with root" — that's not a lock, that's malware.
-
Quit confirmation. Quitting requires Touch ID (or the backup password via an
NSAlert, since SwiftUI sheets inside a status-bar popover are unreliable). -
launchd auto-restart. A
LaunchAgentwithKeepAlive: truerestarts the process in under a second if it's killed. -
Startup sweep. On launch, any locked app already running gets a soft
terminate()(notforceTerminate()) — give it a chance to show its own "save changes?" dialog, since biometrics weren't the reason it's closing. -
Bundle watchdog. A
DispatchSourcewatches the app bundle directory for delete/rename events. On tamper: lock down first (clear all "unlocked" state, close protected apps), then try to restore itself from Trash. - Root ownership + a root daemon. This is the layer that actually mattered.
The real test: App Cleaner & Uninstaller
Layers 1–4 looked solid — until I ran an actual uninstaller against them. App Cleaner & Uninstaller removed the app with zero password prompts, bypassing every layer at once: SIGKILL (uncatchable) → rm -rf the bundle (no Trash, nothing to restore) → delete the LaunchAgent plist from ~/Library/LaunchAgents.
None of those three actions require admin rights, because both the app bundle and the LaunchAgent plist live under the user's own home directory.
Fix: ship as a signed .pkg that installs the bundle owned by root:wheel (sudo installer confirms drwxr-xr-x root wheel), plus a root launchd daemon — installed via the .pkg's postinstall script, which already runs with root privileges — that polls every 30 seconds and relaunches the app if it's not running for the logged-in console user. Now an uninstaller has to clear an admin password prompt on both fronts, not zero.
The correct bypass test (and the one that actually proves the daemon works, not just launchd's normal keep-alive):
launchctl bootout gui/$(id -u)/com.mihailamelin.Shoo.agent
rm -f ~/Library/LaunchAgents/com.mihailamelin.Shoo.agent.plist
killall -9 Shoo
pgrep -x Shoo confirms the process is gone immediately; ~30 seconds later it's back — this time provably via the daemon, since the launchd job itself was fully unloaded, not just its plist deleted.
One thing I had to explicitly handle: a legitimate, biometric-confirmed "Quit" shouldn't be treated as an attack. The daemon checks a marker file the app writes on an authenticated quit, and skips relaunching if it's fresh — otherwise "Quit" would look indistinguishable from being killed.
The self-update race that only showed up in production
Auto-updates go through Sparkle with installationType="package" — a separate installer process does the file swap after the user enters their admin password. Two bugs showed up only on a real, already-installed machine, not in development.
Bug 1: the bundle watchdog from layer 4 above can't tell a legitimate .pkg install from an actual attack — both replace files in the bundle the same way. Symptom: click "Install and Relaunch" in the Sparkle dialog, get a "Shoo was removed!" alert instead, which blocks the main run loop and hangs the update. Fix: a flag set at the earliest possible point — the moment the user triggers checkForUpdates(), before Sparkle touches anything — not in a Sparkle delegate callback, which isn't guaranteed to run before the installer process starts doing file I/O.
Bug 2, more interesting: after a successful update, the UI kept showing the old version number until the user manually quit and reopened the app. Two targeted fixes — a marker file for the root daemon, temporarily disabling launchd's KeepAlive during the update — both failed a live re-test.
Diagnosis that actually worked: comparing inodes, not paths.
sudo lsof -p <PID> | grep Contents/MacOS/Shoo # inode the running process has open
stat -f "%i" /Applications/Shoo.app/Contents/MacOS/Shoo # inode currently on disk
ps/pgrep only show a path string — they can't distinguish the old and new file if the path is identical after a swap. lsof's inode for the open executable can. Turned out the running process was sometimes exec()'d against an already-replaced inode: more than one relauncher (Sparkle, launchd's KeepAlive, the layer-5 daemon) can race to bring the process back up during a file swap, and "the newest process by start time" isn't always "the process actually running the newest code on disk."
Instead of chasing every individual relauncher one at a time, the fix stopped caring which one won the race:
// called ~2s after applicationDidFinishLaunching
func selfHealIfLaunchedStale() {
guard !recentlyAttemptedSelfHeal() else { return }
let inMemoryVersion = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
let onDiskVersion = readVersionDirectlyFromDisk() // bypasses Bundle's cache
guard inMemoryVersion != onDiskVersion else { return }
writeSelfHealMarker()
NSWorkspace.shared.open(Bundle.main.bundleURL) // relaunch the current file on disk
NSApp.terminate(nil)
}
If the code running in memory is older than what's actually on disk right now, relaunch from disk and get out of the way. Confirmed on a real update (1.1.1 → 1.1.2) by checking that the running process's inode matched the on-disk inode exactly — no manual restart needed, version updated itself in the UI.
What's actually novel here, and what isn't
None of the individual pieces are exotic — forceTerminate, LAContext, launchctl, PBKDF2, a shell-script daemon. What's uncommon is applying them together to get a locker with zero TCC permissions, a kill-process model instead of an overlay, and an uninstall-resistance story that survives a real uninstaller instead of just looking good in a demo. Every competitor I profiled for the previous post fails at least one of these three.
Shoo! is $17.99 once, macOS 14+, not on the App Store (the sandbox forbids terminating other processes — which is the entire mechanism above).
Happy to go deeper on any piece of this in the comments — the update race in particular took a full evening of live lsof debugging to actually nail down.
Top comments (0)