From 8d31af4658af64e381c22751686729ac9c839c4c Mon Sep 17 00:00:00 2001 From: Santiago Barros Date: Fri, 10 Jul 2026 10:54:40 -0600 Subject: [PATCH 01/11] Replace the existing bundle on install instead of merging over it A stale copy merged over by ditto keeps files the new bundle no longer ships, which breaks the code-signature seal and makes Gatekeeper reject the app. --- install.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/install.sh b/install.sh index bce0be4..47b89d9 100755 --- a/install.sh +++ b/install.sh @@ -30,6 +30,8 @@ main() { fi mkdir -p "$INSTALL_DIR" + # A stale bundle merged over breaks the code-signature seal; replace cleanly. + rm -rf "$TARGET_APP" ditto "$DIST_APP" "$TARGET_APP" "$LSREGISTER" -f "$TARGET_APP" >/dev/null From 459e8a7df133c9078c195eb87b5f2367568849eb Mon Sep 17 00:00:00 2001 From: Santiago Barros Date: Fri, 10 Jul 2026 10:55:22 -0600 Subject: [PATCH 02/11] Make the update check user-initiated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README promises no network calls, but the app contacted the GitHub releases API on every launch. The check now runs only from a new "Check for Updates…" menu item, with explicit feedback for up-to-date and unreachable cases. --- README.md | 2 +- src/main.m | 44 +++++++++++++++++++++++++++++++------------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ad209e2..53cb09c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ MDviewer is different: - **Secure** — HTML sanitized with [DOMPurify](https://github.com/cure53/DOMPurify), strict Content Security Policy - **Finder integration** — registers as default `.md` handler; double-click to open - **Tabbed windows** — multiple documents in one window -- **Local-first** — no network calls, no telemetry, no accounts +- **Local-first** — no telemetry, no accounts, and no network calls except the update check you trigger yourself from the menu ## Install diff --git a/src/main.m b/src/main.m index 1b63ece..ab35f28 100644 --- a/src/main.m +++ b/src/main.m @@ -569,6 +569,13 @@ - (void)installMainMenu { [appMenu addItem:aboutItem]; [appMenu addItem:[NSMenuItem separatorItem]]; + NSMenuItem *updatesItem = [[NSMenuItem alloc] initWithTitle:@"Check for Updates…" + action:@selector(checkForUpdates:) + keyEquivalent:@""]; + updatesItem.target = self; + [appMenu addItem:updatesItem]; + [appMenu addItem:[NSMenuItem separatorItem]]; + NSMenuItem *hideItem = [[NSMenuItem alloc] initWithTitle:[NSString stringWithFormat:@"Hide %@", appName] action:@selector(hide:) keyEquivalent:@"h"]; @@ -722,10 +729,11 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification { if (!self.openedFileDuringLaunch) { [self openDocument:nil]; } - [self checkForUpdates]; } -- (void)checkForUpdates { +// Only ever runs when the user picks "Check for Updates…" — the app makes no +// network requests on its own. +- (void)checkForUpdates:(id)sender { NSString *currentVersion = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"0.0.0"; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:MDVReleasesURL]]; @@ -733,20 +741,30 @@ - (void)checkForUpdates { request.timeoutInterval = 10; [[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { - if (error || !data) return; - NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; - if (httpResponse.statusCode != 200) return; - - NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - NSString *tagName = json[@"tag_name"]; - if (![tagName isKindOfClass:NSString.class]) return; - - NSString *latestVersion = [tagName hasPrefix:@"v"] ? [tagName substringFromIndex:1] : tagName; - if ([latestVersion compare:currentVersion options:NSNumericSearch] != NSOrderedDescending) return; + NSDictionary *json = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; + NSString *tagName = [json[@"tag_name"] isKindOfClass:NSString.class] ? json[@"tag_name"] : nil; dispatch_async(dispatch_get_main_queue(), ^{ NSAlert *alert = [[NSAlert alloc] init]; + + if (error || httpResponse.statusCode != 200 || !tagName) { + alert.messageText = @"Could not check for updates"; + alert.informativeText = @"The releases page could not be reached. Please try again later."; + [alert addButtonWithTitle:@"OK"]; + [alert runModal]; + return; + } + + NSString *latestVersion = [tagName hasPrefix:@"v"] ? [tagName substringFromIndex:1] : tagName; + if ([latestVersion compare:currentVersion options:NSNumericSearch] != NSOrderedDescending) { + alert.messageText = @"You're up to date"; + alert.informativeText = [NSString stringWithFormat:@"Markdown Viewer %@ is the latest version.", currentVersion]; + [alert addButtonWithTitle:@"OK"]; + [alert runModal]; + return; + } + alert.messageText = [NSString stringWithFormat:@"MDviewer %@ is available", latestVersion]; alert.informativeText = [NSString stringWithFormat:@"You're running version %@. Would you like to download the update?", currentVersion]; [alert addButtonWithTitle:@"Download"]; @@ -932,7 +950,7 @@ - (void)findPreviousMatch:(id)sender { - (BOOL)validateUserInterfaceItem:(id)item { SEL action = item.action; - if (action == @selector(openDocument:)) { + if (action == @selector(openDocument:) || action == @selector(checkForUpdates:)) { return YES; } From 27ff40139e7abc54e76b0abfc1a4ab147a7addb9 Mon Sep 17 00:00:00 2001 From: Santiago Barros Date: Fri, 10 Jul 2026 10:56:29 -0600 Subject: [PATCH 03/11] Add a document font setting: Serif, GitHub, or Geist New Settings window (Cmd+,) with three document fonts: the existing serif, GitHub's system sans stack, and Geist (vendored from the geist npm package with a pinned SHA-256 like the other libraries). The choice persists in NSUserDefaults and is applied live to all open windows as a data-font attribute driven by an injected user script. --- README.md | 2 + build.sh | 7 ++++ src/main.m | 99 +++++++++++++++++++++++++++++++++++++++++++++++++- src/viewer.css | 24 +++++++++++- 4 files changed, 129 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 53cb09c..ae7cf42 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ MDviewer is different: - **Dark mode** — follows your macOS appearance setting - **Secure** — HTML sanitized with [DOMPurify](https://github.com/cure53/DOMPurify), strict Content Security Policy - **Finder integration** — registers as default `.md` handler; double-click to open +- **Font settings** — pick the document font in Settings (`Cmd+,`): Serif (default), GitHub, or Geist (the Next.js font, bundled) - **Tabbed windows** — multiple documents in one window - **Local-first** — no telemetry, no accounts, and no network calls except the update check you trigger yourself from the menu @@ -76,6 +77,7 @@ Requires Xcode Command Line Tools (`xcode-select --install`). | Action | Shortcut | |---|---| | Open file | `Cmd+O` | +| Settings | `Cmd+,` | | Find in document | `Cmd+F` | | Next match | `Cmd+G` | | Previous match | `Cmd+Shift+G` | diff --git a/build.sh b/build.sh index 27bd5f5..368ca2c 100755 --- a/build.sh +++ b/build.sh @@ -32,6 +32,10 @@ MERMAID_VERSION="11.14.0" MERMAID_FILE="package/dist/mermaid.min.js" MERMAID_SHA256="217b66ef4279c33c141b4afe22effad10a91c02558dc70917be2c0981e78ed87" +GEIST_VERSION="1.7.2" +GEIST_FILE="package/dist/fonts/geist-sans/Geist-Variable.woff2" +GEIST_SHA256="a369fcf5628ea2aa4e1b9e2ec6a5b3624e365bda588e1f0f2f12b564f728fbb8" + KATEX_VERSION="0.16.45" KATEX_CSS_SHA256="23aefa0850248a16478b9f55d6b67028f74cc0b46b82b24dc22af068acaa4170" KATEX_JS_SHA256="e1c5d9e1b5b906881c40faf67950585a3f5d5adb4636d10e9678b9ba74b57dcc" @@ -251,6 +255,9 @@ build_bundle() { extract_npm_file "katex" "$KATEX_VERSION" "package/dist/contrib/auto-render.min.js" "$VENDOR_DIR/katex-auto-render.min.js" "$KATEX_AUTO_RENDER_SHA256" extract_npm_dir "katex" "$KATEX_VERSION" "package/dist/fonts" "$VENDOR_DIR/fonts" extract_npm_file "katex" "$KATEX_VERSION" "package/LICENSE" "$LICENSES_DIR/katex-LICENSE" + mkdir -p "$VENDOR_DIR/geist" + extract_npm_file "geist" "$GEIST_VERSION" "$GEIST_FILE" "$VENDOR_DIR/geist/Geist-Variable.woff2" "$GEIST_SHA256" + extract_npm_file "geist" "$GEIST_VERSION" "package/LICENSE.txt" "$LICENSES_DIR/geist-LICENSE.txt" chmod 755 "$RESOURCES_DIR/MarkdownViewer.sh" plutil -lint "$CONTENTS_DIR/Info.plist" >/dev/null diff --git a/src/main.m b/src/main.m index ab35f28..ba60d83 100644 --- a/src/main.m +++ b/src/main.m @@ -4,6 +4,8 @@ #import static NSString *const MDVErrorDomain = @"com.local.markdown-viewer"; +static NSString *const MDVPreferredFontKey = @"MDVPreferredFont"; +static NSString *const MDVPreferredFontDidChangeNotification = @"MDVPreferredFontDidChangeNotification"; static NSString *const MDVReleasesURL = @"https://api.github.com/repos/JackYoung27/MDviewer/releases/latest"; static NSString *const MDVDownloadURL = @"https://github.com/JackYoung27/MDviewer/releases/latest"; @@ -26,6 +28,23 @@ static BOOL MDVURLLooksLikeMarkdown(NSURL *url) { userInfo:@{NSLocalizedDescriptionKey: description ?: @"Unknown error."}]; } +static NSArray *MDVFontOptionValues(void) { + return @[@"serif", @"github", @"geist"]; +} + +static NSString *MDVPreferredFontValue(void) { + NSString *value = [[NSUserDefaults standardUserDefaults] stringForKey:MDVPreferredFontKey]; + return value && [MDVFontOptionValues() containsObject:value] ? value : @"serif"; +} + +static NSString *MDVPreferredFontScript(void) { + NSString *value = MDVPreferredFontValue(); + if ([value isEqualToString:@"serif"]) { + return @"document.documentElement.removeAttribute('data-font');"; + } + return [NSString stringWithFormat:@"document.documentElement.setAttribute('data-font', '%@');", value]; +} + @interface MDVPreviewWindowController : NSWindowController @property(nonatomic, copy) void (^closeHandler)(void); @@ -82,9 +101,29 @@ - (instancetype)init { [window.contentView addSubview:self.webView]; [window setInitialFirstResponder:self.webView]; + [self installPreferredFontUserScript]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(preferredFontDidChange:) + name:MDVPreferredFontDidChangeNotification + object:nil]; + return self; } +- (void)installPreferredFontUserScript { + WKUserContentController *contentController = self.webView.configuration.userContentController; + [contentController removeAllUserScripts]; + WKUserScript *script = [[WKUserScript alloc] initWithSource:MDVPreferredFontScript() + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:YES]; + [contentController addUserScript:script]; +} + +- (void)preferredFontDidChange:(NSNotification *)notification { + [self installPreferredFontUserScript]; + [self.webView evaluateJavaScript:MDVPreferredFontScript() completionHandler:nil]; +} + - (BOOL)hasLoadedDocument { return self.sourceFileURL != nil; } @@ -533,6 +572,7 @@ - (nullable WKWebView *)webView:(WKWebView *)webView @interface MDVAppDelegate : NSObject @property(nonatomic, strong) NSMutableSet *windowControllers; +@property(nonatomic, strong) NSWindow *settingsWindow; @property(nonatomic, assign) BOOL openedFileDuringLaunch; @end @@ -569,6 +609,12 @@ - (void)installMainMenu { [appMenu addItem:aboutItem]; [appMenu addItem:[NSMenuItem separatorItem]]; + NSMenuItem *settingsItem = [[NSMenuItem alloc] initWithTitle:@"Settings…" + action:@selector(showSettings:) + keyEquivalent:@","]; + settingsItem.target = self; + [appMenu addItem:settingsItem]; + NSMenuItem *updatesItem = [[NSMenuItem alloc] initWithTitle:@"Check for Updates…" action:@selector(checkForUpdates:) keyEquivalent:@""]; @@ -919,6 +965,56 @@ - (void)revealSourceFile:(id)sender { [[self currentPreviewWindowController] revealSourceFile:sender]; } +- (void)showSettings:(id)sender { + if (!self.settingsWindow) { + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0.0, 0.0, 340.0, 164.0) + styleMask:(NSWindowStyleMaskTitled | NSWindowStyleMaskClosable) + backing:NSBackingStoreBuffered + defer:NO]; + window.title = @"Settings"; + window.releasedWhenClosed = NO; + + NSTextField *label = [NSTextField labelWithString:@"Document font:"]; + label.font = [NSFont boldSystemFontOfSize:13.0]; + label.frame = NSMakeRect(20.0, 122.0, 300.0, 20.0); + [window.contentView addSubview:label]; + + NSArray *titles = @[ + @"Serif (default)", + @"GitHub (system sans)", + @"Geist (Next.js)", + ]; + NSString *currentValue = MDVPreferredFontValue(); + for (NSUInteger index = 0; index < titles.count; index += 1) { + NSButton *radio = [NSButton radioButtonWithTitle:titles[index] + target:self + action:@selector(fontSelectionChanged:)]; + radio.frame = NSMakeRect(28.0, 88.0 - 26.0 * (CGFloat)index, 292.0, 24.0); + radio.tag = (NSInteger)index; + radio.state = [MDVFontOptionValues()[index] isEqualToString:currentValue] + ? NSControlStateValueOn + : NSControlStateValueOff; + [window.contentView addSubview:radio]; + } + + [window center]; + self.settingsWindow = window; + } + + [self.settingsWindow makeKeyAndOrderFront:sender]; + [NSApp activateIgnoringOtherApps:YES]; +} + +- (void)fontSelectionChanged:(NSButton *)sender { + NSUInteger index = (NSUInteger)sender.tag; + if (index >= MDVFontOptionValues().count) { + return; + } + + [[NSUserDefaults standardUserDefaults] setObject:MDVFontOptionValues()[index] forKey:MDVPreferredFontKey]; + [[NSNotificationCenter defaultCenter] postNotificationName:MDVPreferredFontDidChangeNotification object:nil]; +} + - (void)toggleDarkMode:(id)sender { MDVPreviewWindowController *controller = [self currentPreviewWindowController]; if (controller && controller.isPreviewReady) { @@ -950,7 +1046,8 @@ - (void)findPreviousMatch:(id)sender { - (BOOL)validateUserInterfaceItem:(id)item { SEL action = item.action; - if (action == @selector(openDocument:) || action == @selector(checkForUpdates:)) { + if (action == @selector(openDocument:) || action == @selector(showSettings:) || + action == @selector(checkForUpdates:)) { return YES; } diff --git a/src/viewer.css b/src/viewer.css index 7bf34f4..3cbd647 100644 --- a/src/viewer.css +++ b/src/viewer.css @@ -1,4 +1,14 @@ +@font-face { + font-family: "Geist"; + src: url("vendor/geist/Geist-Variable.woff2") format("woff2"); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + :root { + --body-font: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; + --heading-font: "SF Pro Display", "Segoe UI", sans-serif; --bg: #ffffff; --page-text: #122033; --muted-text: #5b6675; @@ -36,6 +46,16 @@ --find-active-text: #1f1600; } +:root[data-font="github"] { + --body-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + --heading-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; +} + +:root[data-font="geist"] { + --body-font: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --heading-font: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #1c1c1e; @@ -127,7 +147,7 @@ body { min-height: 100vh; background: var(--bg); color: var(--page-text); - font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; + font-family: var(--body-font); } .document { @@ -162,7 +182,7 @@ body { margin-bottom: 0.5em; line-height: 1.2; color: var(--heading-text); - font-family: "SF Pro Display", "Segoe UI", sans-serif; + font-family: var(--heading-font); } .document h1 { From 786a196106cd0b71dafb97cff9737aaae71a7952 Mon Sep 17 00:00:00 2001 From: Santiago Barros Date: Fri, 10 Jul 2026 10:57:37 -0600 Subject: [PATCH 04/11] Add a Quick Look extension: spacebar previews in Finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A data-based QLPreviewProvider app extension built into Contents/PlugIns by build.sh — no Xcode project required. Markdown renders with the bundled marked.umd.js under JavaScriptCore; LaTeX math is extracted from the source (code-fence aware, so $a_i$ survives emphasis parsing) and rendered with katex.renderToString, which needs no DOM — Quick Look executes no JavaScript in data-based previews. KaTeX CSS ships with its woff2 fonts inlined as data URIs. Local images referenced by the markdown are embedded as cid: attachments on the QLPreviewReply, since data-based previews have no base URL; a read-only filesystem exception lets the sandboxed extension read them (macOS additionally asks once for privacy-protected folders like Desktop). Mermaid fences degrade to a styled source block for now. The appex is signed with its entitlements before the outer app — codesign --deep would strip them. Note for testing: qlmanage -p crashes on macOS 26 for all third-party Quick Look extensions; use a QLPreviewPanel harness or Finder itself. --- .gitignore | 1 + README.md | 1 + build.sh | 43 +++- src/QuickLook-Info.plist | 67 ++++++ src/quicklook.entitlements | 17 ++ src/quicklook.m | 419 +++++++++++++++++++++++++++++++++++++ 6 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 src/QuickLook-Info.plist create mode 100644 src/quicklook.entitlements create mode 100644 src/quicklook.m diff --git a/.gitignore b/.gitignore index 94c3f2d..7e10b76 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .build/ dist/ Markdown Viewer.app/ +CLAUDE.md diff --git a/README.md b/README.md index ae7cf42..8d93c52 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ MDviewer is different: - **Dark mode** — follows your macOS appearance setting - **Secure** — HTML sanitized with [DOMPurify](https://github.com/cure53/DOMPurify), strict Content Security Policy - **Finder integration** — registers as default `.md` handler; double-click to open +- **Quick Look** — press Space on a Markdown file in Finder for a rendered preview: tables, code, task lists, images, and LaTeX math included - **Font settings** — pick the document font in Settings (`Cmd+,`): Serif (default), GitHub, or Geist (the Next.js font, bundled) - **Tabbed windows** — multiple documents in one window - **Local-first** — no telemetry, no accounts, and no network calls except the update check you trigger yourself from the menu diff --git a/build.sh b/build.sh index 368ca2c..cdaae9d 100755 --- a/build.sh +++ b/build.sh @@ -15,6 +15,11 @@ MACOS_DIR="$CONTENTS_DIR/MacOS" RESOURCES_DIR="$CONTENTS_DIR/Resources" LICENSES_DIR="$RESOURCES_DIR/licenses" VENDOR_DIR="$RESOURCES_DIR/vendor" +PLUGINS_DIR="$CONTENTS_DIR/PlugIns" +QL_APPEX_NAME="MarkdownViewerQuickLook" +QL_APPEX_DIR="$PLUGINS_DIR/$QL_APPEX_NAME.appex" +QL_MACOS_DIR="$QL_APPEX_DIR/Contents/MacOS" +QL_RESOURCES_DIR="$QL_APPEX_DIR/Contents/Resources" ARCHIVE_PATH="$DIST_DIR/$ARCHIVE_NAME" ICON_SOURCE="$SCRIPT_DIR/assets/mdviewer.svg" ICON_NAME="AppIcon" @@ -147,6 +152,37 @@ build_native_binary() { -o "$MACOS_DIR/MarkdownViewer" } +build_quicklook_extension() { + mkdir -p "$QL_MACOS_DIR" "$QL_RESOURCES_DIR" + + clang \ + -fobjc-arc \ + -fapplication-extension \ + -mmacosx-version-min=12.0 \ + -Wall \ + -Wextra \ + -Wno-unused-parameter \ + -isysroot "$SDK_PATH" \ + -framework Foundation \ + -framework CoreGraphics \ + -framework JavaScriptCore \ + -framework QuickLookUI \ + -framework UniformTypeIdentifiers \ + -Wl,-e,_NSExtensionMain \ + "$SRC_DIR/quicklook.m" \ + -o "$QL_MACOS_DIR/$QL_APPEX_NAME" + + cp "$SRC_DIR/QuickLook-Info.plist" "$QL_APPEX_DIR/Contents/Info.plist" + cp "$SRC_DIR/viewer.css" "$QL_RESOURCES_DIR/viewer.css" + rm -rf "$QL_RESOURCES_DIR/vendor" + mkdir -p "$QL_RESOURCES_DIR/vendor" + cp "$VENDOR_DIR/marked.umd.js" "$QL_RESOURCES_DIR/vendor/marked.umd.js" + cp "$VENDOR_DIR/katex.min.js" "$QL_RESOURCES_DIR/vendor/katex.min.js" + cp "$VENDOR_DIR/katex.min.css" "$QL_RESOURCES_DIR/vendor/katex.min.css" + cp -R "$VENDOR_DIR/fonts" "$QL_RESOURCES_DIR/vendor/fonts" + plutil -lint "$QL_APPEX_DIR/Contents/Info.plist" >/dev/null +} + rasterize_svg() { local svg_path="$1" local png_path="$2" @@ -259,12 +295,17 @@ build_bundle() { extract_npm_file "geist" "$GEIST_VERSION" "$GEIST_FILE" "$VENDOR_DIR/geist/Geist-Variable.woff2" "$GEIST_SHA256" extract_npm_file "geist" "$GEIST_VERSION" "package/LICENSE.txt" "$LICENSES_DIR/geist-LICENSE.txt" + build_quicklook_extension + chmod 755 "$RESOURCES_DIR/MarkdownViewer.sh" plutil -lint "$CONTENTS_DIR/Info.plist" >/dev/null bash -n "$RESOURCES_DIR/MarkdownViewer.sh" if command -v codesign >/dev/null 2>&1; then - if ! codesign --force --deep --sign - "$APP_DIR" >/dev/null 2>&1; then + if ! codesign --force --sign - --entitlements "$SRC_DIR/quicklook.entitlements" "$QL_APPEX_DIR" >/dev/null 2>&1; then + printf 'Warning: ad-hoc codesign of the Quick Look extension failed; Finder previews may not work.\n' >&2 + fi + if ! codesign --force --sign - "$APP_DIR" >/dev/null 2>&1; then printf 'Warning: ad-hoc codesign failed; continuing with unsigned bundle.\n' >&2 elif ! codesign --verify --deep --strict "$APP_DIR" >/dev/null 2>&1; then printf 'Warning: codesign verification failed; continuing with bundle as built.\n' >&2 diff --git a/src/QuickLook-Info.plist b/src/QuickLook-Info.plist new file mode 100644 index 0000000..7c6a9d1 --- /dev/null +++ b/src/QuickLook-Info.plist @@ -0,0 +1,67 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleInfoDictionaryVersion + 6.0 + CFBundleSupportedPlatforms + + MacOSX + + DTPlatformName + macosx + DTSDKName + macosx + CFBundleName + Markdown Viewer Quick Look + CFBundleDisplayName + Markdown Viewer Quick Look + CFBundleIdentifier + com.local.markdown-viewer.quicklook + CFBundleVersion + 2.4.6 + CFBundleShortVersionString + 2.4.6 + CFBundleExecutable + MarkdownViewerQuickLook + CFBundlePackageType + XPC! + LSMinimumSystemVersion + 12.0 + NSHighResolutionCapable + + NSExtension + + NSExtensionPointIdentifier + com.apple.quicklook.preview + NSExtensionPrincipalClass + MDVPreviewProvider + NSExtensionAttributes + + QLIsDataBasedPreview + + QLSupportsSearchableItems + + QLSupportedContentTypes + + net.daringfireball.markdown + public.markdown + com.unknown.md + com.rstudio.rmarkdown + io.typora.markdown + net.ia.markdown + org.quarto.qmarkdown + com.nutstore.down + dyn.ah62d4rv4ge81e5pe + dyn.ah62d4rv4ge8043a + dyn.ah62d4rv4ge81c5pe + dyn.ah62d4rv4ge8043d2 + dyn.ah62d4rv4ge8043dd + dyn.ah62d4rv4ge80c6dmqk + + + + + diff --git a/src/quicklook.entitlements b/src/quicklook.entitlements new file mode 100644 index 0000000..9a1f877 --- /dev/null +++ b/src/quicklook.entitlements @@ -0,0 +1,17 @@ + + + + + com.apple.security.app-sandbox + + + com.apple.security.temporary-exception.files.absolute-path.read-only + + / + + + diff --git a/src/quicklook.m b/src/quicklook.m new file mode 100644 index 0000000..e05188d --- /dev/null +++ b/src/quicklook.m @@ -0,0 +1,419 @@ +#import +#import +#import +#import +#import + +static NSString *const MDVQLErrorDomain = @"com.local.markdown-viewer.quicklook"; + +static NSString *MDVDecodeHTMLEntities(NSString *text); + +@interface MDVPreviewProvider : QLPreviewProvider +@end + +static NSError *MDVQLMakeError(NSInteger code, NSString *description) { + return [NSError errorWithDomain:MDVQLErrorDomain + code:code + userInfo:@{NSLocalizedDescriptionKey: description ?: @"Unknown error."}]; +} + +static NSString *MDVEscapeHTML(NSString *text) { + NSMutableString *escaped = [text mutableCopy]; + [escaped replaceOccurrencesOfString:@"&" withString:@"&" options:NSLiteralSearch range:NSMakeRange(0, escaped.length)]; + [escaped replaceOccurrencesOfString:@"<" withString:@"<" options:NSLiteralSearch range:NSMakeRange(0, escaped.length)]; + [escaped replaceOccurrencesOfString:@">" withString:@">" options:NSLiteralSearch range:NSMakeRange(0, escaped.length)]; + return escaped; +} + +static NSBundle *MDVBundle(void) { + return [NSBundle bundleForClass:[MDVPreviewProvider class]]; +} + +static NSString *MDVLoadBundleResource(NSString *name, NSString *extension, NSString *subdirectory, NSError **error) { + NSURL *resourceURL = [MDVBundle() URLForResource:name withExtension:extension subdirectory:subdirectory]; + if (!resourceURL) { + if (error) { + *error = MDVQLMakeError(1, [NSString stringWithFormat:@"%@.%@ is missing from the Quick Look extension bundle.", name, extension]); + } + return nil; + } + return [NSString stringWithContentsOfURL:resourceURL encoding:NSUTF8StringEncoding error:error]; +} + +static NSString *MDVRenderMarkdownHTML(NSString *markdown, NSError **error) { + NSString *markedSource = MDVLoadBundleResource(@"marked.umd", @"js", @"vendor", error); + if (!markedSource) { + return nil; + } + + JSContext *context = [[JSContext alloc] init]; + __block NSString *exceptionMessage = nil; + context.exceptionHandler = ^(JSContext *ctx, JSValue *exception) { + exceptionMessage = [exception description]; + }; + + [context evaluateScript:markedSource]; + JSValue *marked = context[@"marked"]; + if (exceptionMessage || !marked || marked.isUndefined) { + if (error) { + *error = MDVQLMakeError(2, exceptionMessage ?: @"marked failed to load in JavaScriptCore."); + } + return nil; + } + + JSValue *rendered = [marked invokeMethod:@"parse" + withArguments:@[markdown, @{@"gfm": @YES, @"breaks": @YES, @"async": @NO}]]; + if (exceptionMessage || !rendered.isString) { + if (error) { + *error = MDVQLMakeError(3, exceptionMessage ?: @"marked.parse did not return a string."); + } + return nil; + } + return rendered.toString; +} + +#pragma mark - KaTeX math (JavaScriptCore, no DOM needed) + +// katex.renderToString works without a browser DOM, so math renders even +// though Quick Look forbids web content processes in extension sandboxes. +static JSValue *MDVKaTeXRenderFunction(void) { + static JSContext *context = nil; + static JSValue *renderToString = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSString *katexSource = MDVLoadBundleResource(@"katex.min", @"js", @"vendor", NULL); + if (!katexSource) { + return; + } + context = [[JSContext alloc] init]; + __block BOOL failed = NO; + context.exceptionHandler = ^(JSContext *ctx, JSValue *exception) { + failed = YES; + }; + [context evaluateScript:katexSource]; + JSValue *katex = context[@"katex"]; + if (failed || !katex || katex.isUndefined) { + context = nil; + return; + } + renderToString = katex[@"renderToString"]; + }); + return renderToString; +} + +static NSString *MDVKaTeXRenderTeX(NSString *tex, BOOL displayMode) { + JSValue *render = MDVKaTeXRenderFunction(); + if (!render) { + return nil; + } + + __block BOOL failed = NO; + JSContext *context = render.context; + void (^previousHandler)(JSContext *, JSValue *) = context.exceptionHandler; + context.exceptionHandler = ^(JSContext *ctx, JSValue *exception) { + failed = YES; + }; + JSValue *result = [render callWithArguments:@[tex, @{@"displayMode": @(displayMode), @"throwOnError": @NO}]]; + context.exceptionHandler = previousHandler; + + return (!failed && result.isString) ? result.toString : nil; +} + +// Replaces every regex match with an opaque token that marked passes through +// untouched; the original substring is stored for later restoration. +static void MDVProtectMatches(NSMutableString *text, + NSString *pattern, + unichar tokenMarker, + NSMutableDictionary *store) { + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:NULL]; + NSArray *matches = [regex matchesInString:text options:0 range:NSMakeRange(0, text.length)]; + + for (NSTextCheckingResult *match in matches.reverseObjectEnumerator) { + NSString *token = [NSString stringWithFormat:@"%C%lu%C", tokenMarker, (unsigned long)store.count, tokenMarker]; + store[token] = [text substringWithRange:match.range]; + [text replaceCharactersInRange:match.range withString:token]; + } +} + +// Extracts $$...$$, \[...\], \(...\), and $...$ math from the markdown source +// (skipping code fences and inline code), renders each with KaTeX, and swaps +// in placeholder tokens that are resolved after marked runs. Working on the +// source keeps TeX like $a_i + b_j$ away from marked's emphasis parsing. +static NSString *MDVSubstituteMath(NSString *markdown, + NSMutableDictionary *mathHTMLByToken) { + BOOL mightHaveMath = [markdown containsString:@"$"] || + [markdown containsString:@"\\("] || + [markdown containsString:@"\\["]; + if (!mightHaveMath) { + return markdown; + } + + NSMutableString *working = [markdown mutableCopy]; + NSMutableDictionary *protectedCode = [NSMutableDictionary dictionary]; + MDVProtectMatches(working, @"(?ms)^(```|~~~)[^\n]*$.*?^\\1[ \t]*$", 0xE000, protectedCode); + MDVProtectMatches(working, @"(`+)[\\s\\S]*?\\1", 0xE000, protectedCode); + + NSRegularExpression *mathPattern = [NSRegularExpression regularExpressionWithPattern: + @"\\$\\$([\\s\\S]+?)\\$\\$" // $$display$$ + "|\\\\\\[([\\s\\S]+?)\\\\\\]" // \[display\] + "|\\\\\\(([\\s\\S]+?)\\\\\\)" // \(inline\) + "|\\$([^\\s$][^$\n]*?)\\$" // $inline$ + options:0 + error:NULL]; + NSArray *matches = [mathPattern matchesInString:working options:0 range:NSMakeRange(0, working.length)]; + + for (NSTextCheckingResult *match in matches.reverseObjectEnumerator) { + NSString *tex = nil; + BOOL displayMode = NO; + for (NSUInteger group = 1; group <= 4; group += 1) { + NSRange groupRange = [match rangeAtIndex:group]; + if (groupRange.location != NSNotFound) { + tex = [working substringWithRange:groupRange]; + displayMode = group <= 2; + break; + } + } + + NSString *rendered = tex.length > 0 ? MDVKaTeXRenderTeX(tex, displayMode) : nil; + if (!rendered) { + continue; + } + + NSString *token = [NSString stringWithFormat:@"%C%lu%C", (unichar)0xE001, (unsigned long)mathHTMLByToken.count, (unichar)0xE001]; + mathHTMLByToken[token] = rendered; + [working replaceCharactersInRange:match.range withString:token]; + } + + for (NSString *token in protectedCode) { + NSRange tokenRange = [working rangeOfString:token]; + if (tokenRange.location != NSNotFound) { + [working replaceCharactersInRange:tokenRange withString:protectedCode[token]]; + } + } + + return working; +} + +#pragma mark - Mermaid fallback + +// Mermaid needs a real browser engine, which the Quick Look sandbox refuses to +// spawn (WebContent processes are terminated immediately) — diagrams degrade +// to a styled source fallback. +static NSString *MDVApplyMermaidFallback(NSString *html) { + NSRegularExpression *mermaidBlock = [NSRegularExpression regularExpressionWithPattern: + @"
([\\s\\S]*?)
" options:0 error:NULL]; + NSArray *matches = [mermaidBlock matchesInString:html options:0 range:NSMakeRange(0, html.length)]; + if (matches.count == 0) { + return html; + } + + NSMutableString *result = [html mutableCopy]; + for (NSTextCheckingResult *match in matches.reverseObjectEnumerator) { + NSString *source = [html substringWithRange:[match rangeAtIndex:1]]; + NSString *figure = [NSString stringWithFormat: + @"
" + "

Mermaid diagram — open in Markdown Viewer to render it.

" + "
%@
" + "
", source]; + [result replaceCharactersInRange:match.range withString:figure]; + } + return result; +} + +#pragma mark - Preview assembly + +static NSString *MDVRenderPreviewBody(NSString *markdown, NSError **error) { + NSMutableDictionary *mathHTMLByToken = [NSMutableDictionary dictionary]; + NSString *prepared = MDVSubstituteMath(markdown, mathHTMLByToken); + + NSString *html = MDVRenderMarkdownHTML(prepared, error); + if (!html) { + return nil; + } + + if (mathHTMLByToken.count > 0) { + NSMutableString *resolved = [html mutableCopy]; + for (NSString *token in mathHTMLByToken) { + NSRange tokenRange = [resolved rangeOfString:token]; + if (tokenRange.location != NSNotFound) { + [resolved replaceCharactersInRange:tokenRange withString:mathHTMLByToken[token]]; + } + } + html = resolved; + } + + return MDVApplyMermaidFallback(html); +} + +static NSString *MDVDecodeHTMLEntities(NSString *text) { + NSMutableString *decoded = [text mutableCopy]; + [decoded replaceOccurrencesOfString:@""" withString:@"\"" options:NSLiteralSearch range:NSMakeRange(0, decoded.length)]; + [decoded replaceOccurrencesOfString:@"'" withString:@"'" options:NSLiteralSearch range:NSMakeRange(0, decoded.length)]; + [decoded replaceOccurrencesOfString:@"<" withString:@"<" options:NSLiteralSearch range:NSMakeRange(0, decoded.length)]; + [decoded replaceOccurrencesOfString:@">" withString:@">" options:NSLiteralSearch range:NSMakeRange(0, decoded.length)]; + [decoded replaceOccurrencesOfString:@"&" withString:@"&" options:NSLiteralSearch range:NSMakeRange(0, decoded.length)]; + return decoded; +} + +static NSData *MDVReadImageData(NSURL *imageURL) { + static const NSUInteger MDVMaxImageBytes = 25 * 1024 * 1024; + NSData *data = [NSData dataWithContentsOfURL:imageURL options:NSDataReadingMappedIfSafe error:NULL]; + return (data.length > 0 && data.length <= MDVMaxImageBytes) ? data : nil; +} + +static NSURL *MDVResolveLocalImageURL(NSString *source, NSURL *baseDirectoryURL) { + if ([source hasPrefix:@"file://"]) { + return [NSURL URLWithString:source]; + } + NSString *path = source; + if ([path containsString:@"%"]) { + path = [path stringByRemovingPercentEncoding] ?: path; + } + if ([path hasPrefix:@"/"]) { + return [NSURL fileURLWithPath:path]; + } + return [NSURL fileURLWithPath:path relativeToURL:baseDirectoryURL].absoluteURL; +} + +// Data-based previews have no document base URL, so relative image paths can +// never load on their own; local images are inlined as cid: attachments instead. +static NSString *MDVEmbedLocalImages(NSString *bodyHTML, + NSURL *baseDirectoryURL, + NSMutableDictionary *attachments) { + NSRegularExpression *imageSource = + [NSRegularExpression regularExpressionWithPattern:@"(]*?\\bsrc\\s*=\\s*\")([^\"]+)(\")" + options:NSRegularExpressionCaseInsensitive + error:NULL]; + NSArray *matches = + [imageSource matchesInString:bodyHTML options:0 range:NSMakeRange(0, bodyHTML.length)]; + if (matches.count == 0) { + return bodyHTML; + } + + NSMutableString *result = [bodyHTML mutableCopy]; + NSUInteger attachmentIndex = 0; + + for (NSTextCheckingResult *match in matches.reverseObjectEnumerator) { + NSString *source = MDVDecodeHTMLEntities([bodyHTML substringWithRange:[match rangeAtIndex:2]]); + if ([source rangeOfString:@"^(https?:|data:|cid:)" options:NSRegularExpressionSearch | NSCaseInsensitiveSearch].location != NSNotFound) { + continue; + } + + NSURL *imageURL = MDVResolveLocalImageURL(source, baseDirectoryURL); + NSData *imageData = imageURL ? MDVReadImageData(imageURL) : nil; + if (!imageData) { + continue; + } + + UTType *contentType = [UTType typeWithFilenameExtension:imageURL.pathExtension.lowercaseString]; + if (!contentType || ![contentType conformsToType:UTTypeImage]) { + continue; + } + + NSString *attachmentKey = [NSString stringWithFormat:@"img%lu", (unsigned long)attachmentIndex]; + attachmentIndex += 1; + attachments[attachmentKey] = [[QLPreviewReplyAttachment alloc] initWithData:imageData contentType:contentType]; + [result replaceCharactersInRange:[match rangeAtIndex:2] + withString:[NSString stringWithFormat:@"cid:%@", attachmentKey]]; + } + + return result; +} + +// KaTeX CSS references its fonts with relative url(fonts/...) entries that a +// data-based preview cannot resolve, so the woff2 fonts are inlined as data URIs. +static NSString *MDVKaTeXCSS(void) { + static NSString *inlinedCSS = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSString *css = MDVLoadBundleResource(@"katex.min", @"css", @"vendor", NULL); + if (!css) { + inlinedCSS = @""; + return; + } + + NSRegularExpression *fontURL = + [NSRegularExpression regularExpressionWithPattern:@"url\\(fonts/([^)]+\\.woff2)\\)" options:0 error:NULL]; + NSMutableString *result = [css mutableCopy]; + NSArray *matches = [fontURL matchesInString:css options:0 range:NSMakeRange(0, css.length)]; + + for (NSTextCheckingResult *match in matches.reverseObjectEnumerator) { + NSString *fontName = [css substringWithRange:[match rangeAtIndex:1]]; + NSURL *fontFile = [MDVBundle() URLForResource:[fontName stringByDeletingPathExtension] + withExtension:@"woff2" + subdirectory:@"vendor/fonts"]; + NSData *fontData = fontFile ? [NSData dataWithContentsOfURL:fontFile] : nil; + if (!fontData) { + continue; + } + NSString *replacement = [NSString stringWithFormat:@"url(data:font/woff2;base64,%@)", + [fontData base64EncodedStringWithOptions:0]]; + [result replaceCharactersInRange:match.range withString:replacement]; + } + + inlinedCSS = result; + }); + return inlinedCSS; +} + +// Quick Look renders data-based HTML previews with JavaScript disabled, so the +// document must arrive fully rendered; the CSP is defense in depth on top of that. +static NSString *MDVPreviewHTML(NSString *bodyHTML, NSString *css) { + return [NSString stringWithFormat: + @"\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "
\n%@\n
\n" + "\n" + "\n", css, bodyHTML]; +} + +@implementation MDVPreviewProvider + +- (void)providePreviewForFileRequest:(QLFilePreviewRequest *)request + completionHandler:(void (^)(QLPreviewReply *_Nullable, NSError *_Nullable))handler { + NSURL *fileURL = request.fileURL; + QLPreviewReply *reply = [[QLPreviewReply alloc] + initWithDataOfContentType:UTTypeHTML + contentSize:CGSizeMake(720.0, 900.0) + dataCreationBlock:^NSData *_Nullable(QLPreviewReply *replyToUpdate, NSError **error) { + replyToUpdate.stringEncoding = NSUTF8StringEncoding; + + NSString *markdown = [NSString stringWithContentsOfURL:fileURL encoding:NSUTF8StringEncoding error:NULL]; + if (!markdown) { + NSData *rawData = [NSData dataWithContentsOfURL:fileURL options:0 error:error]; + if (!rawData) { + return nil; + } + markdown = [[NSString alloc] initWithData:rawData encoding:NSISOLatin1StringEncoding] ?: @""; + } + + NSString *body = MDVRenderPreviewBody(markdown, NULL); + if (!body) { + body = [NSString stringWithFormat:@"
%@
", MDVEscapeHTML(markdown)]; + } + + NSString *css = MDVLoadBundleResource(@"viewer", @"css", nil, NULL) ?: @""; + if ([body containsString:@"class=\"katex"]) { + css = [css stringByAppendingFormat:@"\n%@", MDVKaTeXCSS()]; + } + + NSMutableDictionary *attachments = [NSMutableDictionary dictionary]; + body = MDVEmbedLocalImages(body, fileURL.URLByDeletingLastPathComponent, attachments); + if (attachments.count > 0) { + replyToUpdate.attachments = attachments; + } + + return [MDVPreviewHTML(body, css) dataUsingEncoding:NSUTF8StringEncoding]; + }]; + + handler(reply, nil); +} + +@end From 0df2be2b28e6893b33dd75e9de70a46071b5da81 Mon Sep 17 00:00:00 2001 From: Santiago Barros Date: Fri, 10 Jul 2026 10:58:46 -0600 Subject: [PATCH 05/11] Render Mermaid in Quick Look and follow the system theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quick Look sandbox cannot host a browser engine (WebContent processes are terminated immediately, and view-based preview extensions are never launched by quicklookd — both verified on macOS 26), so Mermaid is solved in two layers: 1. The app caches every SVG it renders, keyed by sha256 of the trimmed diagram source, in both light and dark themes (viewer.js renders the non-displayed theme in the background after the visible pass). 2. On cache miss the extension asks an optional on-demand launchd helper over XPC. launchd registers no running process at rest, spawns the helper when the extension connects, and it exits after 45 seconds idle. It is opt-in (./install.sh --with-mermaid-helper) because launchd agents surface as Login Items. The extension prefers the SVG matching the current system appearance, then a live helper render, then a stale-theme SVG; unseen diagrams with no helper degrade to the styled source fallback. A permissions section in the README documents the model. --- README.md | 14 +- build.sh | 19 +++ install.sh | 43 ++++++ src/main.m | 59 +++++++- src/quicklook.entitlements | 11 +- src/quicklook.m | 130 +++++++++++++++-- src/register-mermaid-helper.sh | 38 +++++ src/render-helper.h | 13 ++ src/render-helper.m | 249 +++++++++++++++++++++++++++++++++ src/viewer.js | 55 +++++++- 10 files changed, 612 insertions(+), 19 deletions(-) create mode 100755 src/register-mermaid-helper.sh create mode 100644 src/render-helper.h create mode 100644 src/render-helper.m diff --git a/README.md b/README.md index 8d93c52..f77729a 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,10 @@ MDviewer is different: - **GitHub Flavored Markdown** — tables, task lists, fenced code blocks - **Mermaid diagrams** — renders fenced `mermaid` diagrams inline, fully local - **LaTeX math** — renders inline `$...$` and block `$$...$$` math with bundled KaTeX -- **Dark mode** — follows your macOS appearance setting +- **Dark mode** — the app and Quick Look previews follow your macOS appearance setting, including Mermaid diagrams (rendered and cached in both themes) - **Secure** — HTML sanitized with [DOMPurify](https://github.com/cure53/DOMPurify), strict Content Security Policy - **Finder integration** — registers as default `.md` handler; double-click to open -- **Quick Look** — press Space on a Markdown file in Finder for a rendered preview: tables, code, task lists, images, and LaTeX math included +- **Quick Look** — press Space on a Markdown file in Finder for a fully rendered preview: tables, code, task lists, images, LaTeX math, and Mermaid diagrams (from the app's render cache — or live everywhere with the optional `--with-mermaid-helper` install flag) - **Font settings** — pick the document font in Settings (`Cmd+,`): Serif (default), GitHub, or Geist (the Next.js font, bundled) - **Tabbed windows** — multiple documents in one window - **Local-first** — no telemetry, no accounts, and no network calls except the update check you trigger yourself from the menu @@ -69,8 +69,18 @@ git clone https://github.com/JackYoung27/mdviewer.git cd mdviewer ./build.sh # builds to dist/Markdown Viewer.app ./install.sh # optional: copies to /Applications and sets as default handler + # add --with-mermaid-helper for live Mermaid in Quick Look ``` +## Permissions + +Designed to be inspectable and minimal: + +- The app makes **no network requests on its own** — "Check for Updates…" in the menu is the only network call, and only when you click it. +- The Quick Look extension is **sandboxed** with read-only filesystem access (so previews can load images your markdown references — wherever the file lives — and the diagram cache). It cannot write anything. macOS additionally asks once before it can read images in privacy-protected folders like Desktop or Documents. +- **No background processes by default.** The optional Mermaid helper (only if you install with `--with-mermaid-helper`) appears under Login Items as a background item; launchd spawns it on demand and it exits after 45 seconds idle. Remove it anytime: `launchctl bootout gui/$(id -u)/com.local.markdown-viewer.render-helper && rm ~/Library/LaunchAgents/com.local.markdown-viewer.render-helper.plist` +- All vendored libraries (marked, DOMPurify, Mermaid, KaTeX, Geist) are downloaded from npm at build time and verified against pinned SHA-256 hashes. + Requires Xcode Command Line Tools (`xcode-select --install`). ## Keyboard Shortcuts diff --git a/build.sh b/build.sh index cdaae9d..4b77de8 100755 --- a/build.sh +++ b/build.sh @@ -152,6 +152,19 @@ build_native_binary() { -o "$MACOS_DIR/MarkdownViewer" } +build_render_helper() { + clang \ + -fobjc-arc \ + -Wall \ + -Wextra \ + -Wno-unused-parameter \ + -isysroot "$SDK_PATH" \ + -framework Cocoa \ + -framework WebKit \ + "$SRC_DIR/render-helper.m" \ + -o "$MACOS_DIR/MarkdownViewerRenderHelper" +} + build_quicklook_extension() { mkdir -p "$QL_MACOS_DIR" "$QL_RESOURCES_DIR" @@ -278,6 +291,8 @@ build_bundle() { cp "$SRC_DIR/MarkdownViewer.sh" "$RESOURCES_DIR/MarkdownViewer.sh" cp "$SRC_DIR/viewer.css" "$RESOURCES_DIR/viewer.css" cp "$SRC_DIR/viewer.js" "$RESOURCES_DIR/viewer.js" + cp "$SRC_DIR/register-mermaid-helper.sh" "$RESOURCES_DIR/register-mermaid-helper.sh" + chmod 755 "$RESOURCES_DIR/register-mermaid-helper.sh" cp "$SCRIPT_DIR/LICENSE" "$RESOURCES_DIR/LICENSE" extract_npm_file "marked" "$MARKED_VERSION" "$MARKED_FILE" "$VENDOR_DIR/marked.umd.js" "$MARKED_SHA256" @@ -295,6 +310,7 @@ build_bundle() { extract_npm_file "geist" "$GEIST_VERSION" "$GEIST_FILE" "$VENDOR_DIR/geist/Geist-Variable.woff2" "$GEIST_SHA256" extract_npm_file "geist" "$GEIST_VERSION" "package/LICENSE.txt" "$LICENSES_DIR/geist-LICENSE.txt" + build_render_helper build_quicklook_extension chmod 755 "$RESOURCES_DIR/MarkdownViewer.sh" @@ -305,6 +321,9 @@ build_bundle() { if ! codesign --force --sign - --entitlements "$SRC_DIR/quicklook.entitlements" "$QL_APPEX_DIR" >/dev/null 2>&1; then printf 'Warning: ad-hoc codesign of the Quick Look extension failed; Finder previews may not work.\n' >&2 fi + if ! codesign --force --sign - "$MACOS_DIR/MarkdownViewerRenderHelper" >/dev/null 2>&1; then + printf 'Warning: ad-hoc codesign of the render helper failed.\n' >&2 + fi if ! codesign --force --sign - "$APP_DIR" >/dev/null 2>&1; then printf 'Warning: ad-hoc codesign failed; continuing with unsigned bundle.\n' >&2 elif ! codesign --verify --deep --strict "$APP_DIR" >/dev/null 2>&1; then diff --git a/install.sh b/install.sh index 47b89d9..ea8a7eb 100755 --- a/install.sh +++ b/install.sh @@ -17,7 +17,43 @@ require_command() { fi } +# Registers the on-demand mermaid render helper with launchd. The agent owns +# no running process at rest; launchd spawns it when the Quick Look extension +# connects and it exits itself when idle. +install_render_helper_agent() { + "$TARGET_APP/Contents/Resources/register-mermaid-helper.sh" "$TARGET_APP" || \ + printf 'Warning: could not register the mermaid render helper agent.\n' >&2 +} + +usage() { + cat <<'EOF' +Usage: + ./install.sh Install the app and set it as default .md handler + ./install.sh --with-mermaid-helper Also register the optional background helper that + renders Mermaid diagrams live in Quick Look + (appears under System Settings > Login Items) +EOF +} + main() { + local with_mermaid_helper=0 + local arg + for arg in "$@"; do + case "$arg" in + --with-mermaid-helper) + with_mermaid_helper=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + exit 1 + ;; + esac + done + require_command ditto require_command plutil require_command python3 @@ -153,6 +189,13 @@ PY "$LSREGISTER" -kill -seed -r -domain local -domain system -domain user >/dev/null 2>&1 || true "$LSREGISTER" -f "$TARGET_APP" >/dev/null + if [ "$with_mermaid_helper" -eq 1 ]; then + install_render_helper_agent + else + echo "Mermaid diagrams render in Quick Look after a file is opened in the app once." + echo "For live rendering of never-opened diagrams, re-run with --with-mermaid-helper." + fi + killall cfprefsd Finder >/dev/null 2>&1 || true echo "Installed -> $TARGET_APP" diff --git a/src/main.m b/src/main.m index ba60d83..79a7170 100644 --- a/src/main.m +++ b/src/main.m @@ -1,4 +1,5 @@ #import +#import #import #import #import @@ -45,7 +46,25 @@ static BOOL MDVURLLooksLikeMarkdown(NSURL *url) { return [NSString stringWithFormat:@"document.documentElement.setAttribute('data-font', '%@');", value]; } -@interface MDVPreviewWindowController : NSWindowController +// Rendered Mermaid SVGs are cached by content hash so the Quick Look +// extension — which cannot run a browser engine — can reuse them. +static NSString *MDVMermaidCacheDirectory(void) { + NSString *appSupport = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject; + return [[appSupport stringByAppendingPathComponent:@"Markdown Viewer"] stringByAppendingPathComponent:@"mermaid-cache"]; +} + +static NSString *MDVSHA256Hex(NSString *text) { + NSData *data = [text dataUsingEncoding:NSUTF8StringEncoding]; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(data.bytes, (CC_LONG)data.length, digest); + NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (NSUInteger index = 0; index < CC_SHA256_DIGEST_LENGTH; index += 1) { + [hex appendFormat:@"%02x", digest[index]]; + } + return hex; +} + +@interface MDVPreviewWindowController : NSWindowController @property(nonatomic, copy) void (^closeHandler)(void); @property(nonatomic, strong) WKWebView *webView; @@ -102,6 +121,7 @@ - (instancetype)init { [window setInitialFirstResponder:self.webView]; [self installPreferredFontUserScript]; + [self.webView.configuration.userContentController addScriptMessageHandler:self name:@"mermaidRendered"]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(preferredFontDidChange:) name:MDVPreferredFontDidChangeNotification @@ -110,6 +130,42 @@ - (instancetype)init { return self; } +- (void)userContentController:(WKUserContentController *)userContentController + didReceiveScriptMessage:(WKScriptMessage *)message { + if (![message.name isEqualToString:@"mermaidRendered"] || ![message.body isKindOfClass:NSDictionary.class]) { + return; + } + + NSDictionary *body = message.body; + NSString *source = [body[@"source"] isKindOfClass:NSString.class] ? body[@"source"] : nil; + NSString *svg = [body[@"svg"] isKindOfClass:NSString.class] ? body[@"svg"] : nil; + NSString *theme = [body[@"theme"] isKindOfClass:NSString.class] ? body[@"theme"] : nil; + + static const NSUInteger MDVMaxCachedSVGLength = 4 * 1024 * 1024; + if (source.length == 0 || svg.length == 0 || svg.length > MDVMaxCachedSVGLength || + !([theme isEqualToString:@"light"] || [theme isEqualToString:@"dark"])) { + return; + } + + NSString *trimmedSource = [source stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (trimmedSource.length == 0) { + return; + } + + NSString *cacheDirectory = MDVMermaidCacheDirectory(); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + [[NSFileManager defaultManager] createDirectoryAtPath:cacheDirectory + withIntermediateDirectories:YES + attributes:nil + error:NULL]; + NSString *fileName = [NSString stringWithFormat:@"%@-%@.svg", MDVSHA256Hex(trimmedSource), theme]; + [svg writeToFile:[cacheDirectory stringByAppendingPathComponent:fileName] + atomically:YES + encoding:NSUTF8StringEncoding + error:NULL]; + }); +} + - (void)installPreferredFontUserScript { WKUserContentController *contentController = self.webView.configuration.userContentController; [contentController removeAllUserScripts]; @@ -475,6 +531,7 @@ - (void)stopWatchingSourceFile { } - (void)windowWillClose:(NSNotification *)notification { + [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"mermaidRendered"]; [self stopWatchingSourceFile]; [self clearPendingScrollRestore]; if (self.closeHandler) { diff --git a/src/quicklook.entitlements b/src/quicklook.entitlements index 9a1f877..df27438 100644 --- a/src/quicklook.entitlements +++ b/src/quicklook.entitlements @@ -6,12 +6,17 @@ + folders) and the shared Mermaid SVG cache. Quick Look itself grants + access to the previewed file; nothing is writable. A home-relative + exception was tried and broke images for files outside the home. --> com.apple.security.temporary-exception.files.absolute-path.read-only / + + com.apple.security.temporary-exception.mach-lookup.global-name + + com.local.markdown-viewer.render-helper + diff --git a/src/quicklook.m b/src/quicklook.m index e05188d..e870f3f 100644 --- a/src/quicklook.m +++ b/src/quicklook.m @@ -1,8 +1,12 @@ #import +#import #import #import #import #import +#import + +#import "render-helper.h" static NSString *const MDVQLErrorDomain = @"com.local.markdown-viewer.quicklook"; @@ -194,12 +198,104 @@ static void MDVProtectMatches(NSMutableString *text, return working; } -#pragma mark - Mermaid fallback +#pragma mark - Mermaid // Mermaid needs a real browser engine, which the Quick Look sandbox refuses to -// spawn (WebContent processes are terminated immediately) — diagrams degrade -// to a styled source fallback. -static NSString *MDVApplyMermaidFallback(NSString *html) { +// spawn (WebContent processes are terminated immediately). The app caches every +// SVG it renders, keyed by diagram content hash — the extension reuses those +// and degrades to a styled source fallback for diagrams the app has never shown. +static NSString *MDVSHA256Hex(NSString *text) { + NSData *data = [text dataUsingEncoding:NSUTF8StringEncoding]; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(data.bytes, (CC_LONG)data.length, digest); + NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (NSUInteger index = 0; index < CC_SHA256_DIGEST_LENGTH; index += 1) { + [hex appendFormat:@"%02x", digest[index]]; + } + return hex; +} + +// NSHomeDirectory() points inside the extension's sandbox container; the +// app's cache lives under the user's real home, readable via the read-only +// filesystem exception entitlement. +static NSString *MDVRealHomeDirectory(void) { + struct passwd *userInfo = getpwuid(getuid()); + return (userInfo && userInfo->pw_dir) ? @(userInfo->pw_dir) : NSHomeDirectory(); +} + +static NSString *MDVMermaidCachePath(void) { + return [MDVRealHomeDirectory() stringByAppendingPathComponent:@"Library/Application Support/Markdown Viewer/mermaid-cache"]; +} + +// The sandboxed extension cannot read preferences the normal way; the global +// preferences plist under the real home reveals the current appearance. +static NSString *MDVSystemTheme(void) { + NSString *plistPath = [MDVRealHomeDirectory() + stringByAppendingPathComponent:@"Library/Preferences/.GlobalPreferences.plist"]; + NSDictionary *preferences = [NSDictionary dictionaryWithContentsOfFile:plistPath]; + NSString *style = [preferences[@"AppleInterfaceStyle"] isKindOfClass:NSString.class] ? preferences[@"AppleInterfaceStyle"] : nil; + return [style isEqualToString:@"Dark"] ? @"dark" : @"light"; +} + +static NSString *MDVCachedMermaidSVGForTheme(NSString *hash, NSString *theme) { + NSString *filePath = [MDVMermaidCachePath() stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@-%@.svg", hash, theme]]; + NSString *svg = [NSString stringWithContentsOfFile:filePath encoding:NSUTF8StringEncoding error:NULL]; + return svg.length > 0 ? svg : nil; +} + +// Cache miss: ask the on-demand launchd helper to render the diagram. launchd +// spawns it lazily and it exits when idle, so this costs nothing at rest; if +// the agent is not installed the lookup fails fast and we fall back. +static NSString *MDVRequestMermaidRender(NSString *trimmedSource) { + NSXPCConnection *connection = [[NSXPCConnection alloc] initWithMachServiceName:MDVRenderHelperServiceName + options:0]; + connection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(MDVRenderHelperProtocol)]; + [connection resume]; + + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block NSString *renderedSVG = nil; + + id proxy = [connection remoteObjectProxyWithErrorHandler:^(NSError *error) { + dispatch_semaphore_signal(semaphore); + }]; + [proxy renderMermaidSource:trimmedSource theme:MDVSystemTheme() withReply:^(NSString *svg, NSString *errorMessage) { + renderedSVG = svg.length > 0 ? svg : nil; + dispatch_semaphore_signal(semaphore); + }]; + + dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(25 * NSEC_PER_SEC))); + [connection invalidate]; + return renderedSVG; +} + +// Theme priority: an SVG matching the system appearance, then a live helper +// render (which produces the system theme), then a stale-theme SVG as a last +// resort — a wrong-theme diagram beats no diagram. +static NSString *MDVMermaidSVGForSource(NSString *source) { + NSString *trimmed = [source stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (trimmed.length == 0) { + return nil; + } + + NSString *hash = MDVSHA256Hex(trimmed); + NSString *systemTheme = MDVSystemTheme(); + + NSString *svg = MDVCachedMermaidSVGForTheme(hash, systemTheme); + if (svg) { + return svg; + } + + svg = MDVRequestMermaidRender(trimmed); + if (svg) { + return svg; + } + + NSString *otherTheme = [systemTheme isEqualToString:@"dark"] ? @"light" : @"dark"; + return MDVCachedMermaidSVGForTheme(hash, otherTheme); +} + +static NSString *MDVApplyMermaidRendering(NSString *html) { NSRegularExpression *mermaidBlock = [NSRegularExpression regularExpressionWithPattern: @"
([\\s\\S]*?)
" options:0 error:NULL]; NSArray *matches = [mermaidBlock matchesInString:html options:0 range:NSMakeRange(0, html.length)]; @@ -209,12 +305,24 @@ static void MDVProtectMatches(NSMutableString *text, NSMutableString *result = [html mutableCopy]; for (NSTextCheckingResult *match in matches.reverseObjectEnumerator) { - NSString *source = [html substringWithRange:[match rangeAtIndex:1]]; - NSString *figure = [NSString stringWithFormat: - @"
" - "

Mermaid diagram — open in Markdown Viewer to render it.

" - "
%@
" - "
", source]; + NSString *escapedSource = [html substringWithRange:[match rangeAtIndex:1]]; + NSString *cachedSVG = MDVMermaidSVGForSource(MDVDecodeHTMLEntities(escapedSource)); + + NSString *figure; + if (cachedSVG) { + NSString *dataURI = [NSString stringWithFormat:@"data:image/svg+xml;base64,%@", + [[cachedSVG dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0]]; + figure = [NSString stringWithFormat: + @"
" + "\"Mermaid" + "
", dataURI]; + } else { + figure = [NSString stringWithFormat: + @"
" + "

Could not render Mermaid diagram.

" + "
%@
" + "
", escapedSource]; + } [result replaceCharactersInRange:match.range withString:figure]; } return result; @@ -242,7 +350,7 @@ static void MDVProtectMatches(NSMutableString *text, html = resolved; } - return MDVApplyMermaidFallback(html); + return MDVApplyMermaidRendering(html); } static NSString *MDVDecodeHTMLEntities(NSString *text) { diff --git a/src/register-mermaid-helper.sh b/src/register-mermaid-helper.sh new file mode 100755 index 0000000..c338dc6 --- /dev/null +++ b/src/register-mermaid-helper.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Registers the on-demand Mermaid render helper as a per-user launchd agent. +# Runs as the target user. Usage: register-mermaid-helper.sh "/Applications/Markdown Viewer.app" + +set -euo pipefail + +APP_PATH="${1:?usage: register-mermaid-helper.sh /path/to/Markdown Viewer.app}" +AGENT_LABEL="com.local.markdown-viewer.render-helper" +AGENT_PLIST="$HOME/Library/LaunchAgents/$AGENT_LABEL.plist" + +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$AGENT_PLIST" < + + + + Label + $AGENT_LABEL + ProgramArguments + + $APP_PATH/Contents/MacOS/MarkdownViewerRenderHelper + + MachServices + + $AGENT_LABEL + + + RunAtLoad + + + +PLIST + +launchctl bootout "gui/$(id -u)/$AGENT_LABEL" >/dev/null 2>&1 || true +launchctl bootstrap "gui/$(id -u)" "$AGENT_PLIST" >/dev/null 2>&1 || \ + launchctl load "$AGENT_PLIST" >/dev/null 2>&1 || \ + { printf 'Could not register the mermaid render helper agent.\n' >&2; exit 1; } +echo "render helper agent -> $AGENT_PLIST" diff --git a/src/render-helper.h b/src/render-helper.h new file mode 100644 index 0000000..9c8674c --- /dev/null +++ b/src/render-helper.h @@ -0,0 +1,13 @@ +#import + +// Mach service the on-demand launchd agent registers; the Quick Look +// extension holds a mach-lookup entitlement exception for this exact name. +static NSString *const MDVRenderHelperServiceName = @"com.local.markdown-viewer.render-helper"; + +@protocol MDVRenderHelperProtocol + +- (void)renderMermaidSource:(NSString *)source + theme:(NSString *)theme + withReply:(void (^)(NSString *_Nullable svg, NSString *_Nullable errorMessage))reply; + +@end diff --git a/src/render-helper.m b/src/render-helper.m new file mode 100644 index 0000000..1b1a4bd --- /dev/null +++ b/src/render-helper.m @@ -0,0 +1,249 @@ +// On-demand launchd agent that renders Mermaid diagrams to SVG for the Quick +// Look extension, which cannot host a web content process itself. launchd +// spawns this binary when the extension connects and it exits when idle. +#import +#import +#import + +#import "render-helper.h" + +static const NSTimeInterval MDVIdleExitInterval = 45.0; +static const NSTimeInterval MDVRenderTimeout = 20.0; +static const NSUInteger MDVMaxSourceLength = 1024 * 1024; + +static NSDate *gLastActivity = nil; +static NSInteger gActiveRenders = 0; + +static NSString *MDVSHA256Hex(NSString *text) { + NSData *data = [text dataUsingEncoding:NSUTF8StringEncoding]; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(data.bytes, (CC_LONG)data.length, digest); + NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (NSUInteger index = 0; index < CC_SHA256_DIGEST_LENGTH; index += 1) { + [hex appendFormat:@"%02x", digest[index]]; + } + return hex; +} + +static NSString *MDVMermaidCacheDirectory(void) { + NSString *appSupport = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject; + return [[appSupport stringByAppendingPathComponent:@"Markdown Viewer"] stringByAppendingPathComponent:@"mermaid-cache"]; +} + +static void MDVWriteCachedSVG(NSString *trimmedSource, NSString *theme, NSString *svg) { + NSString *cacheDirectory = MDVMermaidCacheDirectory(); + [[NSFileManager defaultManager] createDirectoryAtPath:cacheDirectory + withIntermediateDirectories:YES + attributes:nil + error:NULL]; + NSString *fileName = [NSString stringWithFormat:@"%@-%@.svg", MDVSHA256Hex(trimmedSource), theme]; + [svg writeToFile:[cacheDirectory stringByAppendingPathComponent:fileName] + atomically:YES + encoding:NSUTF8StringEncoding + error:NULL]; +} + +#pragma mark - Offscreen mermaid rendering + +@interface MDVMermaidRender : NSObject + +@property(nonatomic, strong) WKWebView *webView; +@property(nonatomic, copy) void (^completion)(NSString *_Nullable svg, NSString *_Nullable errorMessage); +@property(nonatomic, assign) NSInteger pollsRemaining; + +@end + +@implementation MDVMermaidRender + +// Keeps renders alive for the duration of their async work; main thread only. +static NSMutableSet *MDVActiveRenderSet(void) { + static NSMutableSet *renders = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + renders = [NSMutableSet set]; + }); + return renders; +} + ++ (void)renderSource:(NSString *)source + theme:(NSString *)theme + completion:(void (^)(NSString *_Nullable, NSString *_Nullable))completion { + NSString *mermaidSource = nil; + NSURL *mermaidURL = [[NSBundle mainBundle] URLForResource:@"mermaid.min" withExtension:@"js" subdirectory:@"vendor"]; + if (mermaidURL) { + mermaidSource = [NSString stringWithContentsOfURL:mermaidURL encoding:NSUTF8StringEncoding error:NULL]; + } + if (!mermaidSource) { + completion(nil, @"mermaid.min.js is missing from the app bundle."); + return; + } + + MDVMermaidRender *render = [[MDVMermaidRender alloc] init]; + render.completion = completion; + [MDVActiveRenderSet() addObject:render]; + + NSString *base64Source = [[source dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0]; + NSString *mermaidTheme = [theme isEqualToString:@"dark"] ? @"dark" : @"default"; + NSString *driverScript = [NSString stringWithFormat: + @"(async () => {" + " try {" + " const bytes = Uint8Array.from(atob(\"%@\"), (c) => c.charCodeAt(0));" + " const source = new TextDecoder().decode(bytes);" + " mermaid.initialize({ startOnLoad: false, securityLevel: \"strict\", theme: \"%@\" });" + " const result = await mermaid.render(\"mdv-diagram\", source);" + " window.__mdvResult = { svg: result.svg };" + " } catch (error) {" + " window.__mdvResult = { error: String(error) };" + " }" + "})();", base64Source, mermaidTheme]; + + // User scripts sidestep any HTML escaping concerns with inline