|
#!/bin/sh |
|
# |
|
# Install MarkEdit and its extensions on macOS. |
|
# Location: https://gist.github.com/heathdutton/b0a8149f1a159efac23fe97df7768f47 |
|
# Run this using: curl -L markedit.install.id|sh |
|
# |
|
# Needs macOS 15+ and nothing else. Homebrew is used when it is already installed, otherwise |
|
# the app is downloaded straight from GitHub releases and verified before it is installed. |
|
# Extensions come from the official registry, pinned and checked by SHA-256. |
|
# |
|
# Safe to rerun: it upgrades what is there, and it asks before quitting a running MarkEdit. |
|
# |
|
# Env: |
|
# MARKEDIT_DEMO=1 always write and open the demo document |
|
# MARKEDIT_QUIT=1 pre-approve quitting MarkEdit for an upgrade |
|
# MARKEDIT_THEMING=1 also clone the theme-development library |
|
# MARKEDIT_APP_DIR=<dir> install the app somewhere other than /Applications |
|
# MARKEDIT_REGISTRY_BASE=<url> use a registry mirror |
|
|
|
set -eu |
|
# pipefail is not POSIX. macOS /bin/sh is bash and has it, so take it when it is on offer. |
|
(set -o pipefail) 2>/dev/null && set -o pipefail || true |
|
|
|
BUNDLE_ID="app.cyan.markedit" |
|
APP_REPO="MarkEdit-app/MarkEdit" |
|
APP_DIR="${MARKEDIT_APP_DIR:-/Applications}" |
|
CONTAINER="${MARKEDIT_CONTAINER:-$HOME/Library/Containers/$BUNDLE_ID/Data/Documents}" |
|
THEMING_DIR="$HOME/.local/share/markedit-theming" |
|
REGISTRY_BASE="${MARKEDIT_REGISTRY_BASE:-https://raw.githubusercontent.com/MarkEdit-app/extensions/main}" |
|
DEMO_NAME="MarkEdit Demo.md" |
|
DEMO_DIR="$HOME/Documents" |
|
DEMO_URL="https://gist.githubusercontent.com/heathdutton/b0a8149f1a159efac23fe97df7768f47/raw/markedit-demo.md" |
|
REGISTRY_INDEX="$REGISTRY_BASE/index.json" |
|
SCHEMA_URL="https://raw.githubusercontent.com/MarkEdit-app/schemas/main/extensions.json" |
|
NL=' |
|
' |
|
|
|
# Registry ids to install. Each one is also the installed filename, which is the convention |
|
# MarkEdit's extension manager expects. |
|
# |
|
# language-data is not optional in practice: 1.34.0 moved code-block syntax highlighting out |
|
# of the app and into this extension, so without it fenced code blocks render flat. |
|
EXTENSIONS="markedit-preview markedit-language-data markedit-ai-writer" |
|
|
|
say() { printf '%s\n' "$*"; } |
|
die() { printf 'error: %s\n' "$*" >&2; exit 1; } |
|
|
|
# Fail on HTTP errors rather than writing an error page to disk, and retry a flaky network. |
|
fetch() { |
|
curl -fsSL --connect-timeout 10 --max-time 300 \ |
|
--retry 3 --retry-delay 1 --retry-connrefused "$@" |
|
} |
|
|
|
# With `curl | bash`, stdin is the script itself, so prompts have to use the terminal. |
|
have_tty() { [ -c /dev/tty ] && (: < /dev/tty) 2>/dev/null; } |
|
|
|
ask() { |
|
reply="" |
|
have_tty || return 1 |
|
printf '%s ' "$1" > /dev/tty |
|
read -r reply < /dev/tty || return 1 |
|
case "$reply" in |
|
[yY] | [yY][eE][sS]) return 0 ;; |
|
*) return 1 ;; |
|
esac |
|
} |
|
|
|
# Quit gracefully so an unsaved document gets its save prompt instead of being killed. |
|
# Returns non-zero if it is still running afterwards, which happens when a dialog is waiting |
|
# or when automation permission was denied, and the caller then leaves the app alone. |
|
quit_markedit() { |
|
osascript -e 'quit app "MarkEdit"' >/dev/null 2>&1 || return 1 |
|
waited=0 |
|
while pgrep -x MarkEdit >/dev/null 2>&1 && [ "$waited" -lt 15 ]; do |
|
sleep 1 |
|
waited=$((waited + 1)) |
|
done |
|
! pgrep -x MarkEdit >/dev/null 2>&1 |
|
} |
|
|
|
# True when $1 is an older version than $2. Field-wise numeric sort, because BSD sort has no -V. |
|
version_lt() { |
|
if [ "$1" = "$2" ]; then |
|
return 1 |
|
fi |
|
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -t. -k1,1n -k2,2n -k3,3n | head -1)" = "$1" ] |
|
} |
|
|
|
# First "<key>": "<value>" in a JSON file. The registry lists versions newest-first, so the |
|
# first hit for version/url/sha256 is the current release. |
|
json_first() { |
|
LC_ALL=C sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1 |
|
} |
|
|
|
sha_of() { shasum -a 256 "$1" | cut -d' ' -f1; } |
|
|
|
app_path() { [ -d "$APP_DIR/MarkEdit.app" ] && printf '%s' "$APP_DIR/MarkEdit.app"; } |
|
|
|
app_version() { |
|
path="$(app_path)" || return 0 |
|
defaults read "$path/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || true |
|
} |
|
|
|
# Newest release tag, read off the redirect rather than the API, so no token is needed. |
|
release_tag() { |
|
curl -fsSLI -o /dev/null -w '%{url_effective}' --connect-timeout 10 --max-time 60 \ |
|
"https://github.com/$1/releases/latest" 2>/dev/null | sed 's#.*/tag/v\{0,1\}##' |
|
} |
|
|
|
# Ask (once) to close a running MarkEdit so its bundle can be replaced. Sets relaunch_after. |
|
close_for_upgrade() { |
|
pgrep -x MarkEdit >/dev/null 2>&1 || return 0 |
|
say "MarkEdit is running and an upgrade is waiting." |
|
if [ "${MARKEDIT_QUIT:-}" = "1" ] || ask "Quit MarkEdit and upgrade it? Unsaved work will prompt. [y/N]"; then |
|
if quit_markedit; then |
|
relaunch_after=1 |
|
return 0 |
|
fi |
|
say " -> MarkEdit is still running, so the upgrade was skipped." |
|
say " Answer its save prompt, or allow automation for this terminal, then rerun." |
|
return 1 |
|
fi |
|
say " -> left running, upgrade skipped. Rerun once it's closed." |
|
return 1 |
|
} |
|
|
|
# --- Preflight --- |
|
[ "$(uname -s)" = "Darwin" ] || die "macOS only, this looks like $(uname -s)." |
|
[ "$(id -u)" -ne 0 ] || die "don't run this as root, the app and its container belong to your user." |
|
command -v curl >/dev/null 2>&1 || die "curl is missing, which should not be possible on macOS." |
|
|
|
os_version="$(sw_vers -productVersion 2>/dev/null || echo 0)" |
|
case "${os_version%%.*}" in |
|
'' | *[!0-9]*) die "could not read the macOS version." ;; |
|
*) [ "${os_version%%.*}" -ge 15 ] || die "MarkEdit needs macOS 15 or later, this is $os_version." ;; |
|
esac |
|
|
|
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/markedit-install.XXXXXX")" |
|
trap 'rm -rf "$WORK_DIR"' EXIT |
|
relaunch_after=0 |
|
fresh_install=0 |
|
|
|
# --- Homebrew, used only when it is already here --- |
|
# Never installed on demand: the direct path below needs nothing macOS doesn't already ship, |
|
# so there is no reason to drag in a package manager or ask for an admin password. |
|
# |
|
# Detection has to be generous. A `curl | bash` from fish, or any shell that never sourced |
|
# shellenv, can have brew installed and absent from PATH, and mistaking that for "no Homebrew" |
|
# is how a machine ends up being asked to install a Homebrew it already has. |
|
if ! command -v brew >/dev/null 2>&1; then |
|
for candidate in "${HOMEBREW_PREFIX:-/nonexistent}/bin/brew" /opt/homebrew/bin/brew /usr/local/bin/brew; do |
|
if [ -x "$candidate" ]; then |
|
eval "$("$candidate" shellenv)" |
|
break |
|
fi |
|
done |
|
fi |
|
|
|
# --- MarkEdit, through Homebrew when it manages the app --- |
|
install_app_brew() { |
|
if ! brew list --cask markedit >/dev/null 2>&1; then |
|
say "Installing MarkEdit..." |
|
fresh_install=1 |
|
brew install --cask markedit |
|
return 0 |
|
fi |
|
|
|
# Captured rather than piped: this must not depend on brew's exit code. Deliberately not |
|
# --greedy, because 1.34.0 updates itself, so brew's receipt goes stale and greedy would |
|
# claim an upgrade on every run. |
|
outdated="$(brew outdated --cask markedit 2>/dev/null || true)" |
|
if [ -z "$outdated" ]; then |
|
say "MarkEdit is up to date." |
|
return 0 |
|
fi |
|
|
|
close_for_upgrade || return 0 |
|
say "Upgrading MarkEdit..." |
|
brew upgrade --cask markedit |
|
} |
|
|
|
# --- MarkEdit, straight from GitHub releases when Homebrew is not in the picture --- |
|
install_app_direct() { |
|
# A Caskroom entry with no working brew means Homebrew owns this app and we simply cannot |
|
# see it. Replacing the bundle here would leave brew's receipt pointing at a version that |
|
# is no longer on disk, so stop and let the user run it where brew works. |
|
for caskroom in /opt/homebrew/Caskroom/markedit /usr/local/Caskroom/markedit; do |
|
if [ -d "$caskroom" ]; then |
|
die "Homebrew installed MarkEdit ($caskroom) but brew isn't usable here, so this run would desync it. Open a terminal where 'brew --version' works and rerun." |
|
fi |
|
done |
|
|
|
latest="$(release_tag "$APP_REPO")" |
|
[ -n "$latest" ] || die "could not work out the latest MarkEdit release." |
|
installed="$(app_version)" |
|
|
|
if [ -n "$installed" ]; then |
|
if [ "$installed" = "$latest" ] || ! version_lt "$installed" "$latest"; then |
|
say "MarkEdit is up to date ($installed)." |
|
return 0 |
|
fi |
|
close_for_upgrade || return 0 |
|
else |
|
fresh_install=1 |
|
fi |
|
|
|
arch="$(uname -m)" |
|
case "$arch" in |
|
arm64) asset="UpdateArchive-arm64.zip" ;; |
|
x86_64) asset="UpdateArchive.zip" ;; |
|
*) die "unsupported architecture: $arch" ;; |
|
esac |
|
|
|
say "Downloading MarkEdit $latest ($asset)..." |
|
archive="$WORK_DIR/$asset" |
|
fetch -o "$archive" "https://github.com/$APP_REPO/releases/latest/download/$asset" \ |
|
|| die "could not download $asset." |
|
|
|
stage="$WORK_DIR/stage" |
|
mkdir -p "$stage" |
|
ditto -x -k "$archive" "$stage" 2>/dev/null || die "could not expand $asset." |
|
|
|
staged="$stage/MarkEdit.app" |
|
[ -d "$staged" ] || die "the archive did not contain MarkEdit.app." |
|
if [ -n "$(find "$stage" -type l 2>/dev/null | head -1)" ]; then |
|
die "the archive contains symlinks, refusing to install it." |
|
fi |
|
|
|
staged_id="$(defaults read "$staged/Contents/Info.plist" CFBundleIdentifier 2>/dev/null || true)" |
|
[ "$staged_id" = "$BUNDLE_ID" ] || die "staged app claims bundle id '$staged_id', expected $BUNDLE_ID." |
|
|
|
staged_version="$(defaults read "$staged/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || true)" |
|
[ "$staged_version" = "$latest" ] || die "staged app is $staged_version but the release is $latest." |
|
|
|
# `file` rather than `lipo`, which is a Command Line Tools stub on a bare Mac. |
|
file "$staged/Contents/MacOS/MarkEdit" 2>/dev/null | grep -q "$arch" \ |
|
|| die "staged app has no $arch binary." |
|
|
|
codesign --verify --strict "$staged" >/dev/null 2>&1 || die "staged app failed signature verification." |
|
spctl --assess --type execute "$staged" >/dev/null 2>&1 || die "Gatekeeper rejected the staged app." |
|
|
|
# A signing-team change means it is not the same publisher, whatever the bundle id says. |
|
current="$(app_path || true)" |
|
if [ -n "$current" ]; then |
|
old_team="$(codesign -dv "$current" 2>&1 | sed -n 's/^TeamIdentifier=//p')" |
|
new_team="$(codesign -dv "$staged" 2>&1 | sed -n 's/^TeamIdentifier=//p')" |
|
if [ -n "$old_team" ] && [ "$old_team" != "$new_team" ]; then |
|
die "signing team changed ($old_team -> $new_team), refusing to replace the app." |
|
fi |
|
fi |
|
|
|
if [ ! -d "$APP_DIR" ] || [ ! -w "$APP_DIR" ]; then |
|
APP_DIR="$HOME/Applications" |
|
mkdir -p "$APP_DIR" |
|
say " -> /Applications isn't writable, installing to $APP_DIR" |
|
fi |
|
|
|
backup="" |
|
if [ -d "$APP_DIR/MarkEdit.app" ]; then |
|
backup="$WORK_DIR/MarkEdit.app.backup" |
|
mv "$APP_DIR/MarkEdit.app" "$backup" || die "could not move the installed app aside." |
|
fi |
|
|
|
if ! mv "$staged" "$APP_DIR/MarkEdit.app"; then |
|
if [ -n "$backup" ]; then |
|
mv "$backup" "$APP_DIR/MarkEdit.app" || true |
|
die "could not install into $APP_DIR. The previous app was put back." |
|
fi |
|
die "could not install into $APP_DIR." |
|
fi |
|
say " -> installed $latest into $APP_DIR" |
|
} |
|
|
|
if command -v brew >/dev/null 2>&1; then |
|
install_app_brew |
|
else |
|
install_app_direct |
|
fi |
|
|
|
installed_version="$(app_version)" |
|
if [ -n "$installed_version" ]; then |
|
say " -> MarkEdit $installed_version" |
|
fi |
|
|
|
# --- Extensions --- |
|
# The sandbox container is created by macOS on first launch. Making it by hand invites the |
|
# container manager to move it aside, so launch the app once and wait for it instead. |
|
if [ ! -d "$CONTAINER" ]; then |
|
say "Waiting for MarkEdit's container (first launch)..." |
|
open -g -a MarkEdit >/dev/null 2>&1 || true |
|
waited=0 |
|
while [ ! -d "$CONTAINER" ] && [ "$waited" -lt 30 ]; do |
|
sleep 1 |
|
waited=$((waited + 1)) |
|
done |
|
fi |
|
[ -d "$CONTAINER" ] || die "MarkEdit's container never showed up. Launch MarkEdit once, then rerun." |
|
|
|
SCRIPTS_DIR="$CONTAINER/scripts" |
|
mkdir -p "$SCRIPTS_DIR" |
|
records="" |
|
|
|
for id in $EXTENSIONS; do |
|
entry="$WORK_DIR/$id.json" |
|
fetch -o "$entry" "$REGISTRY_BASE/extensions/$id.json" \ |
|
|| die "$id is not in the registry at $REGISTRY_BASE." |
|
|
|
version="$(json_first "$entry" version)" |
|
url="$(json_first "$entry" url)" |
|
sha="$(json_first "$entry" sha256)" |
|
min_app="$(json_first "$entry" minAppVersion)" |
|
|
|
[ -n "$version" ] && [ -n "$url" ] && [ -n "$sha" ] \ |
|
|| die "registry entry for $id is missing version, url, or sha256." |
|
case "$url" in |
|
https://*) ;; |
|
*) die "registry entry for $id has a non-HTTPS url." ;; |
|
esac |
|
if [ -n "$min_app" ] && [ -n "$installed_version" ] && version_lt "$installed_version" "$min_app"; then |
|
die "$id $version needs MarkEdit $min_app, but $installed_version is installed." |
|
fi |
|
|
|
say "Installing $id $version..." |
|
staged="$WORK_DIR/$id.js" |
|
fetch -o "$staged" "$url" || die "could not download $id from $url." |
|
|
|
got="$(sha_of "$staged")" |
|
[ "$got" = "$sha" ] || die "$id failed its checksum (registry $sha, downloaded $got). Nothing was changed." |
|
|
|
mv "$staged" "$SCRIPTS_DIR/$id.js" |
|
say " -> $SCRIPTS_DIR/$id.js" |
|
|
|
record=" {$NL \"id\": \"$id\",$NL \"version\": \"$version\",$NL \"url\": \"$url\",$NL \"sha256\": \"$sha\",$NL \"file\": \"$id.js\",$NL \"enabled\": true,$NL \"installDate\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"$NL }" |
|
if [ -n "$records" ]; then |
|
records="$records,$NL$record" |
|
else |
|
records="$record" |
|
fi |
|
done |
|
|
|
# --- extensions.json --- |
|
# Recording version, url, and sha256 is what lets MarkEdit's own updater take over from here. |
|
# Without it the app adopts the files as untracked, and never offers an update for them. |
|
# |
|
# Only written when this script owns every record, since merging JSON without a parser would |
|
# be a good way to eat somebody's manager-installed extension. |
|
EXTENSIONS_JSON="$CONTAINER/extensions.json" |
|
write_json=1 |
|
if [ -f "$EXTENSIONS_JSON" ]; then |
|
for found in $(LC_ALL=C sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$EXTENSIONS_JSON"); do |
|
case " $EXTENSIONS " in |
|
*" $found "*) ;; |
|
*) write_json=0 ;; |
|
esac |
|
done |
|
fi |
|
|
|
if [ "$write_json" = "1" ]; then |
|
printf '{\n "$schema": "%s",\n "registry.url": "%s",\n "registry.updateBehavior": "quiet",\n "installed": [\n%s\n ]\n}\n' \ |
|
"$SCHEMA_URL" "$REGISTRY_INDEX" "$records" > "$EXTENSIONS_JSON.tmp" |
|
mv "$EXTENSIONS_JSON.tmp" "$EXTENSIONS_JSON" |
|
say " -> recorded versions in extensions.json, so the app can update them itself" |
|
else |
|
say " -> left extensions.json alone, it tracks extensions this script didn't install" |
|
fi |
|
|
|
# --- MarkEdit-theming (opt-in) --- |
|
# A library for building themes, not an extension. Skipped on a bare Mac on purpose: cloning |
|
# needs git, and /usr/bin/git is a stub that pops the Command Line Tools installer. |
|
if [ -d "$THEMING_DIR" ] || [ "${MARKEDIT_THEMING:-}" = "1" ]; then |
|
if xcode-select -p >/dev/null 2>&1; then |
|
say "Installing MarkEdit-theming (library)..." |
|
if [ -d "$THEMING_DIR" ]; then |
|
git -C "$THEMING_DIR" pull --ff-only >/dev/null 2>&1 || say " -> pull skipped (local changes?)" |
|
else |
|
git clone --depth 1 https://github.com/MarkEdit-app/MarkEdit-theming.git "$THEMING_DIR" >/dev/null 2>&1 \ |
|
|| say " -> clone failed, skipping" |
|
fi |
|
if [ -d "$THEMING_DIR" ]; then |
|
say " -> $THEMING_DIR" |
|
fi |
|
else |
|
say "Skipping MarkEdit-theming, no command line tools for git." |
|
fi |
|
fi |
|
|
|
# --- Default editor for Markdown --- |
|
# Installing the app is what associates .md: MarkEdit declares net.daringfireball.markdown in |
|
# its own bundle. duti's role writes do not persist on current macOS (they exit 0 and change |
|
# nothing), so only attempt them when duti is already here, and trust LaunchServices for the |
|
# answer rather than claiming success. |
|
handler="" |
|
if command -v duti >/dev/null 2>&1; then |
|
for uti in net.daringfireball.markdown app.markedit.md app.markedit.markdown; do |
|
duti -s "$BUNDLE_ID" "$uti" all >/dev/null 2>&1 || true |
|
done |
|
handler="$(duti -x md 2>/dev/null | head -1 || true)" |
|
fi |
|
|
|
case "$handler" in |
|
MarkEdit.app) say "Markdown: .md opens in MarkEdit." ;; |
|
'') say "Markdown: .md should open in MarkEdit now. If it doesn't, Get Info on any .md file in Finder, then Open With, MarkEdit, Change All." ;; |
|
*) say "Markdown: .md currently opens in $handler. To switch, Get Info on any .md file in Finder, then Open With, MarkEdit, Change All." ;; |
|
esac |
|
|
|
# --- Demo document --- |
|
# A first run has nothing to look at, and MarkEdit ships no sample of its own, so leave one |
|
# behind: it doubles as the shortcut reference and a tour of what the preview renders. |
|
open_demo="" |
|
demo_path="$DEMO_DIR/$DEMO_NAME" |
|
if [ "$fresh_install" = "1" ] || [ "${MARKEDIT_DEMO:-}" = "1" ]; then |
|
if [ ! -f "$demo_path" ]; then |
|
mkdir -p "$DEMO_DIR" |
|
if fetch -o "$demo_path.tmp" "$DEMO_URL"; then |
|
mv "$demo_path.tmp" "$demo_path" |
|
else |
|
rm -f "$demo_path.tmp" |
|
say " -> couldn't fetch the demo document, skipping it" |
|
fi |
|
fi |
|
if [ -f "$demo_path" ]; then |
|
open_demo="$demo_path" |
|
fi |
|
fi |
|
|
|
# Bring the app back if we closed it, and hand over the demo when there is one. |
|
if [ -n "$open_demo" ]; then |
|
open -a MarkEdit "$open_demo" >/dev/null 2>&1 || true |
|
elif [ "$relaunch_after" = "1" ]; then |
|
open -a MarkEdit >/dev/null 2>&1 || true |
|
fi |
|
|
|
say "" |
|
if [ -n "$open_demo" ]; then |
|
say "Done. Opened \"$DEMO_NAME\": the shortcuts, the view modes, and what the preview renders." |
|
say " $demo_path" |
|
elif [ "$relaunch_after" = "1" ]; then |
|
say "Done. MarkEdit is back up, with the extensions loaded." |
|
elif pgrep -x MarkEdit >/dev/null 2>&1; then |
|
say "Done. Restart MarkEdit to load the extensions:" |
|
say " osascript -e 'quit app \"MarkEdit\"' -e 'delay 1' -e 'launch app \"MarkEdit\"'" |
|
else |
|
say "Done. The extensions load the next time MarkEdit starts." |
|
fi |
|
say " Extensions: $SCRIPTS_DIR" |
|
say "" |
|
say "Notes:" |
|
say " - markedit-ai-writer needs Apple Intelligence enabled." |
|
say " - More extensions and themes: https://markedit-app.github.io/extensions" |