#!/bin/bash # # Zeku for macOS and Linux: the one-line installer. # # curl -fsSL https://zeku.dev/install.sh | bash # # On a Mac, in order: # # 1. Checks that this is a Mac running macOS 10.15 or later, and stops if it is not. # 2. Makes a new temporary folder and downloads the current Zeku disk image into it from https://releases.zeku.dev/download/mac. # 3. Mounts the disk image read-only inside that folder, where Finder does not show it, copies Zeku.app out, and unmounts it. # 4. If Zeku is open, asks it to quit, the way Cmd+Q does, and waits for it to close. # 5. Puts Zeku.app in /Applications, or in ~/Applications if your account cannot write to /Applications. An older Zeku.app already there is moved into the temporary folder first. It never uses sudo or asks for your password. # 6. Removes the quarantine attribute from Zeku.app, in case anything added one. # 7. Opens Zeku, then deletes the temporary folder, and the old Zeku.app with it. # # It deletes nothing except its own temporary folder and the old Zeku.app it replaces. If a step fails, it stops, cleans up, and leaves any Zeku you already had where it was. # # Why this skips the "could not verify" block: macOS runs that check only on apps that carry the quarantine attribute, which browsers add to the files they download and curl does not. It is the same app as the .dmg on https://zeku.dev/download. # # On Linux, in order: # # 1. Checks that this is an x86_64 computer with glibc 2.35 or later (Ubuntu 22.04, Debian 12, Fedora 36, current Arch, or newer), and stops if it is not. There is no ARM build yet. # 2. Downloads the current Zeku AppImage from https://releases.zeku.dev/download/linux into ~/.local/share/zeku under a temporary name, and checks that it is an AppImage that runs here. # 3. Copies Zeku's icon out of the AppImage, by way of a new temporary folder. # 4. Renames the download to ~/.local/share/zeku/Zeku.AppImage, replacing the one there. A Zeku that is open keeps running from the old copy until you quit it. # 5. Writes ~/.local/share/zeku/zeku, a small launcher for the AppImage. Where FUSE is not set up, the launcher has the AppImage unpack itself to a temporary folder and run from there (APPIMAGE_EXTRACT_AND_RUN), which works without installing anything and starts a few seconds slower. # 6. Adds Zeku to your applications menu with ~/.local/share/applications/dev.zeku.desktop, and links ~/.local/bin/zeku to the launcher, so zeku works in a terminal. # 7. Opens Zeku, unless it is already open, then deletes the temporary folder. # # It never uses sudo, and it writes nothing outside ~/.local (or $XDG_DATA_HOME) and its temporary folder. If a step fails, it stops, cleans up, and leaves any Zeku you already had where it was. From then on Zeku updates itself, the way it does on Windows and macOS. For testing only, ZEKU_APPIMAGE_URL downloads the AppImage from another address. set -euo pipefail readonly DMG_URL="https://releases.zeku.dev/download/mac" readonly BUNDLE_ID="dev.zeku" work="" # the temporary folder, once it exists volume="" # where the disk image is mounted, while it is keep="" # set when the temporary folder has to outlive the installer reported="" # set once a failure has been explained installed="" # set once the new Zeku.app is in place say() { printf '%s\n' "$*" } fail() { reported=1 printf '%s\n' "$*" >&2 exit 1 } detach() { hdiutil detach "$1" -quiet 2>/dev/null || hdiutil detach "$1" -force -quiet 2>/dev/null } # Runs on every exit, including a failure or Ctrl+C. cleanup() { local status=$? # Never delete through a disk image that is still mounted. if [ -n "$volume" ] && ! detach "$volume"; then keep=1 fi if [ -n "$work" ] && [ -z "$keep" ]; then rm -rf "$work" 2>/dev/null || true fi if [ "$status" -ne 0 ] && [ -z "$reported" ] && [ -z "$installed" ]; then printf '%s\n' "The installer stopped before it finished, so Zeku was not installed." >&2 fi } check_system() { if [ "$(uname -s)" != "Darwin" ]; then fail "This installer is for macOS and Linux. Zeku for Windows is at https://zeku.dev/download." fi if [ "$(id -u)" -eq 0 ]; then fail "Run this without sudo. It installs Zeku for your account and never needs your password." fi local version major minor version=$(sw_vers -productVersion) IFS=. read -r major minor _ <<< "$version" if [ "$major" -lt 10 ] || { [ "$major" -eq 10 ] && [ "${minor:-0}" -lt 15 ]; }; then fail "Zeku needs macOS 10.15 Catalina or later, and this Mac has macOS $version." fi } # The version in an app's Info.plist, or nothing. app_version() { /usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$1/Contents/Info.plist" 2>/dev/null || true } # True while this account has Zeku open, from any copy of Zeku.app. zeku_running() { pgrep -U "$UID" -f '/Zeku\.app/Contents/MacOS/' >/dev/null 2>&1 } # Asks Zeku to quit and waits up to ten seconds for it to close. Each Zeku window can be a process of its own, and one quit reaches one of them, so it asks again until none is left. quit_zeku() { zeku_running || return 0 say "Quitting Zeku..." local error="" for _ in 1 2 3 4 5; do error=$(osascript -e "tell application id \"$BUNDLE_ID\" to quit" 2>&1 >/dev/null) || true for _ in 1 2 3 4; do zeku_running || return 0 sleep 0.5 done done fail "Zeku is still open. Quit it, then run this command again.${error:+ (macOS said: $error)}" } main() { check_system say "Installing Zeku for macOS." local tmp="${TMPDIR:-/tmp}" work=$(mktemp -d "${tmp%/}/zeku-install.XXXXXX") trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' HUP TERM mkdir "$work/volume" "$work/new" "$work/old" say "Downloading Zeku from releases.zeku.dev..." curl -fL --proto '=https' --retry 3 --progress-bar -o "$work/Zeku.dmg" "$DMG_URL" \ || fail "Could not download Zeku. Check your connection, then run this command again." # hdiutil verifies the image's checksums as it mounts it, so a damaged download stops here. say "Checking the disk image..." hdiutil attach -nobrowse -readonly -noautoopen -mountpoint "$work/volume" "$work/Zeku.dmg" >/dev/null \ || fail "Could not open the disk image. Run this command again to download a fresh copy." volume="$work/volume" [ -d "$volume/Zeku.app" ] || fail "The disk image does not contain Zeku.app." ditto "$volume/Zeku.app" "$work/new/Zeku.app" || fail "Could not copy Zeku out of the disk image." detach "$volume" && volume="" local version dest app old version=$(app_version "$work/new/Zeku.app") dest="/Applications" if [ ! -w "$dest" ]; then dest="$HOME/Applications" mkdir -p "$dest" fi app="$dest/Zeku.app" quit_zeku # Swap by renaming, so there is never a half-copied Zeku.app, and a failure puts the old one back. if [ -e "$app" ]; then old=$(app_version "$app") say "Replacing Zeku${old:+ $old} in $dest." mv "$app" "$work/old/" || fail "Could not replace $app. Move it to the Trash, then run this command again." fi if ! mv "$work/new/Zeku.app" "$app"; then if [ -e "$work/old/Zeku.app" ] && ! mv "$work/old/Zeku.app" "$app"; then keep=1 fail "Could not put Zeku in $dest. Your previous Zeku.app is in $work/old." fi fail "Could not put Zeku in $dest." fi installed=1 # Nothing above adds a quarantine attribute, but if anything ever did, macOS would block the first launch. xattr -dr com.apple.quarantine "$app" 2>/dev/null || true say "Installed Zeku${version:+ $version} in $dest." if open "$app" 2>/dev/null; then say "Opening Zeku." else say "Open Zeku from $dest to start writing." fi } # Linux -------------------------------------------------------------------- readonly APPIMAGE_URL="https://releases.zeku.dev/download/linux" readonly MIN_GLIBC="2.35" partial="" # the AppImage while it downloads, next to where it goes # The test the launcher makes every time it starts Zeku, kept in one place so the installer asks the same question. An AppImage mounts itself with FUSE, which takes /dev/fuse and a fusermount to mount it without root, and the runtime some AppImages carry also loads libfuse.so.2. The pipes read to the end rather than stopping at the first match, so pipefail cannot turn a match into a failure. Defined when the Linux installer starts rather than as the script loads, so nothing runs before the whole script has arrived. define_fuse_test() { read -r -d '' FUSE_TEST <<'EOF' || true fuse_ok() { [ -c /dev/fuse ] && [ -r /dev/fuse ] && [ -w /dev/fuse ] || return 1 command -v fusermount3 > /dev/null 2>&1 || command -v fusermount > /dev/null 2>&1 || return 1 if head -c 1048576 "$1" 2> /dev/null | grep -a 'libfuse\.so\.2' > /dev/null; then { ldconfig -p 2> /dev/null || /sbin/ldconfig -p 2> /dev/null; } | grep 'libfuse\.so\.2' > /dev/null \ || [ -e /usr/lib/x86_64-linux-gnu/libfuse.so.2 ] || [ -e /lib/x86_64-linux-gnu/libfuse.so.2 ] \ || [ -e /usr/lib64/libfuse.so.2 ] || [ -e /usr/lib/libfuse.so.2 ] || return 1 fi return 0 } EOF eval "$FUSE_TEST" } # Runs on every exit on Linux, including a failure or Ctrl+C. linux_cleanup() { local status=$? if [ -n "$partial" ]; then rm -f "$partial" 2>/dev/null || true fi if [ -n "$work" ]; then rm -rf "$work" 2>/dev/null || true fi if [ "$status" -ne 0 ] && [ -z "$reported" ] && [ -z "$installed" ]; then printf '%s\n' "The installer stopped before it finished, so Zeku was not installed." >&2 fi } # True when dotted version $1, such as 2.31, is older than $2. older_than() { local a1 a2 b1 b2 IFS=. read -r a1 a2 _ <<< "$1." IFS=. read -r b1 b2 _ <<< "$2." [ "${a1:-0}" -lt "${b1:-0}" ] || { [ "${a1:-0}" -eq "${b1:-0}" ] && [ "${a2:-0}" -lt "${b2:-0}" ]; } } check_linux() { if [ "$(id -u)" -eq 0 ]; then fail "Run this without sudo. It installs Zeku for your account and never needs your password." fi local arch glibc arch=$(uname -m) case "$arch" in x86_64 | amd64) ;; aarch64 | arm64 | armv*) fail "Zeku for Linux is x86_64 only for now, and this computer is ARM ($arch). There is no ARM build yet." ;; *) fail "Zeku for Linux is x86_64 only for now, and this computer is $arch." ;; esac glibc=$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{ print $2 }') || true if [ -z "$glibc" ]; then fail "Zeku needs a Linux distribution built on glibc $MIN_GLIBC or later, such as Ubuntu 22.04, Debian 12, Fedora 36, or current Arch." fi if older_than "$glibc" "$MIN_GLIBC"; then fail "Zeku needs glibc $MIN_GLIBC or later (Ubuntu 22.04, Debian 12, Fedora 36, current Arch, or newer), and this system has glibc $glibc." fi command -v curl >/dev/null 2>&1 || fail "This installer needs curl. Install it, then run this command again." } # True when $1 starts like a type 2 AppImage: an ELF executable with "AI" and 2 at byte 8. is_appimage() { [ "$(head -c 4 "$1" 2>/dev/null | od -An -tx1 | tr -d ' \n')" = "7f454c46" ] \ && [ "$(head -c 11 "$1" 2>/dev/null | tail -c 3 | od -An -tx1 | tr -d ' \n')" = "414902" ] } # Copies the largest icon inside the AppImage to $2. An AppImage unpacks files from itself without FUSE. Best effort: without an icon, the menu shows a generic one. copy_icon() { local best="" size=0 f s (cd "$work" && "$1" --appimage-extract 'usr/share/icons/*' >/dev/null 2>&1) || return 0 for f in "$work"/squashfs-root/usr/share/icons/hicolor/*/apps/*.png; do [ -f "$f" ] || continue s=$(( $(wc -c < "$f") )) if [ "$s" -gt "$size" ]; then size=$s best=$f fi done if [ -n "$best" ]; then cp "$best" "$2.new" && mv -f "$2.new" "$2" fi return 0 } # $1 in single quotes, for a shell script. sed rather than a pattern substitution, because quotes inside ${...} parse differently in the bash 3.2 a Mac runs this with, and the whole script has to parse there. sh_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" } # $1 as the Exec key of a desktop entry wants it: in double quotes with \ " ` and $ escaped, then every backslash doubled again for the file's own string escapes. exec_quote() { printf '"%s"' "$(printf '%s' "$1" | sed -e 's/[\\"`$]/\\&/g' -e 's/\\/\\\\/g')" } # Writes $2 to $1 through a temporary file beside it, so nothing ever reads half a file. write_file() { printf '%s\n' "$2" > "$1.new" && mv -f "$1.new" "$1" } # True while this account has Zeku open, found by its process name. Read from /proc directly, so it needs nothing installed. A process that has exited but not been reaped keeps its name and loses its executable, so it does not count. zeku_open() { local p name for p in /proc/[0-9]*; do [ -O "$p" ] || continue name="" read -r name < "$p/comm" 2>/dev/null || true [ "$name" = "zeku" ] && [ -e "$p/exe" ] && return 0 done return 1 } # One line on getting FUSE for this distribution, or nothing when it is not one this knows. The AppImage's runtime is static and needs no libfuse2, only a fusermount, which fuse3 provides. fuse_hint() { local id="" like="" if [ -r /etc/os-release ]; then id=$(. /etc/os-release && printf '%s' "${ID:-}") || true like=$(. /etc/os-release && printf '%s' "${ID_LIKE:-}") || true fi case " $id $like " in *" arch "*) printf '%s' "sudo pacman -S fuse3" ;; *" fedora "* | *" rhel "* | *" centos "*) printf '%s' "sudo dnf install fuse3" ;; *" suse "* | *" opensuse "*) printf '%s' "sudo zypper install fuse3" ;; *" debian "* | *" ubuntu "*) printf '%s' "sudo apt install fuse3" ;; esac } linux_main() { check_linux define_fuse_test say "Installing Zeku for Linux." local data="${XDG_DATA_HOME:-}" case "$data" in /*) ;; *) data="$HOME/.local/share" ;; esac local dir="$data/zeku" local app="$dir/Zeku.AppImage" launcher="$dir/zeku" icon="$dir/zeku.png" local apps="$data/applications" bin="$HOME/.local/bin" local desktop="$apps/dev.zeku.desktop" local tmp="${TMPDIR:-/tmp}" work=$(mktemp -d "${tmp%/}/zeku-install.XXXXXX") trap linux_cleanup EXIT trap 'exit 130' INT trap 'exit 143' HUP TERM # Downloaded beside where it goes, so putting it in place is a rename within one filesystem, which is instant and never leaves half a file. mkdir -p "$dir" || fail "Could not create $dir." partial=$(mktemp "$dir/.Zeku.AppImage.XXXXXX") || fail "Could not write to $dir." local url="$APPIMAGE_URL" proto="=https" from="releases.zeku.dev" if [ -n "${ZEKU_APPIMAGE_URL:-}" ]; then url="$ZEKU_APPIMAGE_URL" proto="=https,http,file" from="$url (ZEKU_APPIMAGE_URL, for testing)" fi say "Downloading Zeku from $from..." local final final=$(curl -fL --proto "$proto" --retry 3 --progress-bar -w '%{url_effective}' -o "$partial" "$url") \ || fail "Could not download Zeku. Check your connection, then run this command again." is_appimage "$partial" || fail "The download is not a Zeku AppImage. Run this command again to download a fresh copy." chmod 755 "$partial" "$partial" --appimage-version >/dev/null 2>&1 \ || fail "The AppImage does not run from $dir. If your home folder is mounted noexec, Zeku cannot run from it." local version version=$(printf '%s' "$final" | sed -n 's/.*Zeku_\([0-9][0-9.]*[0-9]\)_.*/\1/p') copy_icon "$partial" "$icon" # A Zeku that is open keeps the file it started from, so renaming over it is safe, and the new version starts the next time Zeku does. if [ -e "$app" ]; then say "Replacing the Zeku already in $dir." fi mv -f "$partial" "$app" || fail "Could not put Zeku in $dir." partial="" installed=1 write_file "$launcher" "#!/bin/sh # Starts Zeku. Written by https://zeku.dev/install.sh, which rewrites it on every run. # # Without FUSE, APPIMAGE_EXTRACT_AND_RUN makes the AppImage unpack itself to a temporary folder and run from there: slower to start, but it needs nothing installed, and Zeku's updates still replace the AppImage itself. app=$(sh_quote "$app") $FUSE_TEST if [ -z \"\${APPIMAGE_EXTRACT_AND_RUN:-}\" ] && ! fuse_ok \"\$app\"; then APPIMAGE_EXTRACT_AND_RUN=1 export APPIMAGE_EXTRACT_AND_RUN fi exec \"\$app\" \"\$@\"" || fail "Could not write $launcher." chmod 755 "$launcher" mkdir -p "$apps" write_file "$desktop" "[Desktop Entry] Type=Application Name=Zeku GenericName=Markdown Editor Comment=A native markdown workspace and CMS for Astro sites Exec=$(exec_quote "$launcher") Icon=$icon Terminal=false Categories=Office; Keywords=markdown;astro;writing;cms; StartupWMClass=zeku StartupNotify=true" || fail "Could not add Zeku to your applications menu." if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q "$apps" 2>/dev/null || true fi mkdir -p "$bin" if [ -L "$bin/zeku" ] || [ ! -e "$bin/zeku" ]; then ln -sfn "$launcher" "$bin/zeku" else say "Left $bin/zeku alone, since this installer did not make it." fi say "Installed Zeku${version:+ $version} in $dir." case ":$PATH:" in *":$bin:"*) ;; *) say "$bin is not on your PATH, so in a terminal Zeku is $bin/zeku." ;; esac if ! (set +o pipefail; fuse_ok "$app"); then local hint hint=$(fuse_hint) say "Without FUSE, Zeku unpacks itself each time it starts, which takes a few seconds.${hint:+ For a faster start: $hint}" fi if zeku_open; then say "Zeku is open, so the new version starts the next time you open it." elif [ -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then say "Open Zeku from your applications menu, or run zeku." else if command -v setsid >/dev/null 2>&1; then setsid "$launcher" /dev/null 2>&1 & else nohup "$launcher" /dev/null 2>&1 & fi say "Opening Zeku." fi } # Nothing runs until the whole script has arrived: if the download is cut short, bash stops at the unfinished function or case instead of running half an installer. case "$(uname -s)" in Linux) linux_main ;; *) main ;; esac