← Back to MarkeMark

How MarkeMark was built.

An honest account of shipping a native macOS app — and its Quick Look extension, print engine, and website — with Claude Code on the other side of the terminal.

MarkeMark is a free, native macOS markdown editor. It previews .md files in Finder when you press Space, prints them with multi-column reflow, and lets you edit either the rendered view or the raw source. It's one DMG. No account, no upsell.

I'm Adam — a solo developer, working mostly on weekends. Every line of code that ships in MarkeMark was written through Claude Code, Anthropic's terminal coding agent. The Swift app, the Quick Look extension, the Core Image pipeline that handles inline embeds, the bash scripts that sign and notarize the DMG, this website — all of it. I want to tell you what that was actually like, because the common framing — "AI writes the code, developer presses enter" — is not what happened.

The boring stuff Claude Code handled on its own

There's a category of work in a native Mac project that is pure ceremony — codesigning incantations, entitlements plists, notarytool submissions, xcodebuild flags, pluginkit cache invalidations. Important, nontrivial, but also deeply Googleable and repetitive. Claude Code ate this work without a fight.

The release pipeline — build the archive, export a Developer ID app, re-sign both the main bundle and the Quick Look extension with their own entitlements files to defeat Xcode's silent entitlement merge (more on that below), wrap it all in a notarized DMG, submit, staple, hash, and name the output correctly — is a single ./release-app.sh invocation now. Claude wrote every step of it by reading Apple's docs and my last three failed attempts, and I've stopped thinking about it. Same for ./release-website.sh: regenerates version.json from the top of CHANGELOG.md, syncs the changelog page, and invalidates the Vercel edge cache on push.

The SwiftUI bits that are just bindings-and-views work — the find bar, the print preview sheet, the three-way view-mode switcher, the keyboard shortcut surface area, the welcome sheet on first launch — were almost entirely Claude-driven. I'd describe what I wanted and get a working draft in two or three iterations. These are the cases where pairing with an AI feels closest to the pitch: fast, competent, low-friction.

Where I had to steer

The hard problems in this codebase are almost all about two sandboxed processes — the main app and the Quick Look extension — trying to share state. Every time that architecture came up against Apple's sandbox model, Claude and I had to actually collaborate, and the collaboration looked like me pushing back.

One example: for a long stretch, inline images only worked in the main editor, not in Quick Look. The natural-sounding fix — have the main app grant the QL extension a security-scoped bookmark to the user's vault folder — doesn't work. Security-scoped bookmarks created by a sandboxed process bind to the creating process's identity, not the team identity, even with matching application-groups and matching Team ID. The QL extension receives the bookmark blob and throws NSCocoaErrorDomain Code=256 on resolve. Apple's docs imply this should work. In practice it doesn't, and no amount of entitlements tweaking fixes it.

Claude's instinct, reasonably, was to keep debugging the entitlements. My instinct — after watching three hypotheses all fail inside the same architectural frame — was to question the frame. We ripped the bookmark approach out and replaced it with an app-group container: the main app, which has legitimate file access, copies image bytes into ~/Library/Group Containers/<teamID>.<id>/, and the QL extension reads from there. Both sandboxes have read access to group containers by design. This worked on the first try. The diff was fifty lines; the research and debugging that made those fifty lines possible was a full weekend.

That became a recurring pattern: the "right" architectural move was usually not the first move Claude proposed, and getting there required me to say, stop debugging the surface, list alternative mechanisms. Claude is great at executing within a frame. The humans in the loop still choose frames.

The entitlements drift incident

Here's a specific debugging story that took a long time and taught me something I'll carry forever.

I had removed the files.user-selected.read-write entitlement from the main app's .entitlements file. Rebuilt. Re-exported. Re-signed. The exported .app, inspected with codesign -d --entitlements -, still had the entitlement. Cleaned DerivedData. Still there. Built on a fresh checkout. Still there.

It turned out that xcodebuild -exportArchive with automatic signing merges the project's .entitlements file with the App ID's capabilities registered at developer.apple.com. Entitlements I had removed locally silently reappeared in the final .xcent and the signed binary. New builds repeated the merge. There was no failure — just a quiet reinstatement.

The fix is to re-sign each target after export, bottom-up, with an explicit --entitlements argument pointing at your actual .entitlements file: the extension first, then the main app last so the app's seal includes the re-signed extension. I fold that into release-app.sh now. The bug took about six hours the first time; the write-up took ten minutes. Future me, via Claude's memory system, inherits the ten-minute version.

"The sandbox is silently required for printing"

