<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Doan Nguyen</title>
    <description>The latest articles on DEV Community by Doan Nguyen (@nguyendinhdoan).</description>
    <link>https://dev.to/nguyendinhdoan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4065487%2F6492f0cc-7b5c-4843-beec-c8774f5c405d.jpg</url>
      <title>DEV Community: Doan Nguyen</title>
      <link>https://dev.to/nguyendinhdoan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nguyendinhdoan"/>
    <language>en</language>
    <item>
      <title>Debugging a Markdown Quick Look extension: signing, file changes, and WKWebView</title>
      <dc:creator>Doan Nguyen</dc:creator>
      <pubDate>Thu, 06 Aug 2026 13:32:11 +0000</pubDate>
      <link>https://dev.to/nguyendinhdoan/debugging-a-markdown-quick-look-extension-signing-file-changes-and-wkwebview-2429</link>
      <guid>https://dev.to/nguyendinhdoan/debugging-a-markdown-quick-look-extension-signing-file-changes-and-wkwebview-2429</guid>
      <description>&lt;p&gt;macOS still previews Markdown as raw text. Press Space on a README in Finder and you get hashes, pipes and backticks. I wanted to just read the thing, so I wrote a Quick Look extension. It turned into a full app eventually (QuickMark, and yes, I'm the developer, so take the product mentions with that in mind).&lt;/p&gt;

&lt;p&gt;Four things cost me real evenings. None of them were where I expected to find them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-signing after the build silently killed my extension
&lt;/h2&gt;

&lt;p&gt;This one is nasty because nothing errors.&lt;/p&gt;

&lt;p&gt;My install step used to be: build, copy the &lt;code&gt;.app&lt;/code&gt; into &lt;code&gt;/Applications&lt;/code&gt;, then run a &lt;code&gt;codesign&lt;/code&gt; pass over it for good measure. After that, Quick Look would show the plain text preview again, exactly as if my extension did not exist. No crash, no log in Xcode, no dialog.&lt;/p&gt;

&lt;p&gt;I no longer have the exact invocation, and I want to be precise about that rather than hand you a rule I cannot back up. A plain &lt;code&gt;codesign --force&lt;/code&gt; on the outer bundle does not normally touch nested code, so mine was either re-signing the whole tree with &lt;code&gt;--deep&lt;/code&gt; (which Apple tells you not to do, and recommends signing nested components individually, inside out) or re-signing without handing over the extension's entitlements. Either way the outcome was the same: the appex came out the other side without its sandbox entitlement.&lt;/p&gt;

&lt;p&gt;The build's own signing pass had already signed both the app and the embedded appex correctly. Everything I did afterwards only made it worse. &lt;code&gt;pkd&lt;/code&gt; then refuses to load the extension and falls back to the system text preview, quietly.&lt;/p&gt;

&lt;p&gt;You only see it if you go looking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;log show --predicate 'process == "pkd"' --last 5m
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it was: &lt;code&gt;rejecting; ... plug-ins must be sandboxed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Useful companion command, to check whether your extension is even a candidate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;pluginkit -m -p com.apple.quicklook.preview -A | grep -i yourapp
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your extension is missing from that list, it is not currently a candidate, which can mean it never registered, or it registered and is disabled, or it is installed somewhere the system does not scan. If it is present but Quick Look ignores it, you may be losing a priority fight to another installed extension for the same UTI. In my case that is what it was, another Markdown Quick Look extension in &lt;code&gt;/Applications&lt;/code&gt; out-ranked a fresh build, and uninstalling it fixed things. I never found a way to see or influence the ranking, so treat that as one case report, not an algorithm.&lt;/p&gt;

&lt;h2&gt;
  
  
  I chose the worse syntax highlighter on purpose
&lt;/h2&gt;

&lt;p&gt;I started with Shiki. TextMate grammars, genuinely more accurate tokenizing, output that matches what VS Code shows you. It is the better highlighter and I am not going to pretend otherwise.&lt;/p&gt;

&lt;p&gt;I ripped it out and went back to Prism.&lt;/p&gt;

&lt;p&gt;Cold render went from roughly 200 to 400 ms down to about 50 to 100 ms. Those numbers are wall clock from opening the panel to first paint, on an Apple Silicon Mac, against my own pile of README files, eyeballed over a handful of runs. It was not a benchmark harness. Take it as the shape of the gap, not a figure to quote.&lt;/p&gt;

&lt;p&gt;The shape was enough. For most apps a couple hundred milliseconds is a rounding error. For a Quick Look panel it is the entire feature. The promise is that you tap Space and the document is there. A third of a second of blank panel breaks that promise, and no amount of grammar accuracy buys it back.&lt;/p&gt;

&lt;p&gt;There was a second win, and I need to state it carefully. Prism emits CSS classes like &lt;code&gt;token.keyword&lt;/code&gt;, which I resolve through CSS variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"dark"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--syntax-keyword&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ff7b72&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.token.keyword&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--syntax-keyword&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So when the system flips between light and dark, the theme changes with no re-render and no re-tokenize. In the Shiki setup I had, colors were baked into inline styles at highlight time, so a theme switch meant highlighting the document again. That was my configuration, not a limit of the library. Shiki supports dual themes and CSS-variable output that avoid exactly this. I simply had not reached for them, and with Prism I got the behaviour without having to think about it.&lt;/p&gt;

&lt;p&gt;The lesson I took: pick the tool that is best at your actual constraint, not the one that is best in general. Mine was time-to-first-paint, not correctness.&lt;/p&gt;

&lt;p&gt;While I was measuring things, I also made Mermaid a lazy import. It is around 600 KB and most documents have no diagrams in them, so loading it on every render was pure waste. One check for a &lt;code&gt;mermaid&lt;/code&gt; fence, then &lt;code&gt;await import()&lt;/code&gt; only if there is one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some editors save files by renaming them
&lt;/h2&gt;

&lt;p&gt;My file watcher worked perfectly. Then a friend tried it with Vim and the preview stopped updating.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NSFilePresenter&lt;/code&gt; gives you &lt;code&gt;presentedItemDidChange()&lt;/code&gt;, which fires when someone writes to the file in place. Plenty of editors do that. Vim and Helix, by default, do not. They write a temp file and rename it over the original, because an atomic replace cannot leave you with a half-written file if the editor dies mid-save. Vim's exact strategy depends on &lt;code&gt;backupcopy&lt;/code&gt; and on what the filesystem supports, so this is a common default rather than a law, which is precisely why it is easy to miss during testing.&lt;/p&gt;

&lt;p&gt;A rename is not a change. It fires a different callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;presentedItemDidChange&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;presentedItemDidMove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;newURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both go to the same place. Miss the second one and your app looks broken to precisely the users most likely to try it, which is a special kind of unlucky.&lt;/p&gt;

&lt;p&gt;One caveat on that snippet, because it is incomplete as general advice. &lt;code&gt;presentedItemDidMove(to:)&lt;/code&gt; is really telling you the item moved, and Apple expects a presenter to update its &lt;code&gt;presentedItemURL&lt;/code&gt; when that happens. I ignore &lt;code&gt;newURL&lt;/code&gt; on purpose: in the atomic-save case the replacement lands back at the path I was already watching, so re-reading the original URL is the correct thing. If you also need to follow a genuine move to a new location, you have to store the new URL, and my two-line version will not do that for you.&lt;/p&gt;

&lt;p&gt;One more detail worth stealing: debounce before you re-read. I wait 80 ms and then read through &lt;code&gt;NSFileCoordinator&lt;/code&gt;. The coordinated read is the part that actually synchronises, and only against writers that coordinate too. The delay is a heuristic on top, because without it you sometimes catch a non-coordinating writer mid-flush and render a truncated document for a frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  callAsyncJavaScript exists for a reason
&lt;/h2&gt;

&lt;p&gt;My renderer's entry point is async. So I called it the obvious way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;webView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluateJavaScript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"window.QuickMark.render(md)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And got &lt;code&gt;WKErrorJavaScriptResultTypeIsUnsupported&lt;/code&gt;, which reads as "JavaScript execution returned a result of an unsupported type". Not an obviously helpful message when your JS clearly worked.&lt;/p&gt;

&lt;p&gt;The cause: an async function returns a Promise, and &lt;code&gt;evaluateJavaScript&lt;/code&gt; cannot serialize a Promise across the bridge. It is not waiting for anything. It grabs the return value immediately and gives up on it.&lt;/p&gt;

&lt;p&gt;The fix is a different API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;js&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"await window.QuickMark.render(&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="kt"&gt;JSEscape&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;); return null;"&lt;/span&gt;
&lt;span class="n"&gt;webView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;callAsyncJavaScript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;js&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;callAsyncJavaScript&lt;/code&gt; runs the string as an async function body, so the &lt;code&gt;await&lt;/code&gt; in there is what actually waits. &lt;code&gt;return null;&lt;/code&gt; is not doing the waiting; it is only there to hand the bridge a value it can serialize, so you do not trade one unsupported-type error for another.&lt;/p&gt;

&lt;p&gt;Note what that snippet makes you responsible for. Interpolating the document into the source string means you own the escaping, which in my case goes through a JSON-escape helper. &lt;code&gt;callAsyncJavaScript&lt;/code&gt; also takes an &lt;code&gt;arguments&lt;/code&gt; dictionary that passes values in as named variables, which is the cleaner option and the one I would reach for first if I were writing this today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Three of these four have the same shape: the failure is silent, and the real signal lives in a log or an API I had no reason to know about yet. That is most of what macOS extension development felt like. The code is not hard. Finding out what went wrong is.&lt;/p&gt;

&lt;p&gt;If you want to see how the renderer behaves, it runs in the browser with nothing to install: &lt;a href="https://quickmarkmd.com/preview" rel="noopener noreferrer"&gt;https://quickmarkmd.com/preview&lt;/a&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>swift</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
