diff --git a/.gitignore b/.gitignore index 94c3f2d..3575917 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .build/ dist/ Markdown Viewer.app/ +CLAUDE.md +swift/.build/ diff --git a/README.md b/README.md index ad209e2..01f27f6 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,13 @@ 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 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 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 @@ -65,10 +67,24 @@ MDviewer is different: ```bash 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 +./build.sh # builds to dist/Markdown Viewer.app +./build.sh installer # builds dist/Markdown-Viewer-Installer.pkg — a standard + # macOS installer with checkboxes for "default .md viewer" + # and the optional Mermaid Quick Look helper +./install.sh # CLI alternative: 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 @@ -76,6 +92,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..cc5aa48 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" @@ -32,6 +37,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" @@ -40,10 +49,12 @@ KATEX_AUTO_RENDER_SHA256="e5372d199bcdae8b4de71d0f7ceba72a4ba12774a27c60a6f1f77d usage() { cat <<'EOF' Usage: - ./build.sh Build the app bundle into dist/ - ./build.sh build Same as default - ./build.sh archive Build the app bundle and create a release zip - ./build.sh clean Remove dist/ build outputs + ./build.sh Build the app bundle into dist/ + ./build.sh build Same as default + ./build.sh archive Build the app bundle and create a release zip + ./build.sh installer Build a .pkg installer with optional-feature choices + ./build.sh notarize Build, submit to Apple notary service, staple + ./build.sh clean Remove dist/ build outputs EOF } @@ -143,6 +154,75 @@ build_native_binary() { -o "$MACOS_DIR/MarkdownViewer" } +# Picks the best available signing identity: CODESIGN_IDENTITY override, +# then Developer ID Application, then Apple Development, then ad-hoc. +resolve_signing_identity() { + if [ -n "${CODESIGN_IDENTITY:-}" ]; then + printf '%s' "$CODESIGN_IDENTITY" + return + fi + + local identity + identity="$(security find-identity -v -p codesigning 2>/dev/null | awk -F'"' '/Developer ID Application/ {print $2; exit}')" + if [ -n "$identity" ]; then + printf '%s' "$identity" + return + fi + + identity="$(security find-identity -v -p codesigning 2>/dev/null | awk -F'"' '/Apple Development/ {print $2; exit}')" + if [ -n "$identity" ]; then + printf '%s' "$identity" + return + fi + + printf '%s' "-" +} + +build_render_helper() { + clang \ + -fobjc-arc \ + -Wall \ + -Wextra \ + -Wno-unused-parameter \ + -isysroot "$SDK_PATH" \ + -framework Cocoa \ + -framework Security \ + -framework WebKit \ + "$SRC_DIR/render-helper.m" \ + -o "$MACOS_DIR/MarkdownViewerRenderHelper" +} + +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" @@ -238,6 +318,11 @@ 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/set-default-handler.py" "$RESOURCES_DIR/set-default-handler.py" + cp "$SRC_DIR/register-mermaid-helper.sh" "$RESOURCES_DIR/register-mermaid-helper.sh" + chmod 755 "$RESOURCES_DIR/register-mermaid-helper.sh" + cp "$SRC_DIR/register-quicklook-extension.sh" "$RESOURCES_DIR/register-quicklook-extension.sh" + chmod 755 "$RESOURCES_DIR/register-quicklook-extension.sh" cp "$SCRIPT_DIR/LICENSE" "$RESOURCES_DIR/LICENSE" extract_npm_file "marked" "$MARKED_VERSION" "$MARKED_FILE" "$VENDOR_DIR/marked.umd.js" "$MARKED_SHA256" @@ -251,17 +336,50 @@ 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" + + build_render_helper + build_quicklook_extension chmod 755 "$RESOURCES_DIR/MarkdownViewer.sh" plutil -lint "$CONTENTS_DIR/Info.plist" >/dev/null bash -n "$RESOURCES_DIR/MarkdownViewer.sh" + # Signing: prefers Developer ID (notarizable distribution), then Apple + # Development (real local identity), then ad-hoc. Override with + # CODESIGN_IDENTITY. A Mac App Store build instead needs an Apple + # Distribution cert + provisioning, App Sandbox on every binary, and no + # temporary-exception entitlements. if command -v codesign >/dev/null 2>&1; then - if ! codesign --force --deep --sign - "$APP_DIR" >/dev/null 2>&1; then - printf 'Warning: ad-hoc codesign failed; continuing with unsigned bundle.\n' >&2 + local identity sign_flags + identity="$(resolve_signing_identity)" + sign_flags=() + case "$identity" in + "Developer ID Application"*) + # Hardened runtime + secure timestamp are notarization requirements. + sign_flags=(--options runtime --timestamp) + ;; + esac + + if ! codesign --force --sign "$identity" "${sign_flags[@]+"${sign_flags[@]}"}" --entitlements "$SRC_DIR/quicklook.entitlements" "$QL_APPEX_DIR" >/dev/null 2>&1; then + printf 'Warning: codesign of the Quick Look extension failed; Finder previews may not work.\n' >&2 + fi + if ! codesign --force --sign "$identity" "${sign_flags[@]+"${sign_flags[@]}"}" "$MACOS_DIR/MarkdownViewerRenderHelper" >/dev/null 2>&1; then + printf 'Warning: codesign of the render helper failed.\n' >&2 + fi + if ! codesign --force --sign "$identity" "${sign_flags[@]+"${sign_flags[@]}"}" "$APP_DIR" >/dev/null 2>&1; then + printf 'Warning: 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 fi + + if [ "$identity" = "-" ]; then + echo "Signed ad-hoc (no signing identity in keychain; set one up in Xcode > Settings > Accounts)" + else + echo "Signed with: $identity" + fi fi echo "Done! Built -> $APP_DIR" @@ -273,6 +391,116 @@ archive_bundle() { echo "Archive -> $ARCHIVE_PATH" } +# Builds a macOS installer package with a customization step: the app itself +# (required) plus optional choices for the default .md handler and the +# Mermaid Quick Look helper. +build_installer() { + # MDV_SKIP_BUILD=1 packs the bundle already in dist/ (used by the Swift + # port's build script to ship its own binaries in the installer). + if [ "${MDV_SKIP_BUILD:-0}" != "1" ]; then + build_bundle + elif [ ! -d "$APP_DIR" ]; then + printf 'MDV_SKIP_BUILD=1 but no bundle at %s\n' "$APP_DIR" >&2 + exit 1 + fi + + require_command pkgbuild + require_command productbuild + + local version pkg_dir root_dir installer_path + version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$SRC_DIR/Info.plist")" + pkg_dir="$BUILD_DIR/pkg" + root_dir="$pkg_dir/root" + installer_path="$DIST_DIR/Markdown-Viewer-Installer.pkg" + + rm -rf "$pkg_dir" "$installer_path" + mkdir -p "$root_dir/Applications" + ditto "$APP_DIR" "$root_dir/Applications/$APP_NAME.app" + + # Installer silently fails a component whose script is not executable; + # stage the scripts with the exec bit forced so checkout modes can't matter. + local scripts_dir="$pkg_dir/scripts" + ditto "$SCRIPT_DIR/installer/scripts" "$scripts_dir" + find "$scripts_dir" -type f -exec chmod 755 {} + + + pkgbuild --quiet \ + --root "$root_dir" \ + --scripts "$scripts_dir/app" \ + --identifier "com.local.markdown-viewer.pkg.app" \ + --version "$version" \ + --install-location "/" \ + "$pkg_dir/app.pkg" + + pkgbuild --quiet \ + --nopayload \ + --scripts "$scripts_dir/default-handler" \ + --identifier "com.local.markdown-viewer.pkg.default-handler" \ + --version "$version" \ + "$pkg_dir/default-handler.pkg" + + pkgbuild --quiet \ + --nopayload \ + --scripts "$scripts_dir/quicklook-preferred" \ + --identifier "com.local.markdown-viewer.pkg.quicklook-preferred" \ + --version "$version" \ + "$pkg_dir/quicklook-preferred.pkg" + + pkgbuild --quiet \ + --nopayload \ + --scripts "$scripts_dir/mermaid-helper" \ + --identifier "com.local.markdown-viewer.pkg.mermaid-helper" \ + --version "$version" \ + "$pkg_dir/mermaid-helper.pkg" + + sed "s/@VERSION@/$version/g" "$SCRIPT_DIR/installer/distribution.xml" > "$pkg_dir/distribution.xml" + + local installer_identity + installer_identity="$(security find-identity -v 2>/dev/null | awk -F'"' '/Developer ID Installer/ {print $2; exit}')" + + if [ -n "$installer_identity" ]; then + productbuild --quiet \ + --distribution "$pkg_dir/distribution.xml" \ + --package-path "$pkg_dir" \ + --resources "$SCRIPT_DIR/installer/resources" \ + --sign "$installer_identity" \ + "$installer_path" + echo "Installer (signed: $installer_identity) -> $installer_path" + else + productbuild --quiet \ + --distribution "$pkg_dir/distribution.xml" \ + --package-path "$pkg_dir" \ + --resources "$SCRIPT_DIR/installer/resources" \ + "$installer_path" + echo "Installer (unsigned) -> $installer_path" + fi +} + +# Submits the release zip to Apple's notary service and staples the ticket. +# One-time setup: xcrun notarytool store-credentials mdviewer-notary \ +# --apple-id --team-id (uses an app-specific password). +notarize_archive() { + local identity + identity="$(resolve_signing_identity)" + case "$identity" in + "Developer ID Application"*) ;; + *) + printf 'Notarization needs a "Developer ID Application" certificate in the keychain (found: %s).\n' "$identity" >&2 + exit 1 + ;; + esac + + archive_bundle + + echo "Submitting to Apple notary service (this can take a few minutes)..." + xcrun notarytool submit "$ARCHIVE_PATH" --keychain-profile "${NOTARY_PROFILE:-mdviewer-notary}" --wait + xcrun stapler staple "$APP_DIR" + + # Re-zip so the archive contains the stapled bundle. + rm -f "$ARCHIVE_PATH" + ditto -c -k --sequesterRsrc --keepParent "$APP_DIR" "$ARCHIVE_PATH" + echo "Notarized + stapled -> $ARCHIVE_PATH" +} + clean_outputs() { rm -rf "$DIST_DIR" echo "Removed $DIST_DIR" @@ -288,6 +516,12 @@ main() { archive) archive_bundle ;; + installer) + build_installer + ;; + notarize) + notarize_archive + ;; clean) clean_outputs ;; diff --git a/install.sh b/install.sh index bce0be4..810c3ef 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 @@ -30,127 +66,31 @@ 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 local bundle_id bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$TARGET_APP/Contents/Info.plist")" - python3 - "$bundle_id" "$LAUNCH_SERVICES_PLIST" <<'PY' -import ctypes -import os -import plistlib -import sys -from contextlib import contextmanager - -bundle_id = sys.argv[1] -plist_path = os.path.expanduser(sys.argv[2]) - -EXTENSIONS = ["md", "markdown", "mdown", "mkd"] -CONTENT_TYPES = {"net.daringfireball.markdown"} -UTF8 = 0x08000100 -LS_ROLES_ALL = 0xFFFFFFFF - -CF = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation") -CS = ctypes.CDLL("/System/Library/Frameworks/CoreServices.framework/CoreServices") - -CF.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32] -CF.CFStringCreateWithCString.restype = ctypes.c_void_p -CF.CFRelease.argtypes = [ctypes.c_void_p] -CF.CFRelease.restype = None -CF.CFStringGetCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.c_uint32] -CF.CFStringGetCString.restype = ctypes.c_bool - -CS.UTTypeCreatePreferredIdentifierForTag.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p] -CS.UTTypeCreatePreferredIdentifierForTag.restype = ctypes.c_void_p -CS.LSSetDefaultRoleHandlerForContentType.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p] -CS.LSSetDefaultRoleHandlerForContentType.restype = ctypes.c_int32 -CS.LSCopyDefaultRoleHandlerForContentType.argtypes = [ctypes.c_void_p, ctypes.c_uint32] -CS.LSCopyDefaultRoleHandlerForContentType.restype = ctypes.c_void_p - - -@contextmanager -def cfstr(value): - ref = CF.CFStringCreateWithCString(None, value.encode("utf-8"), UTF8) - try: - yield ref - finally: - CF.CFRelease(ref) - - -def cfstr_to_python(ref): - buf = ctypes.create_string_buffer(4096) - if not CF.CFStringGetCString(ref, buf, len(buf), UTF8): - raise RuntimeError("Could not convert CFString") - return buf.value.decode("utf-8") - - -# --- Resolve extensions to UTIs and register as default handler --- - -with cfstr(bundle_id) as bundle_cf, cfstr("public.filename-extension") as tag_class_cf: - for ext in EXTENSIONS: - with cfstr(ext) as ext_cf: - uti_cf = CS.UTTypeCreatePreferredIdentifierForTag(tag_class_cf, ext_cf, None) - if uti_cf: - CONTENT_TYPES.add(cfstr_to_python(uti_cf)) - CF.CFRelease(uti_cf) - - for ct in sorted(CONTENT_TYPES): - with cfstr(ct) as ct_cf: - status = CS.LSSetDefaultRoleHandlerForContentType(ct_cf, LS_ROLES_ALL, bundle_cf) - if status != 0: - raise RuntimeError(f"LSSetDefaultRoleHandlerForContentType failed for {ct}: {status}") - - current_cf = CS.LSCopyDefaultRoleHandlerForContentType(ct_cf, LS_ROLES_ALL) - if not current_cf: - raise RuntimeError(f"Could not verify default handler for {ct}") - - current = cfstr_to_python(current_cf) - CF.CFRelease(current_cf) - - if current != bundle_id: - raise RuntimeError(f"Handler mismatch for {ct}: expected {bundle_id}, got {current}") - - print(f"default handler set: {ct} -> {current}") - - -# --- Update LaunchServices plist --- - -def is_markdown_handler(h): - if h.get("LSHandlerContentType") in CONTENT_TYPES: - return True - if (h.get("LSHandlerContentTagClass") == "public.filename-extension" - and h.get("LSHandlerContentTag") in EXTENSIONS): - return True - return False - - -payload = {} -if os.path.exists(plist_path): - with open(plist_path, "rb") as f: - payload = plistlib.load(f) - -version_pref = {"LSHandlerRoleAll": "-"} -handlers = [h for h in payload.get("LSHandlers", []) if not is_markdown_handler(h)] - -for ct in sorted(CONTENT_TYPES): - handlers.append({"LSHandlerContentType": ct, "LSHandlerRoleAll": bundle_id, - "LSHandlerPreferredVersions": version_pref}) - -for ext in EXTENSIONS: - handlers.append({"LSHandlerContentTag": ext, "LSHandlerContentTagClass": "public.filename-extension", - "LSHandlerRoleAll": bundle_id, "LSHandlerPreferredVersions": version_pref}) - -payload["LSHandlers"] = handlers -os.makedirs(os.path.dirname(plist_path), exist_ok=True) - -with open(plist_path, "wb") as f: - plistlib.dump(payload, f, fmt=plistlib.FMT_BINARY) -PY + python3 "$TARGET_APP/Contents/Resources/set-default-handler.py" "$bundle_id" "$LAUNCH_SERVICES_PLIST" "$LSREGISTER" -kill -seed -r -domain local -domain system -domain user >/dev/null 2>&1 || true "$LSREGISTER" -f "$TARGET_APP" >/dev/null + # Elect our Quick Look extension over any other Markdown previewer + # (e.g. QLMarkdown) already registered for the same file types. + "$TARGET_APP/Contents/Resources/register-quicklook-extension.sh" "$TARGET_APP" || \ + printf 'Warning: could not elect the Quick Look extension.\n' >&2 + + 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/installer/distribution.xml b/installer/distribution.xml new file mode 100644 index 0000000..ae96fd3 --- /dev/null +++ b/installer/distribution.xml @@ -0,0 +1,43 @@ + + + Markdown Viewer + + + + + + + + + + + + + + + + + + + + + + + app.pkg + default-handler.pkg + quicklook-preferred.pkg + mermaid-helper.pkg + diff --git a/installer/resources/conclusion.rtf b/installer/resources/conclusion.rtf new file mode 100644 index 0000000..f6a28aa --- /dev/null +++ b/installer/resources/conclusion.rtf @@ -0,0 +1,12 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2867 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica-Light;} +{\colortbl;\red255\green255\blue255;} +{\*\expandedcolortbl;;} +\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0 + +\f0\fs24 \cf0 Markdown Viewer is installed.\ +\ +Double-click any Markdown file to open it, or select one in Finder and press Space for a Quick Look preview.\ +\ +Preferences (document font) live under Markdown Viewer > Settings... (Command-,).\ +} \ No newline at end of file diff --git a/installer/resources/welcome.rtf b/installer/resources/welcome.rtf new file mode 100644 index 0000000..4c5a476 --- /dev/null +++ b/installer/resources/welcome.rtf @@ -0,0 +1,16 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2867 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica-Light;} +{\colortbl;\red255\green255\blue255;} +{\*\expandedcolortbl;;} +\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0 + +\f0\fs24 \cf0 Markdown Viewer opens any Markdown file as a clean, print-ready document, with Quick Look previews in Finder.\ +\ +On the next steps you can choose:\ +\ +- whether to make it your default .md viewer,\ +- whether to use it for Quick Look previews (taking over from any other Markdown Quick Look extension, such as QLMarkdown), and\ +- whether to add the optional Mermaid Quick Look helper for live diagram rendering in previews.\ +\ +Everything runs locally. The app makes no network requests on its own.\ +} \ No newline at end of file diff --git a/installer/scripts/app/preinstall b/installer/scripts/app/preinstall new file mode 100755 index 0000000..9b3d994 --- /dev/null +++ b/installer/scripts/app/preinstall @@ -0,0 +1,8 @@ +#!/bin/bash +# Files left behind from an older install would break the new bundle's +# code-signature seal; remove the previous copy before the payload lands. + +set -euo pipefail + +rm -rf "/Applications/Markdown Viewer.app" +exit 0 diff --git a/installer/scripts/default-handler/postinstall b/installer/scripts/default-handler/postinstall new file mode 100755 index 0000000..7aa8257 --- /dev/null +++ b/installer/scripts/default-handler/postinstall @@ -0,0 +1,18 @@ +#!/bin/bash +# Runs as root; applies the per-user default-handler registration as the +# console user via the script shipped inside the installed app. + +set -euo pipefail + +APP_PATH="/Applications/Markdown Viewer.app" +HANDLER_SCRIPT="$APP_PATH/Contents/Resources/set-default-handler.py" +BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Contents/Info.plist")" + +console_user="$(stat -f %Su /dev/console)" +if [ -z "$console_user" ] || [ "$console_user" = "root" ] || [ ! -f "$HANDLER_SCRIPT" ]; then + exit 0 +fi +uid="$(id -u "$console_user")" + +launchctl asuser "$uid" sudo -u "$console_user" /usr/bin/python3 "$HANDLER_SCRIPT" "$BUNDLE_ID" || true +exit 0 diff --git a/installer/scripts/mermaid-helper/postinstall b/installer/scripts/mermaid-helper/postinstall new file mode 100755 index 0000000..0f16806 --- /dev/null +++ b/installer/scripts/mermaid-helper/postinstall @@ -0,0 +1,17 @@ +#!/bin/bash +# Runs as root; registers the per-user mermaid render helper agent as the +# console user via the script shipped inside the installed app. + +set -euo pipefail + +APP_PATH="/Applications/Markdown Viewer.app" +REGISTER_SCRIPT="$APP_PATH/Contents/Resources/register-mermaid-helper.sh" + +console_user="$(stat -f %Su /dev/console)" +if [ -z "$console_user" ] || [ "$console_user" = "root" ] || [ ! -x "$REGISTER_SCRIPT" ]; then + exit 0 +fi +uid="$(id -u "$console_user")" + +launchctl asuser "$uid" sudo -u "$console_user" "$REGISTER_SCRIPT" "$APP_PATH" || true +exit 0 diff --git a/installer/scripts/quicklook-preferred/postinstall b/installer/scripts/quicklook-preferred/postinstall new file mode 100755 index 0000000..6b09070 --- /dev/null +++ b/installer/scripts/quicklook-preferred/postinstall @@ -0,0 +1,17 @@ +#!/bin/bash +# Runs as root; applies the per-user Quick Look extension election as the +# console user via the script shipped inside the installed app. + +set -euo pipefail + +APP_PATH="/Applications/Markdown Viewer.app" +REGISTER_SCRIPT="$APP_PATH/Contents/Resources/register-quicklook-extension.sh" + +console_user="$(stat -f %Su /dev/console)" +if [ -z "$console_user" ] || [ "$console_user" = "root" ] || [ ! -x "$REGISTER_SCRIPT" ]; then + exit 0 +fi +uid="$(id -u "$console_user")" + +launchctl asuser "$uid" sudo -u "$console_user" "$REGISTER_SCRIPT" "$APP_PATH" || true +exit 0 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/main.m b/src/main.m index 1b63ece..f852660 100644 --- a/src/main.m +++ b/src/main.m @@ -1,9 +1,13 @@ #import +#import #import #import #import static NSString *const MDVErrorDomain = @"com.local.markdown-viewer"; +static NSString *const MDVPreferredFontKey = @"MDVPreferredFont"; +static NSString *const MDVDidOfferDefaultHandlerKey = @"MDVDidOfferDefaultHandler"; +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,7 +30,42 @@ static BOOL MDVURLLooksLikeMarkdown(NSURL *url) { userInfo:@{NSLocalizedDescriptionKey: description ?: @"Unknown error."}]; } -@interface MDVPreviewWindowController : NSWindowController +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]; +} + +// 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; @@ -82,9 +121,66 @@ - (instancetype)init { [window.contentView addSubview:self.webView]; [window setInitialFirstResponder:self.webView]; + [self installPreferredFontUserScript]; + [self.webView.configuration.userContentController addScriptMessageHandler:self name:@"mermaidRendered"]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(preferredFontDidChange:) + name:MDVPreferredFontDidChangeNotification + object:nil]; + 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]; + 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; } @@ -436,6 +532,7 @@ - (void)stopWatchingSourceFile { } - (void)windowWillClose:(NSNotification *)notification { + [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"mermaidRendered"]; [self stopWatchingSourceFile]; [self clearPendingScrollRestore]; if (self.closeHandler) { @@ -533,6 +630,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 +667,19 @@ - (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:@""]; + 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 +833,76 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification { if (!self.openedFileDuringLaunch) { [self openDocument:nil]; } - [self checkForUpdates]; + [self offerToBecomeDefaultMarkdownViewer]; +} + +// The markdown content types the app declares, resolved once per launch. +- (NSArray *)markdownContentTypes { + NSMutableArray *types = [NSMutableArray array]; + NSMutableSet *seen = [NSMutableSet set]; + + UTType *declared = [UTType typeWithIdentifier:@"net.daringfireball.markdown"]; + if (declared) { + [types addObject:declared]; + [seen addObject:declared.identifier]; + } + for (NSString *extension in @[@"md", @"markdown", @"mdown", @"mkd"]) { + UTType *type = [UTType typeWithFilenameExtension:extension]; + if (type && ![seen containsObject:type.identifier]) { + [types addObject:type]; + [seen addObject:type.identifier]; + } + } + return types; } -- (void)checkForUpdates { +// Asked once, on the first launch where another app (usually Xcode on a +// developer Mac) owns Markdown files. +- (void)offerToBecomeDefaultMarkdownViewer { + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + if ([defaults boolForKey:MDVDidOfferDefaultHandlerKey]) { + return; + } + + NSArray *types = [self markdownContentTypes]; + if (types.count == 0) { + return; + } + + NSWorkspace *workspace = [NSWorkspace sharedWorkspace]; + NSURL *currentHandlerURL = [workspace URLForApplicationToOpenContentType:types.firstObject]; + NSString *currentBundleID = currentHandlerURL ? [NSBundle bundleWithURL:currentHandlerURL].bundleIdentifier : nil; + + [defaults setBool:YES forKey:MDVDidOfferDefaultHandlerKey]; + + if ([currentBundleID isEqualToString:[NSBundle mainBundle].bundleIdentifier]) { + return; + } + + NSString *currentName = currentHandlerURL + ? [[NSFileManager defaultManager] displayNameAtPath:currentHandlerURL.path] + : @"another app"; + + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = @"Open Markdown files with Markdown Viewer?"; + alert.informativeText = [NSString stringWithFormat: + @"Markdown files currently open in %@. Make Markdown Viewer the default so double-clicking a .md file shows a rendered preview?\n\nYou can change this anytime via Get Info in Finder.", currentName]; + [alert addButtonWithTitle:@"Make Default"]; + [alert addButtonWithTitle:@"Not Now"]; + + if ([alert runModal] != NSAlertFirstButtonReturn) { + return; + } + + NSURL *appURL = [NSBundle mainBundle].bundleURL; + for (UTType *type in types) { + [workspace setDefaultApplicationAtURL:appURL toOpenContentType:type completionHandler:nil]; + } +} + +// 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 +910,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"]; @@ -901,6 +1088,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) { @@ -932,7 +1169,8 @@ - (void)findPreviousMatch:(id)sender { - (BOOL)validateUserInterfaceItem:(id)item { SEL action = item.action; - if (action == @selector(openDocument:)) { + if (action == @selector(openDocument:) || action == @selector(showSettings:) || + action == @selector(checkForUpdates:)) { return YES; } @@ -958,6 +1196,11 @@ - (BOOL)validateUserInterfaceItem:(id)item { @end +// TODO(distribution): sandbox the main app (App Sandbox is mandatory for the +// Mac App Store): user-selected read/write for open/export, security-scoped +// bookmarks for recent documents and live reload, network.client for the +// manual update check, and an app group shared with the Quick Look extension +// to replace the mermaid cache's home-relative path. int main(int argc, const char *argv[]) { @autoreleasepool { NSApplication *application = [NSApplication sharedApplication]; diff --git a/src/quicklook.entitlements b/src/quicklook.entitlements new file mode 100644 index 0000000..df27438 --- /dev/null +++ b/src/quicklook.entitlements @@ -0,0 +1,22 @@ + + + + + com.apple.security.app-sandbox + + + 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 new file mode 100644 index 0000000..e870f3f --- /dev/null +++ b/src/quicklook.m @@ -0,0 +1,527 @@ +#import +#import +#import +#import +#import +#import +#import + +#import "render-helper.h" + +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 + +// Mermaid needs a real browser engine, which the Quick Look sandbox refuses to +// 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)]; + if (matches.count == 0) { + return html; + } + + NSMutableString *result = [html mutableCopy]; + for (NSTextCheckingResult *match in matches.reverseObjectEnumerator) { + 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; +} + +#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 MDVApplyMermaidRendering(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 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/register-quicklook-extension.sh b/src/register-quicklook-extension.sh new file mode 100755 index 0000000..b16c4dc --- /dev/null +++ b/src/register-quicklook-extension.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Registers the Quick Look extension with PluginKit and elects it as the +# preferred previewer for Markdown files, overriding any other Quick Look +# extension currently handling them (e.g. QLMarkdown). Runs as the target +# user. Usage: register-quicklook-extension.sh "/Applications/Markdown Viewer.app" + +set -euo pipefail + +APP_PATH="${1:?usage: register-quicklook-extension.sh /path/to/Markdown Viewer.app}" +APPEX_PATH="$APP_PATH/Contents/PlugIns/MarkdownViewerQuickLook.appex" +LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + +"$LSREGISTER" -f "$APP_PATH" >/dev/null 2>&1 || true +pluginkit -a "$APPEX_PATH" >/dev/null 2>&1 || true +pluginkit -e use -i com.local.markdown-viewer.quicklook >/dev/null 2>&1 +qlmanage -r >/dev/null 2>&1 || true +qlmanage -r cache >/dev/null 2>&1 || true +echo "Quick Look extension elected: com.local.markdown-viewer.quicklook" 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..60bedda --- /dev/null +++ b/src/render-helper.m @@ -0,0 +1,293 @@ +// 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 + +#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