One day my app started triggering this dialog whenever it opened the user's home folder:
"Unifyl" would like to access your Photos library.
The app is a file manager. It contains zero photo-related code. It does not link the Photos
framework, it never touches .photoslibrary bundles, and the size-calculation code that was
running at the time deliberately skips ~/Pictures. Yet macOS was certain.
This post is about the debugging technique that finally answered the only question that matters in this situation — which path did I touch? — because Apple's privacy subsystem will tell you everything except that.
Privacy prompts come from tccd. You can watch it live:
log stream --predicate 'subsystem == "com.apple.TCC"' --info
This gives you a lot: the service being requested (kTCCServicePhotos), your bundle id, and
whether the event is a mere status check or a real prompt. Two things I learned reading this
stream for hours:
preflight=yes are status queries — they never produce a dialog. Chasing
them is a dead end. The actual prompt is the preflight=no request that escalates to
AUTHREQ_PROMPTING.So I knew what was being requested. I had no idea why. My app walks directories in a
background queue to compute folder sizes; any one of thousands of paths could have been the
trigger, and my mental list of "media-protected locations" — ~/Pictures,
*.photoslibrary — was apparently wrong. Auditing the skip-list and finding it correct proves
the list, not the bug.
Here is the useful property of TCC prompts: while the dialog is on screen, the syscall that
triggered it does not return. The offending thread is sitting blocked inside
open(), and on arm64 the first argument to a syscall — the path pointer — is still in
register x0, exactly where the calling convention put it.
So, with the dialog up, don't dismiss it. Attach a debugger instead:
lldb -p $(pgrep -x Unifyl)
(lldb) thread list
# ... several threads with frame #0 = libsystem_kernel.dylib`__open ...
(lldb) thread select 9
(lldb) memory read -f s $x0
0x16dae5a30: "/Users/me/Library/Photos"
I had four worker threads parked in __open. I read x0 as a C string on each of
them. All four gave the same answer:
/Users/me/Library/Photos
~/Library/Photos. Not the .photoslibrary bundle in ~/Pictures — a plain,
ordinary-looking directory under ~/Library that turns out to hold "Shared with You"
photo content, and which macOS therefore guards with kTCCServicePhotos, the same
protection as the photo library itself. I have not found it in any Apple documentation of
photo-library locations. My background walk was faithfully descending into it, and the first
open() inside raised the prompt.
The fix was one line of policy: treat ~/Library/Photos exactly like
~/Pictures — never enter it uninvited during background size walks.
Two practical notes that cost me time:
TCC remembers by code identity. Once you click "Don't Allow", subsequent runs won't
prompt — they are silently denied, and your reproduction is gone. Resetting with
tccutil reset Photos com.your.bundle.id works, but during a long session it is faster to
re-sign a throwaway copy ad hoc: codesign -f -s - --deep gives the copy a fresh identity,
so every rebuild starts with a clean TCC slate. Ad-hoc signing strips entitlements, so re-add
com.apple.security.get-task-allow if you still want to attach lldb to it.
The unified log buffer rolls fast. If you go looking for the tccd entry after the fact, it may already be gone. Stream it live into a file while you reproduce.
__open, __openat, getattrlist, opendir.x0, or x1 for the
*at variants' path).The register does not speculate. Four threads, one answer, bug closed — after weeks of guessing had produced nothing but a well-audited, wrong skip-list.