Shipping a libmpv-based video player on the Mac App Store
Most well-known mpv-based Mac players, including IINA, distribute outside the Mac App Store. When I built Reel, a local-video player/library app for macOS, I wanted to know why, and whether it could be done. It can. It took a month from first commit to approval, and almost none of the hard parts were about playing video.
This is a field report of every wall I hit: a JIT crash that only happens under the App Store's rules, LGPL compliance with static linking, two sandbox traps that pass locally and fail later, and one design rejection. If you're putting any FFmpeg/libmpv-family library into a sandboxed Mac app, this should save you a week or two.
Setup: Swift / SwiftUI app. Playback is AVFoundation plus libmpv (via MPVKit, statically linked, LGPL build) for containers where AVFoundation isn't enough, such as mkv, webm, and avi. Distribution: Mac App Store, sandboxed, with StoreKit 2 for a tip jar.
Wall 1: LuaJIT inside libmpv gets your process killed — only on the App Store build
During development (sandbox off), libmpv worked perfectly. The moment I built with the Mac App Store configuration — App Sandbox plus Hardened-Runtime-style entitlement restrictions — the process died inside mpv_initialize(), before playing anything.
The culprit is the pair of JIT entitlements:
-
com.apple.security.cs.allow-jit— allowed on the Mac App Store -
com.apple.security.cs.allow-unsigned-executable-memory— not allowed on the Mac App Store
libmpv statically links LuaJIT, and LuaJIT's machine-code allocator requests plain RWX executable memory rather than MAP_JIT memory. With only allow-jit, AMFI kills the process.
I confirmed it with an A/B test on the actual signed builds:
| Entitlements on the binary | mpv_initialize() |
Result |
|---|---|---|
allow-jit only (the most MAS permits) |
SIGKILL (exit 137) | AMFI kills the process |
allow-jit + allow-unsigned-executable-memory (dev config) |
succeeds, playback works | fine — but unshippable |
"Just disable Lua scripts" does not work
My first hope was configuration: load-scripts=no, osc=no, ytdl=no, config=no. No effect. LuaJIT's allocator grabs executable memory during init regardless of whether any script will ever run. If LuaJIT is linked in, you crash.
The fix: rebuild libmpv with Lua disabled
Reel doesn't use mpv's Lua scripting at all — no OSC, no user scripts. So the fix was a one-line change to the mpv build:
- -Dlua=luajit
+ -Dlua=disabled
A script applies this and rebuilds Libmpv.xcframework. Verification:
| libmpv build | Entitlements | mpv_initialize() |
|---|---|---|
| standard (LuaJIT) |
allow-jit only |
SIGKILL 137 |
| Lua disabled |
allow-jit only |
succeeds, playback works |
- Lua symbols in the rebuilt xcframework: 0 (previously ~50 undefined LuaJIT references)
- All the mpv API I use (
mpv_create,mpv_initialize,mpv_render_context_create, …) intact — zero functional loss for this app
A pleasant side effect: dropping LuaJIT removed the need for allow-unsigned-executable-memory and disable-library-validation entirely, so the final signature is tighter than the dev build ever was. The shipping entitlements file is now just: app-sandbox, network.client (StoreKit), files.user-selected.read-only, files.bookmarks.app-scope, and cs.allow-jit.
Lesson: if you statically link any library that JITs (LuaJIT, some JS engines, …), test against App Store entitlement rules first. And ask whether you actually use the feature that needs the JIT — the cheapest fix may be compiling it out.
Wall 2: LGPL compliance with static linking
libmpv and its FFmpeg dependencies are LGPL (I use MPVKit's LGPL variant — no GPL components, no x264/x265, so there's no App Store incompatibility at the license level). But static linking triggers the LGPL's re-linking obligation: users must be able to relink the application against a modified version of the library.
There are three classic ways to satisfy it. Dynamic linking doesn't get you anything on the Mac App Store (users can't swap a dylib inside a signed, store-installed bundle anyway) and would have meant restructuring the build, so I went with a written offer, valid for three years:
- An in-app Licenses window (App menu → Licenses…) listing every library, its license, and upstream source URL, with the full LGPL texts bundled in
Reel.app/Contents/Resources - The written offer text included in the App Store description's notes: on request, I provide the machine-readable object code of the app plus linked libraries, sufficient to relink against a user-modified library
- A repo script that reproduces my only modification to the libraries (
-Dlua=disabled), and another script that collects the.ofiles and link inputs from the exact release build into an archive for anyone who requests it
Not legal advice — this is the reading I implemented, and it passed review. The parts most people forget: keep the build inputs of the shipped release around for the duration of the offer, and remember the offer must ship with the binary (store listing + in-app), not just sit in your repo.
Wall 3: two sandbox traps that pass locally and fail later
Playing files the user picked, across relaunches, needs security-scoped bookmarks. The happy path is well documented:
NSOpenPanel → url.bookmarkData(options: .withSecurityScope)
next launch → resolve → startAccessingSecurityScopedResource()
The traps are not.
Trap A: the bookmark entitlement key has a decoy spelling
The correct key is:
com.apple.security.files.bookmarks.app-scope
The variant without files. (com.apple.security.bookmarks.app-scope) signs and runs locally just fine — and is then rejected by App Store submission validation as an unsupported entitlement. Everything works on your machine, so nothing warns you until you upload.
Trap B: a dropped folder's security scope dies with the closure
Shipped in 1.0, added folder drag-and-drop in 1.1, and hit this: opening a folder from Finder/Dock (.onOpenURL) worked, but dropping the same folder onto the window silently did nothing — drop highlight appears, nothing imports.
Debug logging showed the URL was arriving. The failure chain:
-
NSItemProvider.loadObject(ofClass: URL.self)→ returns nil for Finder file URLs -
loadItem+URL(dataRepresentation:)→ yields a URL without a security scope;startAccessingSecurityScopedResource()returns false and a laterfileExistscheck quietly fails - Root cause: the scoped URL from
loadInPlaceFileRepresentationis only valid until that closure returns. I was hopping to the main actor withTask { @MainActor in … }— by the time the task ran, the scope was gone.
The fix: mint the security-scoped bookmark inside the closure, while the scope is alive, and pass the bookmark bytes onward:
provider.loadInPlaceFileRepresentation(forTypeIdentifier: UTType.folder.identifier) { url, _, _ in
guard let url else { return }
let ok = url.startAccessingSecurityScopedResource()
defer { if ok { url.stopAccessingSecurityScopedResource() } }
// The scope is alive *now* — capture it as a bookmark before leaving the closure.
let bookmark = try? url.bookmarkData(options: .withSecurityScope,
includingResourceValuesForKeys: nil, relativeTo: nil)
Task { @MainActor in store.importDroppedBookmark(bookmark, fallback: url) }
}
The asymmetry that makes this hard to spot: URLs from .onOpenURL come via LaunchServices and their scope persists, so the "same" feature works through one door and not the other.
Wall 4: App Review — Guideline 4, the un-reopenable main window
First 1.0 submission was rejected under Guideline 4 (Design): close the main window with the red button and there's no menu item to get it back. Fair.
The fix:
- Made the main window a dedicated single
Windowscene (notWindowGroup), so the system lists it in the Window menu and reopening works - Added an explicit Window → Main Window (⌘0) item that also brings it forward when a player window covers it
- The app keeps its menu bar alive with the main window closed
In the resubmission notes I wrote exactly what changed and how I verified it ("clean build; close main window with the red button; ⌘0 reopens it"). Plain reproduction steps, no argument. Approved.
One postscript: an older branch still had the WindowGroup implementation, and I later nearly merged it back over the reviewed fix. Whatever implementation passed review is the canonical one — guard it.
Smaller things that also cost time
- You can't attach a new build to an approved version. New changes require a new version number. I learned this trying to add drag-and-drop to the already-approved 1.0 → it shipped as 1.1.
- App Store screenshots must have no alpha channel (macOS: 2880×1800). Screen captures have alpha; converting to JPEG kills it reliably. Also, a plain UI dump looks like nothing at search-result thumbnail size — I compose the first screenshot (headline, rounded corners, shadow, dark background) with a small Python/PIL script over real captures.
-
String Catalogs: only strings that go through
LocalizedStringKeyare auto-extracted, andxcodebuildwon't write keys back into the catalog for you. Budget manual passes.
The checklist I wish I'd had
- Statically linking anything that JITs? Test
mpv_initialize()(or equivalent) under App Store entitlements, not your dev entitlements.allow-unsigned-executable-memoryis not available to you. - Do you actually use the JIT-dependent feature? Compiling it out may be the whole fix.
- LGPL + static linking → written offer + relink materials + bundled license texts, and keep the release's build inputs.
- Bookmark entitlement key is
com.apple.security.files.bookmarks.app-scope— thefiles.-less spelling only fails at submission. - Security-scoped URLs from drag-and-drop die with the provider closure — bookmark them in place.
- Close your main window with the red button. Can a reviewer get it back from the menu bar?
- Approved version = new build needs a new version number.
- Screenshots: no alpha, and make the first one legible at thumbnail size.
Building the player was the easy part. Getting it — safely, license-compliantly, review-provenly — into people's hands is where the month went. But the walls are all climbable, documented above, and mostly one-time costs.
Reel, a local video player for macOS, is available on the Mac App Store. Happy to answer questions about any of the above.
Top comments (0)