Similar story, different surface: printing from a sandboxed Mac app requires the com.apple.security.print entitlement. If it's missing, every print API — WKWebView.printOperation, PDFDocument.printOperation, NSPrintOperation(view:), custom NSView paginators, in-memory or disk-round-tripped PDFs — fails with the identical error: "This application does not support printing."

That error message reads like a code-level bug. It sounds like your view hierarchy is wrong, or your responder chain is broken, or you're calling a deprecated API. It is none of those things. It's an entitlement. We burned most of an evening on this one before I noticed the pattern: every print API fails the same way, with the same string, regardless of how we approach it. That's not a code bug — that's a capability gate. Once we added com.apple.security.print to the entitlements, every print path lit up.

The lesson that generalised: in a sandboxed Apple app, when a whole class of APIs fails identically regardless of how you call them, check the entitlements file before you touch any code.

Build 7, Build 9 — and why we sandboxed the app again

MarkeMark v1.6 Build 7 dropped the sandbox on both the main app and the Quick Look extension. The goal was simpler: no more "Grant Folder Access" banner, no more bookmark dance, the app just reads files like a pre-sandbox Mac app would.

Build 7 shipped. Then users reported Quick Look was broken — Space on a .md file fell through to the plain-text system generator. No preview.

It turned out that macOS's Quick Look preview extension host silently refuses to register unsandboxed .appex's. pluginkit -m -p com.apple.quicklook.preview omitted MarkeMark entirely. The LaunchServices record carried no plugin Identifiers: line. Every third-party Quick Look preview extension installed on my machine was sandboxed; MarkeMark post-Build-7 was the only outlier. The sandbox drop that was supposed to simplify things had broken the flagship feature.

Build 9 re-sandboxed both targets. Build 10 found the middle path: sandbox stays off on the main app (so it can read sibling images without a bookmark) and on in the Quick Look extension (so pluginkit registers it). The main app, on opening any document, walks the folder and pre-caches every image into the app-group container. The sandboxed QL extension reads from the cache.

This is the kind of design that only reveals itself after three different architectures have failed. I did not design it in advance. Neither did Claude. We arrived at it by shipping, breaking, and pivoting — six DMGs in about forty-eight hours.

What surprised me

Three things.

The memory matters more than the model. What Claude Code can do in a single session is impressive. What it can do across sessions, when you've written down rules like "Before building anything that touches system APIs, web search for the current version's actual behavior first" or "When inheriting a failed debug session, run one quick independent test before buying the framing," is an order of magnitude better. The rules turn individual debugging wins into reusable knowledge. I now spend real effort curating that memory.

Good prompts are a small fraction of the work. The work is clarifying what you actually want, describing constraints you didn't realise were constraints, and noticing when an answer is technically correct but structurally wrong. Claude Code is extraordinarily good at executing — but executing the wrong plan faster is a worse outcome than it sounds.

Testing still belongs to me. Claude will tell you a build succeeded. It will tell you tests passed. It will not tell you that qlmanage -t produces the Text.qlgenerator thumbnail regardless of whether your app extension registered, because the legacy thumbnail API doesn't invoke App Extension previews in headless sessions. I learned that the hard way. I ship nothing now without a Finder Spacebar test done by a human — me. That gate is in the project's CLAUDE.md and every agent respects it.

The stack

  • Swift + SwiftUI, targeting macOS 13+. HSplitView for the split pane, NSTextView via NSViewRepresentable for the editor (SwiftUI's TextEditor is not enough), WKWebView for the preview.
  • Custom regex-based markdown renderer (MVP) plus bundled highlight.js 11.11.1 for offline syntax highlighting.
  • Quick Look extension as a sibling App Extension target, sandboxed, sharing state with the main app through an app-group container.
  • Print / PDF engine using WKWebView's printOperation and a custom multi-column CSS layout.
  • Developer ID signing + notarization, driven by release-app.sh. Distribution via DMG from this site — no App Store (the sandbox trade-off rules that out for now).
  • Cloudflare Registrar for the domain, Vercel for the website, git-push-to-deploy.

If you want to try it

Grab the latest DMG from the homepage. It's free, signed, notarized, and offline — no tracking, no updates-until-you-install-them. If you build Mac software or write Markdown by volume, I hope it saves you five seconds a day forever.

And if you're curious about Claude Code itself: it's a CLI tool from Anthropic, free to install, and in my experience the fastest way currently available to go from a weekend project idea to notarized, shipping software on macOS. It won't write the product for you. It will write every line of code once you know what you want.

— Adam