Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
.build/
dist/
Markdown Viewer.app/
CLAUDE.md
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -65,17 +67,32 @@ 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

| Action | Shortcut |
|---|---|
| Open file | `Cmd+O` |
| Settings | `Cmd+,` |
| Find in document | `Cmd+F` |
| Next match | `Cmd+G` |
| Previous match | `Cmd+Shift+G` |
Expand Down
246 changes: 240 additions & 6 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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
}

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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 <you> --team-id <TEAMID> (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"
Expand All @@ -288,6 +516,12 @@ main() {
archive)
archive_bundle
;;
installer)
build_installer
;;
notarize)
notarize_archive
;;
clean)
clean_outputs
;;
Expand Down
Loading