15 KiB
App Sandbox: preparation plan
Status: design and preparation only. Nothing on main is sandboxed today.
This is the plan of record for adopting the App Sandbox ahead of an App Store
build, modeled on CotEditor's approach. Facts about CotEditor were verified
against coteditor/CotEditor on 2026-07-09; facts about Edmund were surveyed
on main the same day. When a stage below lands, move its facts into
../ARCHITECTURE.md in the same PR.
1. Where Edmund stands
- No entitlements file exists anywhere in the repo.
scripts/build-app.shad-hoc signs (codesign --force --deep --sign -) with no--entitlementsargument. The app is unsandboxed by construction. Info.plistlacksLSApplicationCategoryType, which the App Store requires.- Distribution is GitHub releases plus Sparkle (
SUFeedURLpointing atappcast.xmlonmain, EdDSA-signed updates). There is no GitHub-vs-App-Store switch anywhere in the code or build scripts. - The document layer is already
NSDocument.DocumentsubclassesNSDocumentandDocumentControllersubclassesNSDocumentController(Sources/edmd/App/), so file coordination, autosave, recent documents, and sandbox-blessed access to every file the user opens or saves come with the framework. The sandbox work below is almost entirely about access outside the document system. - Several shipping features currently depend on unrestricted filesystem access. §3 inventories them.
2. The reference model: CotEditor
CotEditor sandboxes every build variant, not only the App Store one. The pieces:
- Three entitlements files, one per distribution:
CotEditor.entitlements(App Store),CotEditor-Sparkle.entitlements(notarized direct download),CotEditor-AdHoc.entitlements(local/dev). All three setcom.apple.security.app-sandbox,files.user-selected.read-write, andprint. The Sparkle variant addscom.apple.security.network.clientplus twotemporary-exception.mach-lookup.global-nameentries,$(PRODUCT_BUNDLE_IDENTIFIER)-spksand…-spki. Those are Sparkle 2's sandbox XPC services; its Info.plist also setsSUEnableInstallerLauncherService = true. - Variant selection lives in build configuration, not code structure.
Each variant's xcconfig points
CODE_SIGN_ENTITLEMENTSat its file. The Sparkle variant setsSWIFT_ACTIVE_COMPILATION_CONDITIONS = SPARKLE; the App Store variant excludesSparkle*source files outright. Updater code compiles only under#if SPARKLE. - User-supplied setting files are imported under
startAccessingSecurityScopedResource()(SettingFileManaging.swift), and CotEditor's own themes live inside the sandbox container's Application Support, so it never needs a standing grant to a home-directory folder.
What transfers to Edmund: sandbox everywhere so there is one behavior to test; Sparkle stays in the sandboxed GitHub build; a variant is nothing more than an entitlements file plus a compile flag plus a source exclusion.
What does not transfer: Edmund's themes/extensions design puts
user-managed files in ~/.edmund/ (see extensibility.md
§3), outside any container, so Edmund needs the onboarding grant described in
§4, which CotEditor never needed. CotEditor's iCloud entitlements are out of
scope for now. And Edmund is SPM plus a shell script rather than an Xcode
project, so "xcconfig" translates to build-app.sh arguments (§5).
3. Touchpoint inventory and fix map
Everything on main that breaks or needs review under the sandbox.
~/.edmund/logs(two independent computations).Log.swiftandAppSettings.logDirectoryeach build the path fromhomeDirectoryForCurrentUser. Raw home-directory access is invisible to a sandboxed process (it sees the container's home). Fix: the path-provider seam already designated inextensibility.md§4, backed by the security-scoped bookmark from the onboarding grant (§4 below). Until a grant exists, the seam falls back to a container-local log directory so logging never silently dies.- Crash reporter.
CrashReporter.swiftreads other processes'.ipsfiles from~/Library/Logs/DiagnosticReports, which no entitlement can reach; its own header already names MetricKit as the sandbox replacement. The feature is dormant (placeholder endpoint, UI commented out). Fix: swap to MetricKit if the feature ever ships; until then compile it out or no-op it in sandboxed builds. ItsURLSessionupload would additionally neednetwork.client. - Sibling images (the big one).
EditorTextView+ImageRendering.swiftresolves relative image paths against the document's folder, andDocumentHTML.swiftreads the same files to inlinedata:URIs for read mode and PDF export. Openingnotes.mdgrants access tonotes.mdonly, not todiagram.pngnext to it, so every relative image breaks under the sandbox. Options considered:- Per-folder grant (recommended). On the first sibling access that
fails, prompt once with an
NSOpenPanelpreset to the document's folder, store a security-scoped bookmark keyed by folder, reuse it for every document in that folder. This is the pattern document-based App Store editors use. - Document-scoped bookmarks stored per document: needs somewhere to put
the bookmark, which plain
.mdfiles do not offer. - Do nothing and let relative images break: unacceptable for a Markdown editor whose selling point is live preview.
- Per-folder grant (recommended). On the first sibling access that
fails, prompt once with an
- Wiki links.
EditorTextView+WikiLinks.swiftresolves a direct child and then recursively enumerates the whole document directory to find a target by name. Both need the same per-folder grant as sibling images. ProgrammaticopenDocumenton a URL the app cannot read fails rather than prompting, so resolution must happen inside the granted scope. - Absolute and tilde paths in Markdown.
~/…and/…image and link paths (ImageRendering,WikiLinks,DocumentHTML) fail outside granted scopes. Behavior: degrade gracefully (broken-image placeholder, dead link), and document that granting the containing folder revives them. No entitlement fixes arbitrary absolute paths, by design. - Rename and Move.
Document.swiftcallsFileManager.moveItemdirectly. Source (the open document) and destination (anNSSavePanelchoice) are both inside granted scopes, so this likely works, but it bypasses file coordination. Label: verify under a sandboxed build in Stage SB1; if it misbehaves, switch toNSDocument.move(to:). - Sparkle. Keep it in the sandboxed GitHub build, CotEditor-style:
network.client, the-spks/-spkimach-lookup exceptions, andSUEnableInstallerLauncherService = truein Info.plist. The App Store variant excludes Sparkle entirely:#if SPARKLEaround the import, theSPUStandardUpdaterController, and the "Check for Updates…" menu item inmain.swift;build-app.shskips embedding the framework; theSU*Info.plist keys go away. - RaTeX WASM payload (on
feat/extensions-registry-and-tab). Compatible with the sandbox, and deliberately so: App Review forbids downloaded executable code except scripts run by Apple's WebKit or JavaScriptCore (guideline 2.5.2), and RaTeX executes as WASM inside an in-processJSContext. Decision (2026-07-09): the payload runtime-downloads on both builds; the App Store entitlements gainnetwork.clientwhen Advanced Math ships there. Two verify items for Stage SB1: (a) without thecom.apple.security.cs.allow-jitentitlement, JavaScriptCore cannotMAP_JITon Apple Silicon, so RaTeX must run acceptably on JSC's interpreter-only WASM tier (if it cannot, addallow-jit); (b) the installer's Application Support path must come fromFileManager.urls(for: .applicationSupportDirectory), which resolves to the container automatically, nothomeDirectoryForCurrentUser; check at rebase time. - Read mode and PDF export. The
WKWebViews load HTML strings with images pre-inlined asdata:URIs and do no file access of their own, so they are unaffected once the inlining path (sibling images above) has access. Printing needs theprintentitlement. NSWorkspace.shared.openon external links: allowed under the sandbox, no entitlement needed.- ReproScript (DEBUG only). The sandbox blocks posting
CGEvents, which would kill the live-repro driver. Recommendation: the local dev build stays unsandboxed so the diagnostics tooling keeps working, and sandboxed builds are an explicitbuild-app.shvariant you build when testing sandbox behavior. This deliberately diverges from CotEditor's sandbox-everywhere stance; Edmund's live-repro tooling is worth the split. - Preferences migration (open question). A sandboxed app reads defaults
from its container, not
~/Library/Preferences/com.i7t5.edmund.plist. Whether macOS migrates existing preferences into the container on first sandboxed launch must be verified in Stage SB1 with a real upgrade scenario; if it does not, existing users lose settings and the migration needs handling before any sandboxed release ships. - Confirmed non-issues: no
Process/NSTask, no AppleScript, no direct pasteboard types beyond stockNSTextbehavior, no screen capture, no IOKit.
4. The ~/.edmund/ grant
The onboarding flow (planned, not yet built) asks the user to grant access
to ~/.edmund/ once:
- Explain why (themes and extensions live there, VSCode/Obsidian style).
- Present an
NSOpenPanelwithcanChooseDirectories,canCreateDirectories, anddirectoryURLpreset to the home directory, prompt text "Grant Access". The panel is the sandbox-blessed way to turn a user gesture into filesystem access, and it lets the user create~/.edmundif it does not exist yet. - Persist
url.bookmarkData(options: .withSecurityScope)in UserDefaults. - On every launch, resolve the bookmark, call
startAccessingSecurityScopedResource(), and hold the access for the app's lifetime. If resolution reports the bookmark stale, re-request via the same panel flow.
The path-provider seam is the only consumer of this bookmark. Built without the sandbox flag, the seam returns the raw home-directory URL and the grant machinery compiles out. Before a grant exists (first launch, declined onboarding), the seam falls back to container-local paths for logs and reports themes/extensions as unavailable rather than crashing or silently writing elsewhere.
Open sub-decision: whether the panel must land exactly on ~/.edmund (reject
other choices) or any folder is accepted and treated as the root. Requiring
the exact folder is simpler and matches the published docs; recorded in §7.
5. Entitlements and build mechanics
Edmund has no Xcode project, so CotEditor's xcconfig layer becomes files plus
build-app.sh arguments:
Edmund.entitlements(App Store):com.apple.security.app-sandbox,com.apple.security.files.user-selected.read-write,com.apple.security.print. Nonetwork.clientuntil crash reporting or another network feature actually ships. Advanced Math's runtime download is that feature: when it ships on the App Store build,network.clientcomes with it. CotEditor'sfiles.user-selected.executableis omitted; Edmund has no feature that needs it.Edmund-Sparkle.entitlements(GitHub build): the above plusnetwork.clientand the two mach-lookup exceptionscom.i7t5.edmund-spks/com.i7t5.edmund-spki.build-app.shgrows a variant argument (--variant adhoc|sparkle|mas, defaultadhocwhich keeps today's behavior: no entitlements, Sparkle embedded).sparkleandmaspass--entitlements <file>to the finalcodesigncall;masalso skips the Sparkle embedding block and strips theSU*keys from the copied Info.plist.- Compile flags: SPM cannot switch defines per invocation from
Package.swiftcleanly, sobuild-app.shpasses them:swift build -Xswiftc -DSPARKLEfor thesparklevariant (and today's default), nothing formas. Updater code inmain.swiftmoves under#if SPARKLE. A separate-DSANDBOXflag gates the bookmark machinery in the path-provider seam. - Info.plist: add
LSApplicationCategoryType(public.app-category.productivity) unconditionally. - Signing reality check: the sandbox itself works with ad-hoc signing,
so every stage below is locally testable (the container appears at
~/Library/Containers/com.i7t5.edmund/). Developer ID notarization for the Sparkle build and App Store Connect distribution need real certificates and are outside the repo's scope.
6. Staged plan
Each stage is a standalone future task; none is started.
- Stage SB0: path-provider seam. Collapse the duplicated
~/.edmundcomputations (Log.swift,AppSettings.logDirectory) into one provider. Identical to extensibility Stage 1's prerequisite; whichever effort runs first builds it. Pure refactor, unit-testable, no behavior change. - Stage SB1: first sandboxed build. Add the two entitlements files,
LSApplicationCategoryType, and the--variantplumbing inbuild-app.sh. Build with sandbox on, then run a manual smoke checklist: open, edit, save, autosave, rename, move, print/PDF export, read mode, relative and absolute images, wiki links, logging, the preferences-migration question from §3, and (once the extensions branch merges) RaTeX WASM execution withoutallow-jit. Record every breakage in this doc. Nothing ships from this stage; it exists to turn §3's predictions into observed facts. - Stage SB2: the
~/.edmundbookmark. Implement §4 behind-DSANDBOX: bookmark persistence, launch-time resolution, container fallback for logs. Interim UI is a "Grant Access…" button in Settings until onboarding exists. - Stage SB3: folder grants for documents. The per-folder prompt and bookmark store for sibling images and wiki links, plus graceful degradation for absolute paths. This is the stage with real UX surface; design the prompt copy with the onboarding work.
- Stage SB4: App Store variant.
#if SPARKLEexclusion, MetricKit or removal for the crash reporter, the preferences-migration answer, real signing, App Store Connect, review. Blocked on SB1 through SB3 and on actual certificates.
7. Open decisions
| Decision | Options | Status |
|---|---|---|
| Sibling-image access UX | per-folder grant prompt / per-document bookmarks / let them break | Decided 2026-07-09: per-folder grant |
| Dev (ad-hoc) build sandboxed? | yes, CotEditor-style / no, keep repro tooling alive | Decided 2026-07-09: no; sandbox testing is an explicit variant (§3, ReproScript) |
| RaTeX / Advanced Math on the App Store build | runtime download (the JavaScriptCore exception) / bundle the WASM / GitHub-only | Decided 2026-07-09: runtime download on both builds (§3) |
| Crash reporter under sandbox | MetricKit rewrite / delete the feature | undecided; feature is dormant anyway |
| Preferences migration | automatic (verify) / explicit importer | verify in SB1 before deciding |
| Grant target | exactly ~/.edmund / any user-chosen folder |
leaning: exactly ~/.edmund |
| iCloud documents | adopt CotEditor's entitlements / defer | defer |