Your tvOS Top Shelf tile is cached, and reinstalling won't clear it
The Top Shelf tile for AmbientCast, my tvOS app, started rendering a dark placeholder instead of its artwork. Everything else about the tile was fine: the title line was correct and current, and the progress indicator tracked the real job percentage. Only the image was missing. I spent an evening on it, wrote two fixes that were both wrong, and the thing that eventually worked was rebooting the Apple TV.
ELI5: what's the Top Shelf?
On an Apple TV, highlighting an app on the top row of the home screen makes a large banner appear above it. That banner is the Top Shelf, and it's the app's chance to show something live — what's playing, what's new, what's in progress — before you've opened it.
The app doesn't draw it. It hands the system a description of what should appear, and the system renders it. That gap between what you hand over and what gets drawn is the whole subject of this post.
That is an unsatisfying ending, so let me give you the useful version up front: the system caches the rendered Top Shelf tile, dedupes incoming content against that cache, and the cache survives uninstalling and reinstalling your app. If you have shipped even one build that handed the Top Shelf a bad item, you can spend hours looking at correct code that produces a wrong screen. Nothing about the symptom suggests a cache, which is exactly why it costs so much time.
What I ruled out first, and why it wasn't enough
This was a TVTopShelfContentProvider returning a single
TVTopShelfSectionedItem with imageShape = .hdtv,
built against a tvOS 17 deployment target and observed on a physical Apple
TV 4K (3rd generation) running the tvOS 27.0 public beta, build 24J5325d.
Before changing anything I checked the obvious candidates, all on device:
-
Was the extension crashing? No — no crash reports for it
at all. You can pull every crash log off a tethered Apple TV with
devicectl device copy from --domain-type systemCrashLogs, which is a fast way to answer "is this thing dying or just rendering wrong?" - Was the data stale? No. The extension reads a snapshot the app writes into a shared App Group. I pulled that container off the device and decoded it, and it was current.
-
Were the images actually in the built
.appex? Yes, both of them, at 400×225 and 800×450. - Was the extension even running? Yes, and this is the clue I misread. The tile's title and progress bar matched the live state, so the extension was running, reading its snapshot, building its item, and returning it. Only the image was absent.
That last point felt like it narrowed things down enormously. If the item renders, the extension works; if the image alone is missing, the bug must be in the image. That reasoning is what sent me off in the wrong direction, because it assumes the tile on screen was rendered from the item I was currently returning. It wasn't.
ELI5: what's an app extension, and an App Group?
An app extension is a small separate program that ships inside your app but runs on its own — here, the bit that supplies the Top Shelf banner. The system starts it when it wants content, and it's a genuinely different process from the app, with its own restrictions.
Because they're separate, they can't simply read each other's files. An App Group is a shared folder both are permitted to open, which is how the app leaves a snapshot of the current state where the extension can find it. Think of it less as "the same program" and more as two coworkers sharing one drawer.
Getting logs out of a Top Shelf extension
Before anything else got better, this did, and it's worth its own section because it applies to any tvOS extension.
print() from a Top Shelf extension reaches nobody. It goes to
stdout, and nothing is attached to that process on a real Apple TV — the
extension runs on the system's schedule when the Home screen wants content,
which is precisely when you are not sitting in Xcode with a debugger
attached. There is also no command-line path to a physical device's logs;
log stream has no device flag.
What works is os.Logger at .notice, read in
Console.app with the Apple TV tethered. Not .debug — debug-level
messages aren't persisted by default, so they'll be missing from exactly the
capture you bothered to collect. Use privacy: .public on any
interpolation you actually want to read, or you'll get
<private> where the answer should be.
let log = Logger(subsystem: "com.example.app", category: "TopShelf")
let path = url.path
log.notice("""
image=\(path, privacy: .public) \
exists=\(FileManager.default.fileExists(atPath: path), privacy: .public) \
readable=\(FileManager.default.isReadableFile(atPath: path), privacy: .public)
""")
Logging whether the file exists and is readable from this process was the specific thing I needed. If the path is readable from the extension and the tile still shows a placeholder, then either the rendering process can't reach it from its own sandbox or it's rejecting the image after reading it — two problems with completely different fixes, and no way at all to tell them apart from a screenshot.
Two things I believed about App Groups on tvOS that were wrong
My first fix was to copy the images out of the extension's bundle and into
the App Group container, on the theory that a path inside an
.appex isn't reachable from the system process that draws the
shelf. That fix didn't work, so I concluded that App Groups on tvOS share
UserDefaults and nothing else — that
containerURL(forSecurityApplicationGroupIdentifier:) just
returns nil there. I wrote that in a comment. It is false, and the device log
says so plainly:
container_create_or_lookup_app_group_path_by_app_group_identifier: success
The container resolves fine. What's refused is writing to it from an extension:
Sandbox: MyTopShelfExtension deny(1) file-write-create
/private/var/.../Shared/AppGroup/<uuid>/Library/TopShelfImage@1x.png
That surfaces in Swift as NSCocoaErrorDomain 513. So the real
rule is: an app extension can read the shared container but cannot
create files in it, while the containing app can do both. If you
need something dynamic on the shelf, the app writes it and the extension
only reads it.
ELI5: what's a sandbox, and what is "deny(1)"?
Every app on Apple's platforms runs inside a sandbox: a set of rules about which files it may touch, enforced by the operating system rather than by the app agreeing to behave. Reach outside the lines and the system simply refuses.
deny(1) file-write-create is the system saying so in its own
log — this process asked to create a file at that path and was denied.
It's useful precisely because it comes from outside your code: it proves
the request was made and rejected, rather than never happening.
Worse, my failed copy didn't fall back. It returned nil for both image URLs
when the copy failed, so several builds went to the device handing the system
image1x=nil image2x=nil — guaranteeing the blank tile the change
was supposed to fix. Remember that; it's the whole ending.
The log line that explains the rest
With the images back to plain bundle URLs and the log showing good paths going out, the tile still drew a placeholder. Then this turned up:
Skipping content update for [com.example.app] because it is unchanged
The Home screen process dedupes fetched content against its cached model. My item identifier was a constant string, and with the printer parked on the same status, a corrected item was indistinguishable from the cached one. A fix to the image alone could never reach the screen, because from the system's point of view nothing had changed.
That's a genuine trap independent of everything else here, and the fix is cheap — derive the identifier from the content you're rendering:
// Before: a constant. A corrected item looks identical to the cached one.
let item = TVTopShelfSectionedItem(identifier: "current-item")
// After: identity varies with what's actually in the item.
let identity = [title, state, imageURL?.path ?? "no-image"].joined(separator: "|")
let item = TVTopShelfSectionedItem(identifier: "current-item-\(identity)")
Including the image path is deliberate: a bundle path embeds a UUID that changes on every install, so a reinstall always produces a fresh identifier.
One trap on the way there, which I walked straight into: don't run that
string through hashValue. Swift seeds Hashable
with a value chosen randomly per process, and the documentation is explicit
that hash values "are not guaranteed to be equal across different
executions of your program." The Top Shelf extension gets launched fresh
every time the system wants content, so
identity.hashValue yields a different number on every run
whether or not anything in the item changed. It does defeat the dedupe —
but for a reason that has nothing to do with the content, which makes the
identity string you just assembled pure decoration. Worse, it's the kind of
accident that stops being true the moment someone swaps in a stable hash
and quietly restores the caching you were trying to escape. Use the string
itself; identifiers are not required to be short.
And it still didn't fix it. Good paths, fresh identifier, placeholder tile.
What actually cleared it
Rebooting the Apple TV. Instantly, with no code change.
The cached rendering was one built while my own commits were returning nil image URLs. Once the Home screen had cached a model with no images, nothing displaced it: not a corrected build, not a changed item identifier, not uninstalling and reinstalling the app. It kept faithfully drawing a tile from builds ago for hours, while every diagnostic I could run said the current code was correct — which it was.
One honest caveat, because it's the part I can't close: my nil-URL commits explain why it stayed broken all evening, but the tile was already showing a placeholder before I wrote them, which is why I opened the bug in the first place. I never isolated what poisoned the cache originally. The most I can say is that it was a day of repeated installs, and that a reboot cleared whatever it was.
What I'd tell you to check first
If a Top Shelf change doesn't seem to take effect, reboot the device before you doubt the code. It costs a minute. Doubting the code cost me an evening and produced two commits that made things worse.
The general shape is worth carrying past tvOS. I had a cache I didn't know existed, sitting between correct code and a wrong screen, and every instrument I had was pointed at the code. All my logging measured what the extension sent. Nothing measured what the system drew, and the two had quietly stopped being the same thing. When a fix that should work changes nothing at all — not the symptom, not even its shape — stop refining the fix and start asking whether your output is reaching the renderer.
And when you're debugging blind, don't let a broken build ship a null. A fallback that returns yesterday's correct value is annoying. A null gets cached somewhere you can't see, and then you're debugging your own debugging.