#!/bin/sh # Yunagi installer for macOS and Linux. # # curl -fsSL https://getyunagi.com/install.sh | sh # # What it does: docs/release/07-build-and-distribution.md section 4.1. # Environment variables (all optional): # YUNAGI_VERSION version to install (default: the "version" in latest.json). "v1.2.3" and "1.2.3" both work. # YUNAGI_BASE_URL where release files are (default: GitHub Releases). Files are read from /v/. # YUNAGI_LATEST_URL where latest.json is (default: the official site). # # The whole script is wrapped in main(), which is called on the last line, so a download that is # cut off halfway never runs a half-written command. # POSIX sh only (no [[ ]], no pipefail, no local). set -eu # Where release files and latest.json are read from (docs/release/07 sections 2 and 4.1). DEFAULT_BASE_URL='https://github.com/misty-swan/yunagi/releases/download' DEFAULT_LATEST_URL='https://getyunagi.com/latest.json' APP_ID='io.github.misty-swan.yunagi' TMP_DIR='' INSTALL_ROOT='' OS='' ARCH='' say() { printf '%s\n' "$*" } die() { printf 'install.sh: %s\n' "$*" >&2 exit 1 } cleanup() { if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then rm -rf "$TMP_DIR" fi # Temporary files of a copy that did not finish (after a successful rename they no longer exist). if [ -n "$INSTALL_ROOT" ]; then rm -f "$INSTALL_ROOT/.Yunagi.AppImage.new" "$INSTALL_ROOT/bin/.yunagi.new" fi } need() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but was not found." } # Sets OS (macos|linux) and ARCH (x86_64|arm64). Unsupported combinations stop here. detect_platform() { uname_s=$(uname -s) uname_m=$(uname -m) case "$uname_s" in Darwin) OS=macos ;; Linux) OS=linux ;; *) die "unsupported OS: $uname_s (supported: macOS, Linux. On Windows use install.ps1)." ;; esac case "$uname_m" in x86_64 | amd64) ARCH=x86_64 ;; arm64 | aarch64) ARCH=arm64 ;; *) die "unsupported architecture: $uname_m (supported: x86_64, arm64)." ;; esac # A shell running under Rosetta on an Apple Silicon Mac says x86_64. Install the native build. if [ "$OS" = macos ] && [ "$ARCH" = x86_64 ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" = 1 ]; then ARCH=arm64 fi } fetch() { # fetch URL OUTPUT_FILE curl -fsSL --retry 2 -o "$2" "$1" || die "could not download $1" } # Prints the version to install, without a leading "v". resolve_version() { version=${YUNAGI_VERSION:-} if [ -z "$version" ]; then latest_url=${YUNAGI_LATEST_URL:-$DEFAULT_LATEST_URL} fetch "$latest_url" "$TMP_DIR/latest.json" version=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$TMP_DIR/latest.json" | head -n 1 | sed 's/.*:[[:space:]]*"\([^"]*\)"/\1/') [ -n "$version" ] || die "could not read the version from $latest_url" fi version=${version#v} case "$version" in '' | *[!0-9A-Za-z.+-]* | [!0-9]*) die "invalid version: $version" ;; esac printf '%s\n' "$version" } sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' else die "'sha256sum' or 'shasum' is required but was not found." fi } # download_verified NAME: downloads NAME and NAME.sha256 into TMP_DIR and stops when the hash differs. download_verified() { name=$1 base=${YUNAGI_BASE_URL:-$DEFAULT_BASE_URL} base=${base%/} # a trailing slash would make "//" in the URL fetch "$base/v$VERSION/$name" "$TMP_DIR/$name" fetch "$base/v$VERSION/$name.sha256" "$TMP_DIR/$name.sha256" expected=$(awk '{print tolower($1); exit}' "$TMP_DIR/$name.sha256") case "$expected" in *[!0-9a-f]* | '') die "$name.sha256 does not hold a SHA-256 hash." ;; esac [ "${#expected}" -eq 64 ] || die "$name.sha256 does not hold a SHA-256 hash." actual=$(sha256_of "$TMP_DIR/$name") if [ "$actual" != "$expected" ]; then die "SHA-256 mismatch for $name (expected $expected, got $actual). Nothing was installed." fi say "Verified $name" } # Makes LINK point at TARGET. Refuses to replace a file that is not a link. make_link() { target=$1 link=$2 mkdir -p "$(dirname "$link")" if [ -e "$link" ] && [ ! -L "$link" ]; then die "$link already exists and is not a link. Move it away and run this again." fi rm -f "$link" ln -s "$target" "$link" } install_linux() { root="$HOME/.local/opt/yunagi" appimage="Yunagi_${VERSION}_${ARCH}.AppImage" cli="yunagi-cli_${VERSION}_${ARCH}.tar.gz" download_verified "$appimage" download_verified "$cli" # Check what is inside before anything is placed. # (Unpack everything so that both "yunagi" and "./yunagi" as the member name work.) mkdir -p "$TMP_DIR/cli" tar -xzf "$TMP_DIR/$cli" -C "$TMP_DIR/cli" || die "could not unpack $cli" [ -f "$TMP_DIR/cli/yunagi" ] || die "$cli does not hold the 'yunagi' command." mkdir -p "$root/bin" # Write both next to their final places first, then rename both. A running app is never overwritten # in place, and a failure while copying (for example a full disk) leaves the old install as it was. INSTALL_ROOT=$root cp "$TMP_DIR/$appimage" "$root/.Yunagi.AppImage.new" cp "$TMP_DIR/cli/yunagi" "$root/bin/.yunagi.new" chmod 755 "$root/.Yunagi.AppImage.new" "$root/bin/.yunagi.new" mv -f "$root/.Yunagi.AppImage.new" "$root/Yunagi.AppImage" mv -f "$root/bin/.yunagi.new" "$root/bin/yunagi" data_home=${XDG_DATA_HOME:-$HOME/.local/share} case "$data_home" in /*) ;; *) data_home="$HOME/.local/share" ;; # XDG says a relative path must be ignored esac desktop="$data_home/applications/$APP_ID.desktop" desktop_note='' case "$root" in *[\"\\\$\`%]*) # These characters need escaping rules in the Exec line that differ between launchers. Skip the entry. desktop='' desktop_note="No desktop entry was made because $root contains a character that the desktop entry format treats specially (one of \" \\ \$ \` %)." ;; *) mkdir -p "$data_home/applications" printf '%s\n' \ '[Desktop Entry]' \ 'Type=Application' \ 'Name=Yunagi' \ 'Comment=Daily work note for engineers' \ "Exec=\"$root/Yunagi.AppImage\"" \ 'Terminal=false' \ 'Categories=Office;Utility;' \ >"$desktop.new" mv -f "$desktop.new" "$desktop" ;; esac make_link "$root/bin/yunagi" "$HOME/.local/bin/yunagi" say '' say "Installed Yunagi $VERSION:" say " app $root/Yunagi.AppImage" say " command $root/bin/yunagi" say " link $HOME/.local/bin/yunagi" if [ -n "$desktop" ]; then say " desktop entry $desktop" else say "$desktop_note" fi # An AppImage needs FUSE to start. Only warn: the installation itself is complete. if ! command -v fusermount >/dev/null 2>&1 && ! command -v fusermount3 >/dev/null 2>&1; then say '' say 'Note: FUSE (fusermount) was not found. An AppImage cannot start without it.' say 'Install your distribution'"'"'s FUSE 2 package (for example libfuse2 or fuse2), or start the app with' say " $root/Yunagi.AppImage --appimage-extract-and-run" fi } install_macos() { app="Yunagi_${VERSION}_${ARCH}.app.tar.gz" download_verified "$app" mkdir -p "$TMP_DIR/stage" tar -xzf "$TMP_DIR/$app" -C "$TMP_DIR/stage" || die "could not unpack $app" [ -x "$TMP_DIR/stage/Yunagi.app/Contents/MacOS/yunagi" ] || die "$app does not hold Yunagi.app with the 'yunagi' command." mkdir -p "$HOME/Applications" # Keep the old app aside until the new one is in place, so a failed move does not lose a working install. dest="$HOME/Applications/Yunagi.app" rm -rf "$dest.old" if [ -e "$dest" ]; then mv "$dest" "$dest.old" || die "could not replace $dest (is Yunagi running?). Quit it and run this again." fi if ! mv "$TMP_DIR/stage/Yunagi.app" "$dest"; then rm -rf "$dest" if [ -e "$dest.old" ]; then mv "$dest.old" "$dest" fi die "could not move Yunagi.app into $HOME/Applications. The previous install was kept." fi rm -rf "$dest.old" make_link "$HOME/Applications/Yunagi.app/Contents/MacOS/yunagi" "$HOME/.local/bin/yunagi" say '' say "Installed Yunagi $VERSION:" say " app $HOME/Applications/Yunagi.app" say " command $HOME/Applications/Yunagi.app/Contents/MacOS/yunagi" say " link $HOME/.local/bin/yunagi" } # True when DIR is one of the entries of PATH. in_path() { case ":${PATH:-}:" in *":$1:"* | *":$1/:"*) return 0 ;; *) return 1 ;; esac } path_hint() { if in_path "$HOME/.local/bin"; then return 0 fi say '' say "$HOME/.local/bin is not in your PATH, so 'yunagi' will not be found yet." say 'Add this line to your shell startup file (for example ~/.profile or ~/.zshrc), then open a new terminal:' say ' export PATH="$HOME/.local/bin:$PATH"' } # Same test as the app: docs/design/04-interaction-spec.md (OS-wide shortcut). is_wayland() { session=${XDG_SESSION_TYPE:-} if [ "$session" = wayland ]; then return 0 fi if [ "$session" != x11 ] && [ -n "${WAYLAND_DISPLAY:-}" ]; then return 0 fi return 1 } wayland_note() { if in_path "$HOME/.local/bin"; then cmd='yunagi show' else cmd="$HOME/.local/bin/yunagi show" fi say '' say 'On Wayland the shortcut Mod+Shift+D may not work. If it does not, assign the command' say " $cmd" say "to a key of your choice in your desktop environment's keyboard settings:" say ' GNOME: Settings > Keyboard > Keyboard Shortcuts > Custom Shortcuts' say ' KDE: System Settings > Keyboard > Shortcuts > Add Custom Shortcut' say ' Hyprland, Sway, etc.: add a key binding (bind) to your config file' say "To open the note mode directly, assign another key to '$cmd --note'." say "A key binding often does not inherit your shell's PATH. If it says the command is not found," say "write the full path: $HOME/.local/bin/yunagi show" } main() { need uname need curl need tar need awk need grep need sed need head need mktemp need mkdir need cp need mv need rm need ln need chmod need dirname detect_platform TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/yunagi-install.XXXXXX") || die 'could not create a temporary directory.' trap cleanup EXIT trap 'exit 1' HUP INT TERM [ -n "${HOME:-}" ] || die 'HOME is not set.' VERSION=$(resolve_version) say "Installing Yunagi $VERSION ($OS, $ARCH)" case "$OS" in linux) install_linux ;; macos) install_macos ;; esac path_hint if [ "$OS" = linux ] && is_wayland; then wayland_note fi } main "$@"